Spaces:
Running
Running
<html lang="ko"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> | |
<title>과일 리듬 마스터</title> | |
<style> | |
body { | |
font-family: 'Arial', sans-serif; | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
justify-content: center; | |
height: 100vh; | |
margin: 0; | |
background: linear-gradient(135deg, #4CAF50, #81C784); /* Greenish gradient */ | |
color: #fff; | |
text-align: center; | |
overflow: hidden; | |
touch-action: manipulation; | |
} | |
#game-container { | |
background-color: rgba(255, 255, 255, 0.15); | |
padding: 20px; /* Reduced padding */ | |
border-radius: 20px; | |
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2); | |
width: 90%; | |
max-width: 380px; /* Slightly reduced max-width */ | |
} | |
#target-fruit { | |
font-size: 2.8em; /* Adjusted font size */ | |
font-weight: bold; | |
margin-bottom: 15px; /* Reduced margin */ | |
color: #FFF; | |
text-shadow: 2px 2px 4px rgba(0,0,0,0.3); | |
height: 50px; | |
} | |
#rhythm-visualizer-container { | |
width: 180px; /* Size of the circle container */ | |
height: 180px; | |
margin: 10px auto; /* Reduced margin */ | |
position: relative; | |
} | |
#rhythm-circle { | |
width: 100%; | |
height: 100%; | |
} | |
.circle-segment { | |
fill: rgba(255,255,255,0.2); /* Default segment color */ | |
stroke: #FFF; | |
stroke-width: 2; | |
transition: fill 0.1s ease; | |
} | |
.circle-segment.active { | |
fill: #FFEB3B; /* Active segment color (bright yellow) */ | |
} | |
.circle-segment.preview { | |
fill: #A5D6A7; /* Preview segment color (light green) */ | |
} | |
#tap-button { | |
padding: 18px 35px; /* Adjusted padding */ | |
font-size: 1.4em; /* Adjusted font size */ | |
background-color: #FFC107; | |
color: #8C5B00; | |
border: none; | |
border-radius: 10px; | |
cursor: pointer; | |
box-shadow: 0 5px 15px rgba(0,0,0,0.15); | |
transition: background-color 0.2s, transform 0.1s; | |
user-select: none; | |
-webkit-tap-highlight-color: transparent; | |
margin-top: 10px; /* Added some margin */ | |
} | |
#tap-button:active { | |
background-color: #FFA000; | |
transform: scale(0.95); | |
} | |
#feedback { | |
font-size: 1.4em; /* Adjusted font size */ | |
margin-top: 15px; /* Reduced margin */ | |
height: 25px; | |
font-weight: bold; | |
} | |
.feedback-correct { | |
color: #c8e6c9; /* Lighter green */ | |
} | |
.feedback-incorrect { | |
color: #ffcdd2; /* Lighter red */ | |
} | |
#score-display { | |
margin-top: 10px; /* Reduced margin */ | |
font-size: 1.1em; /* Adjusted font size */ | |
} | |
#start-button { | |
padding: 12px 25px; /* Adjusted padding */ | |
font-size: 1.1em; /* Adjusted font size */ | |
background-color: #FFA000; | |
color: white; | |
border: none; | |
border-radius: 8px; | |
cursor: pointer; | |
margin-top: 15px; /* Reduced margin */ | |
} | |
#instructions { | |
margin-top: 10px; /* Reduced margin */ | |
font-size: 0.8em; /* Adjusted font size */ | |
color: rgba(255,255,255,0.85); | |
line-height: 1.4; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="game-container"> | |
<div id="target-fruit">과일 리듬 마스터!</div> | |
<div id="rhythm-visualizer-container"> | |
<svg id="rhythm-circle" viewBox="0 0 100 100"></svg> | |
</div> | |
<button id="tap-button" disabled>탭!</button> | |
<div id="feedback"></div> | |
<div id="score-display">점수: 0</div> | |
<button id="start-button">게임 시작</button> | |
<div id="instructions"> | |
<p>표시되는 과일의 음절 수에 맞춰 탭하세요!<br> | |
(예: 망고 = 2번, 코코넛 = 3번)<br> | |
스페이스바 또는 탭 버튼 사용</p> | |
</div> | |
</div> | |
<script> | |
const targetFruitDisplay = document.getElementById('target-fruit'); | |
const rhythmCircleSVG = document.getElementById('rhythm-circle'); | |
const tapButton = document.getElementById('tap-button'); | |
const feedbackDisplay = document.getElementById('feedback'); | |
const scoreDisplay = document.getElementById('score-display'); | |
const startButton = document.getElementById('start-button'); | |
const FRUITS = [ | |
{ name: 'PEAR', beats: 1, kor: '배' }, | |
{ name: 'PLUM', beats: 1, kor: '자두' }, | |
{ name: 'APPLE', beats: 2, kor: '사과' }, | |
{ name: 'MANGO', beats: 2, kor: '망고' }, | |
{ name: 'COCONUT', beats: 3, kor: '코코넛' }, | |
{ name: 'BLUEBERRY', beats: 3, kor: '블루베리' }, | |
{ name: 'WATERMELON', beats: 4, kor: '수박' } | |
]; | |
let currentFruit = null; | |
let currentTapCount = 0; | |
let expectedTaps = 0; | |
let score = 0; | |
let gameActive = false; | |
let inputTimeout; | |
let audioContext; | |
const tapFrequency = 380; | |
const successFrequency = 523.25; // C5 | |
const failFrequency = 261.63; // C4 | |
function initAudioContext() { | |
if (!audioContext) { | |
audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
} | |
} | |
function playTone(frequency, duration = 0.07, type = 'sine') { | |
if (!audioContext) return; | |
const oscillator = audioContext.createOscillator(); | |
const gainNode = audioContext.createGain(); | |
oscillator.type = type; | |
oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime); | |
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); | |
gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + duration); | |
oscillator.connect(gainNode); | |
gainNode.connect(audioContext.destination); | |
oscillator.start(); | |
oscillator.stop(audioContext.currentTime + duration); | |
} | |
function getPointOnCircle(radius, angleDegrees, cx, cy) { | |
const angleRadians = (angleDegrees - 90) * Math.PI / 180; // Adjust by -90 to start from top | |
return { | |
x: cx + radius * Math.cos(angleRadians), | |
y: cy + radius * Math.sin(angleRadians) | |
}; | |
} | |
function drawCircularVisualizer(totalBeats, activeBeats) { | |
rhythmCircleSVG.innerHTML = ''; // Clear previous segments | |
const cx = 50, cy = 50, radius = 45; | |
const angleStep = 360 / totalBeats; | |
for (let i = 0; i < totalBeats; i++) { | |
const startAngle = i * angleStep; | |
const endAngle = (i + 1) * angleStep; | |
const p1 = getPointOnCircle(radius, startAngle, cx, cy); | |
const p2 = getPointOnCircle(radius, endAngle, cx, cy); | |
const largeArcFlag = angleStep > 180 ? 1 : 0; | |
const pathData = [ | |
`M ${cx},${cy}`, // Move to center | |
`L ${p1.x},${p1.y}`, // Line to first point on circle | |
`A ${radius},${radius} 0 ${largeArcFlag} 1 ${p2.x},${p2.y}`, // Arc to second point | |
'Z' // Close path | |
].join(' '); | |
const segment = document.createElementNS('http://www.w3.org/2000/svg', 'path'); | |
segment.setAttribute('d', pathData); | |
segment.classList.add('circle-segment'); | |
if (i < activeBeats) { | |
segment.classList.add('active'); | |
} else { | |
segment.classList.add('preview'); // For upcoming beats | |
} | |
rhythmCircleSVG.appendChild(segment); | |
} | |
} | |
function setupNextFruit() { | |
currentTapCount = 0; | |
currentFruit = FRUITS[Math.floor(Math.random() * FRUITS.length)]; | |
expectedTaps = currentFruit.beats; | |
targetFruitDisplay.textContent = currentFruit.kor.toUpperCase(); // Display Korean name | |
feedbackDisplay.textContent = ''; | |
feedbackDisplay.className = ''; | |
drawCircularVisualizer(expectedTaps, 0); // Draw initial circle | |
clearTimeout(inputTimeout); | |
inputTimeout = setTimeout(() => { | |
if (gameActive && currentTapCount < expectedTaps && currentTapCount > 0) { | |
handleResult(false, "너무 느려요!"); | |
} else if (gameActive && currentTapCount === 0) { | |
// No input, silently move to next if desired, or small penalty. | |
// For this version, we'll just wait for user input. | |
} | |
}, 2500 + expectedTaps * 600); | |
} | |
function handleTap() { | |
if (!gameActive || !currentFruit || currentTapCount >= expectedTaps) return; | |
initAudioContext(); | |
playTone(tapFrequency + currentTapCount * 30, 0.05, 'triangle'); // Slightly different tone per tap | |
currentTapCount++; | |
drawCircularVisualizer(expectedTaps, currentTapCount); | |
clearTimeout(inputTimeout); | |
if (currentTapCount === expectedTaps) { | |
handleResult(true); | |
} else { | |
inputTimeout = setTimeout(() => { | |
if (gameActive && currentTapCount < expectedTaps) { | |
handleResult(false, "리듬 실패!"); | |
} | |
}, 900); | |
} | |
} | |
function handleResult(isCorrect, message = "") { | |
if (!gameActive) return; | |
gameActive = false; // Temporarily pause game to show result | |
if (isCorrect) { | |
feedbackDisplay.textContent = '성공!'; | |
feedbackDisplay.className = 'feedback-correct'; | |
playTone(successFrequency, 0.25, 'square'); | |
score++; | |
scoreDisplay.textContent = `점수: ${score}`; | |
setTimeout(() => { | |
gameActive = true; // Resume game | |
setupNextFruit(); | |
}, 800); | |
} else { | |
feedbackDisplay.textContent = message || '실패!'; | |
feedbackDisplay.className = 'feedback-incorrect'; | |
playTone(failFrequency, 0.35, 'sawtooth'); | |
setTimeout(() => { | |
gameActive = true; // Resume game | |
setupNextFruit(); | |
}, 1300); | |
} | |
// currentTapCount will be reset in setupNextFruit | |
} | |
function startGame() { | |
initAudioContext(); | |
gameActive = true; | |
score = 0; | |
scoreDisplay.textContent = `점수: ${score}`; | |
tapButton.disabled = false; | |
startButton.style.display = 'none'; | |
document.getElementById('instructions').style.display = 'none'; | |
setupNextFruit(); | |
} | |
startButton.addEventListener('click', startGame); | |
tapButton.addEventListener('click', handleTap); | |
document.addEventListener('keydown', (event) => { | |
if (event.code === 'Space') { | |
event.preventDefault(); | |
if (!gameActive && startButton.style.display !== 'none') { | |
startGame(); | |
} else if (tapButton.disabled === false) { // Check if tap button is active | |
handleTap(); // Directly call handleTap | |
} | |
} | |
}); | |
// Initial state for visualizer before game starts | |
drawCircularVisualizer(1,0); // Show a single dimmed segment | |
</script> | |
</body> | |
</html> |