Spaces:
Running
Running
<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> |