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 }) => (
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 {children} ;
};
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 (
);
}
// --- 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 →
)}
Volver al Menú
>
);
};
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 (
{gameState === 'MENU' && renderMenuScreen()}
{(gameState === 'PLAYING' || gameState === 'GUESSED') && renderGameScreen()}
{gameState === 'GAME_OVER' && renderGameOverScreen()}
);
}
No hay comentarios.:
Publicar un comentario