rhythm-circle / index.html
kimhyunwoo's picture
Update index.html
c4bb773 verified
<!DOCTYPE html>
<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>