import React, { useState, useRef, useCallback, useEffect } from 'react'; import { ChordType, GameState, GameMode, Chord, Interval } from './types'; // FIX: Added NOTE_FREQUENCIES to imports to make it available in the component. import { LEVEL_CONFIG, NOTE_NAMES, INITIAL_LIVES, TIME_PER_QUESTION, POINTS_PER_CORRECT_ANSWER, TIME_BONUS_MULTIPLIER, NOTE_FREQUENCIES } from './constants'; import { formatChordName, getIntervalBySemitones, getChordFrequencies, shuffleArray } from './utils'; // --- Child Components --- interface GameStatsDisplayProps { level: number; score: number; highScore: number; streak: number; lives: number; progress: number; neededToAdvance: number; } const GameStatsDisplay: React.FC = ({ level, score, highScore, streak, lives, progress, neededToAdvance }) => (

🏆 High Score

{highScore}

Puntaje

{score}

Racha

🔥 {streak}

Nivel {level} - Progreso

{'❤️'.repeat(lives)}{'❤️'.repeat(INITIAL_LIVES - lives)}
); const TimerBar: React.FC<{ timeLeft: number, totalTime: number }> = ({ timeLeft, totalTime }) => { const percentage = (timeLeft / totalTime) * 100; const barColor = percentage > 50 ? 'bg-green-500' : percentage > 25 ? 'bg-yellow-500' : 'bg-red-500'; return (
); }; interface GameButtonProps extends React.ButtonHTMLAttributes { variant?: 'primary' | 'secondary' | 'guess'; feedback?: 'correct' | 'incorrect' | 'neutral'; } const GameButton: React.FC = ({ children, variant = 'primary', feedback = 'neutral', className, ...props }) => { const baseClasses = "w-full font-bold py-3 px-6 rounded-lg transition-all duration-200 focus:outline-none focus:ring-4 disabled:opacity-80 disabled:cursor-not-allowed text-lg shadow-lg"; const variantClasses = { primary: 'bg-cyan-500 hover:bg-cyan-400 text-slate-900 focus:ring-cyan-300', secondary: 'bg-slate-700 hover:bg-slate-600 text-white focus:ring-slate-500', guess: 'bg-indigo-600 hover:bg-indigo-500 text-white focus:ring-indigo-400', }; const feedbackClasses = { neutral: '', correct: 'bg-green-600 text-white animate-pop ring-4 ring-green-500', incorrect: 'bg-red-600 text-white animate-shake' } return ; }; const FeedbackDisplay: React.FC<{ message: string; isCorrect: boolean | null }> = ({ message, isCorrect }) => { if (isCorrect === null || !message) return null; const bgColor = isCorrect ? 'bg-green-500/20' : 'bg-red-500/20'; const textColor = isCorrect ? 'text-green-300' : 'text-red-300'; const icon = isCorrect ? '✓' : '✗'; return (

{icon}{message}

); } // --- Main App Component --- export default function App() { const [gameState, setGameState] = useState('MENU'); const [gameMode, setGameMode] = useState(null); const [level, setLevel] = useState(1); const [score, setScore] = useState(0); const [highScore, setHighScore] = useState(0); const [streak, setStreak] = useState(0); const [progress, setProgress] = useState(0); const [lives, setLives] = useState(INITIAL_LIVES); const [timeLeft, setTimeLeft] = useState(TIME_PER_QUESTION); const [currentChord, setCurrentChord] = useState(null); const [currentInterval, setCurrentInterval] = useState(null); const [currentIntervalRoot, setCurrentIntervalRoot] = useState(null); const [options, setOptions] = useState([]); const [userGuess, setUserGuess] = useState(null); const [feedback, setFeedback] = useState(""); const [isCorrect, setIsCorrect] = useState(null); const audioContextRef = useRef(null); const timerRef = useRef(null); useEffect(() => { const storedHighScore = localStorage.getItem('OidoMusicalPro_highScore'); if (storedHighScore) setHighScore(parseInt(storedHighScore, 10)); }, []); useEffect(() => { if (score > highScore) { setHighScore(score); localStorage.setItem('OidoMusicalPro_highScore', score.toString()); } }, [score, highScore]); const getAudioContext = useCallback(() => { if (!audioContextRef.current) { audioContextRef.current = new (window.AudioContext || (window as any).webkitAudioContext)(); } if (audioContextRef.current.state === 'suspended') { audioContextRef.current.resume(); } return audioContextRef.current; }, []); const playUISound = useCallback((type: 'correct' | 'incorrect' | 'levelUp') => { const audioContext = getAudioContext(); const masterGain = audioContext.createGain(); masterGain.gain.setValueAtTime(0.2, audioContext.currentTime); masterGain.connect(audioContext.destination); if (type === 'correct') { const osc = audioContext.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(880, audioContext.currentTime); osc.connect(masterGain); osc.start(); osc.stop(audioContext.currentTime + 0.1); } else if (type === 'incorrect') { const osc = audioContext.createOscillator(); osc.type = 'square'; osc.frequency.setValueAtTime(164, audioContext.currentTime); masterGain.gain.setValueAtTime(0.15, audioContext.currentTime); osc.connect(masterGain); osc.start(); osc.stop(audioContext.currentTime + 0.15); } else if (type === 'levelUp') { [660, 880, 1100].forEach((freq, i) => { const osc = audioContext.createOscillator(); osc.type = 'triangle'; osc.frequency.setValueAtTime(freq, audioContext.currentTime); osc.connect(masterGain); osc.start(audioContext.currentTime + i * 0.1); osc.stop(audioContext.currentTime + i * 0.1 + 0.1); }); } }, [getAudioContext]); const playChord = useCallback((rootNoteName: string, type: ChordType) => { const audioContext = getAudioContext(); const frequencies = getChordFrequencies(NOTE_FREQUENCIES[rootNoteName], type); const duration = 2.0; const masterGain = audioContext.createGain(); masterGain.gain.setValueAtTime(0, audioContext.currentTime); masterGain.gain.linearRampToValueAtTime(0.3, audioContext.currentTime + 0.05); masterGain.gain.setValueAtTime(0.3, audioContext.currentTime + duration - 0.2); masterGain.gain.exponentialRampToValueAtTime(0.0001, audioContext.currentTime + duration); masterGain.connect(audioContext.destination); frequencies.forEach(freq => { const osc = audioContext.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(freq, audioContext.currentTime); osc.connect(masterGain); osc.start(audioContext.currentTime); osc.stop(audioContext.currentTime + duration); }); }, [getAudioContext]); const playInterval = useCallback((rootNoteName: string, semitones: number) => { const audioContext = getAudioContext(); const rootFrequency = NOTE_FREQUENCIES[rootNoteName]; const secondFrequency = rootFrequency * Math.pow(2, semitones / 12); const duration = 0.8; const masterGain = audioContext.createGain(); masterGain.connect(audioContext.destination); [rootFrequency, secondFrequency].forEach((freq, index) => { const osc = audioContext.createOscillator(); osc.type = 'sine'; osc.frequency.setValueAtTime(freq, audioContext.currentTime); const startTime = audioContext.currentTime + index * (duration); const gainNode = audioContext.createGain(); gainNode.gain.setValueAtTime(0, startTime); gainNode.gain.linearRampToValueAtTime(0.5, startTime + 0.05); gainNode.gain.setValueAtTime(0.5, startTime + duration - 0.1); gainNode.gain.exponentialRampToValueAtTime(0.0001, startTime + duration); gainNode.connect(masterGain); osc.connect(gainNode); osc.start(startTime); osc.stop(startTime + duration); }); }, [getAudioContext]); const handleTimeOut = useCallback(() => { if (gameState !== 'PLAYING' || !gameMode) return; playUISound('incorrect'); setStreak(0); let feedbackMessage = "¡Se acabó el tiempo!"; if (gameMode === 'CHORDS' && currentChord) { feedbackMessage += ` El acorde era ${formatChordName(currentChord)}.`; setTimeout(() => playChord(currentChord.root, currentChord.type), 300); } else if (gameMode === 'INTERVALS' && currentInterval && currentIntervalRoot) { feedbackMessage += ` El intervalo era de ${currentInterval.name}.`; setTimeout(() => playInterval(currentIntervalRoot, currentInterval.semitones), 300); } setFeedback(feedbackMessage); setIsCorrect(false); setGameState('GUESSED'); const newLives = lives - 1; setLives(newLives); if (newLives <= 0) { setTimeout(() => setGameState('GAME_OVER'), 2000); } }, [gameState, lives, gameMode, currentChord, currentInterval, currentIntervalRoot, playChord, playInterval]); const clearTimer = useCallback(() => { if (timerRef.current) clearInterval(timerRef.current); timerRef.current = null; }, []); useEffect(() => { if (gameState === 'PLAYING') { timerRef.current = window.setInterval(() => setTimeLeft(prev => prev - 1), 1000); } else { clearTimer(); } return clearTimer; }, [gameState, clearTimer]); useEffect(() => { if (timeLeft <= 0) { handleTimeOut(); } }, [timeLeft, handleTimeOut]); const startNewRound = useCallback((currentMode: GameMode, currentLevel: number) => { setFeedback(""); setIsCorrect(null); setUserGuess(null); setTimeLeft(TIME_PER_QUESTION); setGameState('PLAYING'); if (currentMode === 'CHORDS') { const config = LEVEL_CONFIG.CHORDS[Math.min(currentLevel - 1, LEVEL_CONFIG.CHORDS.length - 1)]; const randomRoot = config.roots[Math.floor(Math.random() * config.roots.length)]; const randomType = config.types[Math.floor(Math.random() * config.types.length)] as ChordType; const correctChord: Chord = { root: randomRoot, type: randomType }; const newOptions: Chord[] = [correctChord]; const optionSet = new Set([formatChordName(correctChord)]); while (newOptions.length < 4) { const distractorRoot = config.roots[Math.floor(Math.random() * config.roots.length)]; const distractorType = config.types[Math.floor(Math.random() * config.types.length)] as ChordType; const distractorChord: Chord = { root: distractorRoot, type: distractorType }; const key = formatChordName(distractorChord); if (!optionSet.has(key)) { optionSet.add(key); newOptions.push(distractorChord); } } setOptions(shuffleArray(newOptions)); setCurrentChord(correctChord); playChord(correctChord.root, correctChord.type); } else if (currentMode === 'INTERVALS') { const config = LEVEL_CONFIG.INTERVALS[Math.min(currentLevel - 1, LEVEL_CONFIG.INTERVALS.length - 1)]; const randomSemitones = config.intervals[Math.floor(Math.random() * config.intervals.length)]; const correctInterval = getIntervalBySemitones(randomSemitones)!; const newOptions: Interval[] = [correctInterval]; const optionSet = new Set([correctInterval.name]); while (newOptions.length < 4) { const distractorSemitones = config.intervals[Math.floor(Math.random() * config.intervals.length)]; const distractorInterval = getIntervalBySemitones(distractorSemitones)!; if (!optionSet.has(distractorInterval.name)) { optionSet.add(distractorInterval.name); newOptions.push(distractorInterval); } } setOptions(shuffleArray(newOptions)); setCurrentInterval(correctInterval); const randomRoot = NOTE_NAMES[Math.floor(Math.random() * 7)]; setCurrentIntervalRoot(randomRoot); playInterval(randomRoot, correctInterval.semitones); } }, [playChord, playInterval]); const startGame = (selectedMode: GameMode) => { getAudioContext(); setGameMode(selectedMode); setLevel(1); setScore(0); setStreak(0); setProgress(0); setLives(INITIAL_LIVES); startNewRound(selectedMode, 1); }; const resetGame = () => { setGameState('MENU'); setGameMode(null); setIsCorrect(null); setFeedback(""); }; const handleGuess = (guess: any) => { if (gameState !== 'PLAYING' || !gameMode) return; setUserGuess(guess); setGameState('GUESSED'); let isGuessCorrect = false; let feedbackMessage = ""; if (gameMode === 'CHORDS' && currentChord) { isGuessCorrect = guess.root === currentChord.root && guess.type === currentChord.type; feedbackMessage = isGuessCorrect ? "¡Correcto!" : `Incorrecto. Era un ${formatChordName(currentChord)}.`; } else if (gameMode === 'INTERVALS' && currentInterval) { isGuessCorrect = guess.semitones === currentInterval.semitones; feedbackMessage = isGuessCorrect ? "¡Correcto!" : `Incorrecto. Era un intervalo de ${currentInterval.name}.`; } setIsCorrect(isGuessCorrect); setFeedback(feedbackMessage); if (isGuessCorrect) { playUISound('correct'); const timeBonus = Math.floor(timeLeft * TIME_BONUS_MULTIPLIER); setScore(s => s + (POINTS_PER_CORRECT_ANSWER * level) + timeBonus); setStreak(s => s + 1); const newProgress = progress + 1; const config = LEVEL_CONFIG[gameMode][Math.min(level - 1, LEVEL_CONFIG[gameMode].length - 1)]; if (newProgress >= config.correctToAdvance) { playUISound('levelUp'); const newLevel = level + 1; setLevel(newLevel); setProgress(0); setFeedback(`¡Subiste al Nivel ${newLevel}!`); setTimeout(() => startNewRound(gameMode, newLevel), 2000); } else { setProgress(newProgress); } } else { playUISound('incorrect'); setStreak(0); if (gameMode === 'CHORDS' && currentChord) { setTimeout(() => playChord(currentChord.root, currentChord.type), 300); } else if (gameMode === 'INTERVALS' && currentInterval && currentIntervalRoot) { setTimeout(() => playInterval(currentIntervalRoot, currentInterval.semitones), 300); } const newLives = lives - 1; setLives(newLives); if (newLives <= 0) { setTimeout(() => setGameState('GAME_OVER'), 2000); } } }; const replaySound = () => { if (gameMode === 'CHORDS' && currentChord) { playChord(currentChord.root, currentChord.type); } else if (gameMode === 'INTERVALS' && currentInterval && currentIntervalRoot) { playInterval(currentIntervalRoot, currentInterval.semitones); } } const getButtonFeedback = (option: any): 'correct' | 'incorrect' | 'neutral' => { if(gameState !== 'GUESSED') return 'neutral'; if(gameMode === 'CHORDS') { const isCorrectOption = option.root === currentChord?.root && option.type === currentChord?.type; const isUserGuess = userGuess && option.root === userGuess?.root && option.type === userGuess?.type; if (isCorrectOption) return 'correct'; if (isUserGuess && !isCorrectOption) return 'incorrect'; } if(gameMode === 'INTERVALS') { const isCorrectOption = option.semitones === currentInterval?.semitones; const isUserGuess = userGuess && option.semitones === userGuess?.semitones; if (isCorrectOption) return 'correct'; if (isUserGuess && !isCorrectOption) return 'incorrect'; } return 'neutral'; } const renderGameScreen = () => { if (!gameMode) return null; const config = LEVEL_CONFIG[gameMode][Math.min(level - 1, LEVEL_CONFIG[gameMode].length - 1)]; return ( <>
Repetir Sonido 🎵
{options.map((option, index) => ( handleGuess(option)} variant="guess" disabled={gameState === 'GUESSED'} feedback={getButtonFeedback(option)} > {gameMode === 'CHORDS' ? formatChordName(option) : option.name} ))}
{gameState === 'GUESSED' && lives > 0 && !feedback.includes("Nivel") && ( startNewRound(gameMode, level)} variant="primary"> Siguiente → )}
); }; const renderMenuScreen = () => (

¡Entrena tu oído musical y supera tu récord!

🏆 High Score: {highScore}

startGame('CHORDS')}>Entrenamiento de Acordes startGame('INTERVALS')}>Entrenamiento de Intervalos
); const renderGameOverScreen = () => (

¡Juego Terminado!

Lograste alcanzar el Nivel {level}.

Puntaje Final

{score}

🏆 High Score: {highScore}

{gameMode && startGame(gameMode)}>Jugar de Nuevo} Volver al Menú
); return (

Oído Musical Pro

{gameState === 'GAME_OVER' ? '¡Sigue practicando para mejorar!' : gameMode === 'CHORDS' ? 'Escucha el acorde y adivina cuál es.' : gameMode === 'INTERVALS' ? 'Escucha el intervalo y adivina cuál es.' : 'Elige un modo para empezar a entrenar.'}

{gameState === 'MENU' && renderMenuScreen()} {(gameState === 'PLAYING' || gameState === 'GUESSED') && renderGameScreen()} {gameState === 'GAME_OVER' && renderGameOverScreen()}

Un juego de entrenamiento musical para tu blog.

); }

No hay comentarios.:

Publicar un comentario