fruit / index.html
kimhyunwoo's picture
Update index.html
d0f5097 verified
<!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>