<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <title>Fruit Rhythm Tap! 🎶</title> <style> body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: linear-gradient(to right, #ffecd2 0%, #fcb69f 100%); color: #5D4037; text-align: center; overflow-x: hidden; touch-action: manipulation; padding: 10px 0; } #game-wrapper { background-color: rgba(255, 255, 255, 0.8); /* Slightly more opaque */ padding: 20px 25px; /* Adjusted padding */ border-radius: 20px; box-shadow: 0 8px 25px rgba(0, 0, 0, 0.2); /* Softer shadow */ width: 90%; max-width: 420px; /* Slightly wider for emojis */ } #current-fruit-name { font-size: 2.5em; /* Adjusted for emojis */ font-weight: bold; color: #E64A19; margin-bottom: 8px; height: 55px; /* Increased height for emojis */ line-height: 55px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #current-fruit-taps { font-size: 1.1em; /* Adjusted */ color: #795548; margin-bottom: 20px; height: 20px; } #tap-indicators { display: flex; justify-content: center; align-items: center; margin-bottom: 25px; min-height: 30px; } .indicator-dot { width: 18px; /* Slightly smaller */ height: 18px; background-color: #FFCC80; border: 2px solid #FFA726; border-radius: 50%; margin: 0 5px; /* Adjusted margin */ transition: all 0.2s ease; } .indicator-dot.tapped { background-color: #FFA726; transform: scale(1.25); /* Slightly more pop */ } .indicator-dot.correct { background-color: #81C784; border-color: #4CAF50; } .indicator-dot.incorrect { background-color: #E57373; border-color: #F44336; } #action-button { padding: 15px 35px; /* Adjusted padding */ font-size: 1.5em; /* Adjusted for emojis */ background-color: #FF7043; color: white; border: none; border-radius: 12px; cursor: pointer; box-shadow: 0 5px 12px rgba(0,0,0,0.1); transition: background-color 0.2s, transform 0.1s; user-select: none; -webkit-tap-highlight-color: transparent; width: 85%; /* Adjusted width */ max-width: 280px; /* Adjusted max-width */ } #action-button:active { background-color: #F4511E; transform: scale(0.96); } #action-button:disabled { background-color: #BDBDBD; cursor: not-allowed; } #feedback-message { font-size: 1.25em; /* Adjusted */ margin-top: 20px; height: 30px; font-weight: bold; color: #D32F2F; } #feedback-message.correct { color: #388E3C; } #score-area { margin-top: 18px; /* Adjusted */ font-size: 1.25em; /* Adjusted */ color: #4E342E; } #waveform-canvas { width: 100%; height: 70px; /* Adjusted height */ background-color: rgba(255, 255, 255, 0.2); /* More transparent */ border-radius: 10px; margin-top: 20px; border: 1px solid #FFCC80; } </style> </head> <body> <div id="game-wrapper"> <div id="current-fruit-name">Fruit Rhythm! 🎶</div> <div id="current-fruit-taps">Tap to Start!</div> <div id="tap-indicators"></div> <button id="action-button">▶️ Start Game</button> <div id="feedback-message"></div> <div id="score-area">Score: <span id="score">0</span></div> <canvas id="waveform-canvas"></canvas> </div> <script> const fruitNameDisplay = document.getElementById('current-fruit-name'); const fruitTapsDisplay = document.getElementById('current-fruit-taps'); const tapIndicatorsContainer = document.getElementById('tap-indicators'); const actionButton = document.getElementById('action-button'); const feedbackMessageDisplay = document.getElementById('feedback-message'); const scoreDisplay = document.getElementById('score'); const waveformCanvas = document.getElementById('waveform-canvas'); const canvasCtx = waveformCanvas.getContext('2d'); const FRUITS_RHYTHMS = [ { name: 'PEAR', emoji: '🍐', beats: 1 }, { name: 'PLUM', emoji: '🍑', beats: 1 }, // Using peach as plum often doesn't have its own { name: 'APPLE', emoji: '🍎', beats: 2 }, { name: 'MANGO', emoji: '🥭', beats: 2 }, { name: 'GRAPES', emoji: '🍇', beats: 2 }, { name: 'ORANGE', emoji: '🍊', beats: 2 }, { name: 'BANANA', emoji: '🍌', beats: 3 }, { name: 'COCONUT', emoji: '🥥', beats: 3 }, { name: 'BLUEBERRY', emoji: '🫐', beats: 3 }, // May need good font/OS support { name: 'PINEAPPLE', emoji: '🍍', beats: 4 }, { name: 'WATERMELON', emoji: '🍉', beats: 4 } ]; let currentFruitChallenge = null; let userTapCount = 0; let currentScore = 0; let gameInProgress = false; let tapTimeoutId = null; const TAP_SEQUENCE_TIMEOUT = 1100; // ms const CORRECT_FEEDBACK_DURATION = 1000; // ms const INCORRECT_FEEDBACK_DURATION = 1500; // ms // --- Web Audio API Setup --- let audioCtx; let analyserNode; let waveformDataArray; let waveformBufferLength; function initAudio() { if (!audioCtx) { audioCtx = new (window.AudioContext || window.webkitAudioContext)(); analyserNode = audioCtx.createAnalyser(); analyserNode.fftSize = 2048; waveformBufferLength = analyserNode.frequencyBinCount; waveformDataArray = new Uint8Array(waveformBufferLength); analyserNode.connect(audioCtx.destination); } if (audioCtx.state === 'suspended') { audioCtx.resume(); } } function playSound(type, tapIndex = 0, totalTapsInSequence = 1) { if (!audioCtx) initAudio(); // Ensure audio is initialized if (audioCtx.state === 'suspended') audioCtx.resume(); // Try to resume if suspended const now = audioCtx.currentTime; if (type === 'tap') { const osc = audioCtx.createOscillator(); const gainNode = audioCtx.createGain(); osc.connect(gainNode); gainNode.connect(analyserNode); let baseFreq = 220; // A3 // Make taps ascend in pitch for multi-tap fruits if (totalTapsInSequence > 1) { baseFreq += (tapIndex * 40); // Each tap in sequence is ~a third higher } osc.frequency.setValueAtTime(baseFreq + Math.random() * 20, now); osc.type = 'square'; // More percussive gainNode.gain.setValueAtTime(0.25, now); gainNode.gain.exponentialRampToValueAtTime(0.01, now + 0.15); osc.start(now); osc.stop(now + 0.15); } else if (type === 'correct') { // Play a C-major arpeggio (C4-E4-G4) const baseCorrectFreq = 261.63; // C4 const frequencies = [baseCorrectFreq, baseCorrectFreq * Math.pow(2, 4/12), baseCorrectFreq * Math.pow(2, 7/12)]; const noteDuration = 0.1; const gap = 0.03; let time = now; frequencies.forEach((freq, index) => { const osc = audioCtx.createOscillator(); const g = audioCtx.createGain(); osc.connect(g); g.connect(analyserNode); osc.frequency.setValueAtTime(freq, time); osc.type = 'triangle'; // Softer, pleasant tone g.gain.setValueAtTime(0.3, time); g.gain.setValueAtTime(0.3, time + noteDuration * 0.8); // Sustain briefly g.gain.exponentialRampToValueAtTime(0.01, time + noteDuration); osc.start(time); osc.stop(time + noteDuration + 0.05); time += noteDuration + gap; }); } else if (type === 'incorrect') { // Play two dissonant notes const freqs = [164.81, 155.56]; // E3, D#3 const noteDur = 0.12; let t = now; freqs.forEach((f, i) => { const osc = audioCtx.createOscillator(); const g = audioCtx.createGain(); osc.connect(g); g.connect(analyserNode); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(f, t); g.gain.setValueAtTime(0.25, t); g.gain.exponentialRampToValueAtTime(0.01, t + noteDur); osc.start(t); osc.stop(t + noteDur + 0.02); t += noteDur * 0.7; // Slight overlap }); } } // --- Waveform Drawing --- let animationFrameId_waveform; function drawWaveform() { animationFrameId_waveform = requestAnimationFrame(drawWaveform); if (!analyserNode || !gameInProgress) { // canvasCtx.clearRect(0, 0, waveformCanvas.width, waveformCanvas.height); // Handled by DPR scaling now return; } analyserNode.getByteTimeDomainData(waveformDataArray); // Use canvas.width/height which are scaled by DPR canvasCtx.fillStyle = 'rgba(255, 255, 255, 0.2)'; canvasCtx.fillRect(0, 0, waveformCanvas.width, waveformCanvas.height); canvasCtx.lineWidth = 2.5; // Slightly thicker line canvasCtx.strokeStyle = '#FF7043'; canvasCtx.beginPath(); // Calculations should use the CSS dimensions (clientWidth) for sliceWidth, // but drawing happens on the scaled canvas. const cssWidth = waveformCanvas.clientWidth; const cssHeight = waveformCanvas.clientHeight; const sliceWidth = cssWidth * 1.0 / waveformBufferLength; let x = 0; for (let i = 0; i < waveformBufferLength; i++) { const v = waveformDataArray[i] / 128.0; // Normalize (0-255 -> 0-2) const y = v * cssHeight / 2; // Scale to CSS height if (i === 0) { canvasCtx.moveTo(x, y); } else { canvasCtx.lineTo(x, y); } x += sliceWidth; } canvasCtx.lineTo(cssWidth, cssHeight / 2); canvasCtx.stroke(); } function setupCanvas() { const dpr = window.devicePixelRatio || 1; waveformCanvas.width = waveformCanvas.clientWidth * dpr; waveformCanvas.height = waveformCanvas.clientHeight * dpr; canvasCtx.scale(dpr, dpr); // Scale context for sharper drawing // Initial clear canvasCtx.fillStyle = 'rgba(255, 255, 255, 0.2)'; canvasCtx.fillRect(0, 0, waveformCanvas.width, waveformCanvas.height); } // --- Game Logic Functions --- function startGame() { setupCanvas(); // Setup canvas dimensions and scaling initAudio(); gameInProgress = true; currentScore = 0; scoreDisplay.textContent = currentScore; actionButton.innerHTML = '👆 Tap!'; // Using innerHTML for emoji actionButton.removeEventListener('click', startGame); actionButton.addEventListener('click', handleUserTap); feedbackMessageDisplay.textContent = ''; nextFruit(); if (animationFrameId_waveform) cancelAnimationFrame(animationFrameId_waveform); // Clear previous if any drawWaveform(); } function nextFruit() { userTapCount = 0; clearTimeout(tapTimeoutId); currentFruitChallenge = FRUITS_RHYTHMS[Math.floor(Math.random() * FRUITS_RHYTHMS.length)]; fruitNameDisplay.textContent = `${currentFruitChallenge.emoji} ${currentFruitChallenge.name}`; fruitTapsDisplay.textContent = `${currentFruitChallenge.beats} tap${currentFruitChallenge.beats > 1 ? 's' : ''}!`; actionButton.disabled = false; feedbackMessageDisplay.textContent = ''; feedbackMessageDisplay.className = ''; renderTapIndicators(); } function renderTapIndicators(resultState = null) { tapIndicatorsContainer.innerHTML = ''; for (let i = 0; i < currentFruitChallenge.beats; i++) { const dot = document.createElement('div'); dot.classList.add('indicator-dot'); if (i < userTapCount) { dot.classList.add('tapped'); } if (resultState) { dot.classList.add(resultState); } tapIndicatorsContainer.appendChild(dot); } } function handleUserTap() { if (!gameInProgress || !currentFruitChallenge) return; initAudio(); // Ensure audio context is active // Pass current tap number (1-indexed for sound logic) and total beats playSound('tap', userTapCount, currentFruitChallenge.beats); userTapCount++; renderTapIndicators(); clearTimeout(tapTimeoutId); if (userTapCount === currentFruitChallenge.beats) { checkRhythm(); } else if (userTapCount < currentFruitChallenge.beats) { tapTimeoutId = setTimeout(() => { if(gameInProgress) { feedbackMessageDisplay.textContent = '⏳ Timeout! Too slow.'; feedbackMessageDisplay.className = ''; playSound('incorrect'); renderTapIndicators('incorrect'); actionButton.disabled = true; setTimeout(nextFruit, INCORRECT_FEEDBACK_DURATION); } }, TAP_SEQUENCE_TIMEOUT); } else { // This case should ideally not be reached if button is disabled correctly, // but as a fallback: checkRhythm(); } } function checkRhythm() { clearTimeout(tapTimeoutId); actionButton.disabled = true; if (userTapCount === currentFruitChallenge.beats) { currentScore += 10 * currentFruitChallenge.beats; scoreDisplay.textContent = currentScore; feedbackMessageDisplay.textContent = '✅ Correct! Great job!'; feedbackMessageDisplay.className = 'correct'; playSound('correct'); renderTapIndicators('correct'); setTimeout(nextFruit, CORRECT_FEEDBACK_DURATION); } else { feedbackMessageDisplay.textContent = '❌ Oops! Try again.'; feedbackMessageDisplay.className = ''; playSound('incorrect'); renderTapIndicators('incorrect'); setTimeout(nextFruit, INCORRECT_FEEDBACK_DURATION); } } // Initial setup actionButton.addEventListener('click', startGame); document.addEventListener('keydown', (event) => { if (event.code === 'Space' || event.key === 'Enter') { // Added Enter key event.preventDefault(); if (actionButton.disabled === false) { actionButton.click(); } } }); // Call setupCanvas once on load to prepare it. // It will be called again in startGame for dynamic resizing if needed. window.addEventListener('load', setupCanvas); window.addEventListener('resize', setupCanvas); // Re-setup canvas on resize </script> </body> </html>