Created
April 11, 2025 12:07
-
-
Save kazuph/1f0438834631593f3b1df2268b424ca7 to your computer and use it in GitHub Desktop.
音当てゲーム gemini 2.5 pro
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="ja"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>音符当てクイズ</title> | |
<style> | |
body { | |
font-family: sans-serif; | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
min-height: 100vh; | |
background-color: #f0f0f0; | |
margin: 0; | |
flex-direction: column; /* 縦並びにする */ | |
} | |
.container { | |
background-color: #fff; | |
padding: 20px; | |
border-radius: 8px; | |
box-shadow: 0 2px 10px rgba(0,0,0,0.1); | |
text-align: center; | |
max-width: 500px; /* 横幅を制限 */ | |
width: 90%; /* レスポンシブ対応 */ | |
} | |
#start-screen, #quiz-screen, #result-screen { | |
display: none; /* Initially hide all screens */ | |
} | |
#start-screen.active, #quiz-screen.active, #result-screen.active { | |
display: block; /* Show the active screen */ | |
} | |
h1, h2 { | |
color: #333; | |
} | |
button { | |
padding: 12px 20px; | |
font-size: 16px; | |
margin: 10px 5px; | |
cursor: pointer; | |
border: none; | |
border-radius: 5px; | |
background-color: #007bff; | |
color: white; | |
transition: background-color 0.3s; | |
} | |
button:hover { | |
background-color: #0056b3; | |
} | |
button:disabled { | |
background-color: #ccc; | |
cursor: not-allowed; | |
} | |
#staff-container { | |
margin: 20px auto; | |
width: 90%; /* SVGの幅を調整 */ | |
max-width: 400px; /* 最大幅 */ | |
height: 150px; /* 高さを確保 */ | |
border: 1px solid #eee; /* 境界線(デバッグ用、任意) */ | |
position: relative; /* For absolute positioning of clefs/notes */ | |
} | |
#options-container { | |
margin-top: 20px; | |
display: flex; | |
flex-wrap: wrap; /* ボタンが折り返すように */ | |
justify-content: center; /* 中央揃え */ | |
} | |
#options-container button { | |
min-width: 80px; /* ボタンの最小幅 */ | |
} | |
#feedback { | |
margin-top: 15px; | |
font-size: 1.1em; | |
min-height: 30px; /* 高さを確保 */ | |
} | |
.correct { | |
color: green; | |
font-weight: bold; | |
} | |
.incorrect { | |
color: red; | |
font-weight: bold; | |
} | |
#question-info { | |
margin-bottom: 10px; | |
font-size: 0.9em; | |
color: #555; | |
} | |
/* SVG styles */ | |
.staff-line { | |
stroke: #000; | |
stroke-width: 1; | |
} | |
.note-head { | |
fill: #000; | |
} | |
.ledger-line { | |
stroke: #000; | |
stroke-width: 1; | |
} | |
.clef { | |
font-size: 70px; /* Adjust size as needed */ | |
font-family: serif; /* Or a specific music font if available */ | |
} | |
#clef-symbol { | |
position: absolute; | |
top: 15px; /* Adjust vertical position */ | |
left: 10px; /* Adjust horizontal position */ | |
font-size: 70px; | |
line-height: 1; | |
} | |
#note-group { | |
position: absolute; | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
} | |
</style> | |
</head> | |
<body> | |
<div className="container"> | |
<!-- Start Screen --> | |
<div id="start-screen" className="active"> | |
<h1>音符当てクイズ</h1> | |
<p>練習したい記号を選んでね!</p> | |
<button onclick="startQuiz('treble')">ト音記号 (G clef)</button> | |
<button onclick="startQuiz('bass')">ヘ音記号 (F clef)</button> | |
</div> | |
<!-- Quiz Screen --> | |
<div id="quiz-screen"> | |
<div id="question-info">問題 1 / 30 - スコア: 0</div> | |
<div id="staff-container"> | |
<!-- Staff lines will be drawn dynamically --> | |
<div id="clef-symbol"></div> <!-- Clef symbol here --> | |
<svg id="note-group" width="100%" height="100%" viewBox="0 0 400 150" preserveAspectRatio="xMidYMid meet"> | |
<!-- Staff lines and note drawn here by JS --> | |
</svg> | |
</div> | |
<p>この音符はどれ?</p> | |
<div id="options-container"> | |
<!-- Options buttons will be generated here --> | |
</div> | |
<div id="feedback"></div> | |
</div> | |
<!-- Result Screen --> | |
<div id="result-screen"> | |
<h2>クイズ終了!</h2> | |
<p id="final-score"></p> | |
<button onclick="restartApp()">もう一度挑戦する</button> | |
</div> | |
</div> | |
<script> | |
// --- Global Variables --- | |
let currentClef = 'treble'; // 'treble' or 'bass' | |
let currentQuestionIndex = 0; | |
let score = 0; | |
const totalQuestions = 30; | |
let notesToPractice = []; | |
let currentNote = null; | |
let audioContext = null; | |
let oscillator = null; | |
// --- DOM Elements --- | |
const startScreen = document.getElementById('start-screen'); | |
const quizScreen = document.getElementById('quiz-screen'); | |
const resultScreen = document.getElementById('result-screen'); | |
const questionInfo = document.getElementById('question-info'); | |
const staffContainer = document.getElementById('staff-container'); | |
const noteGroupSvg = document.getElementById('note-group'); | |
const clefSymbolDiv = document.getElementById('clef-symbol'); | |
const optionsContainer = document.getElementById('options-container'); | |
const feedbackDiv = document.getElementById('feedback'); | |
const finalScoreP = document.getElementById('final-score'); | |
// --- Constants --- | |
// Note definitions: { name: 'C4', midi: 60, frequency: 261.63, doReMi: 'ド' } | |
// We'll generate frequency dynamically later. Midi note number is useful for pitch calculation. | |
const allNotes = { | |
// Bass Clef Range (G2 to E4) | |
G2: { midi: 43, doReMi: 'ソ' }, A2: { midi: 45, doReMi: 'ラ' }, B2: { midi: 47, doReMi: 'シ' }, | |
C3: { midi: 48, doReMi: 'ド' }, D3: { midi: 50, doReMi: 'レ' }, E3: { midi: 52, doReMi: 'ミ' }, F3: { midi: 53, doReMi: 'ファ' }, G3: { midi: 55, doReMi: 'ソ' }, A3: { midi: 57, doReMi: 'ラ' }, B3: { midi: 59, doReMi: 'シ' }, | |
C4: { midi: 60, doReMi: 'ド' }, D4: { midi: 62, doReMi: 'レ' }, E4: { midi: 64, doReMi: 'ミ' }, | |
// Treble Clef Range (E4 to C6) - Extend slightly for better options | |
F4: { midi: 65, doReMi: 'ファ' }, G4: { midi: 67, doReMi: 'ソ' }, A4: { midi: 69, doReMi: 'ラ' }, B4: { midi: 71, doReMi: 'シ' }, | |
C5: { midi: 72, doReMi: 'ド' }, D5: { midi: 74, doReMi: 'レ' }, E5: { midi: 76, doReMi: 'ミ' }, F5: { midi: 77, doReMi: 'ファ' }, G5: { midi: 79, doReMi: 'ソ' }, A5: { midi: 81, doReMi: 'ラ' }, B5: { midi: 83, doReMi: 'シ' }, | |
C6: { midi: 84, doReMi: 'ド' } | |
}; | |
const clefRanges = { | |
treble: Object.entries(allNotes) | |
.filter(([name, note]) => note.midi >= 64 && note.midi <= 84) // E4 to C6 | |
.map(([name, note]) => ({ name, ...note })), | |
bass: Object.entries(allNotes) | |
.filter(([name, note]) => note.midi >= 43 && note.midi <= 64) // G2 to E4 | |
.map(([name, note]) => ({ name, ...note })) | |
}; | |
// DoReMi options for multiple choice (Fixed Do) | |
const doReMiOptions = ['ド', 'レ', 'ミ', 'ファ', 'ソ', 'ラ', 'シ']; | |
// SVG drawing constants | |
const svgWidth = 400; | |
const svgHeight = 150; | |
const staffTopMargin = 40; | |
const staffBottomMargin = 40; | |
const lineSpacing = 10; // Space between staff lines | |
const staffHeight = lineSpacing * 4; | |
const staffY = (svgHeight - staffHeight) / 2; // Vertical center | |
const staffLeftMargin = 50; | |
const staffRightMargin = 50; | |
const staffWidth = svgWidth - staffLeftMargin - staffRightMargin; | |
const noteX = staffLeftMargin + staffWidth / 2; // Center note horizontally | |
const noteRadiusX = lineSpacing * 0.8; // Note ellipse horizontal radius | |
const noteRadiusY = lineSpacing * 0.6; // Note ellipse vertical radius | |
const ledgerLineLength = noteRadiusX * 2.5; // Length of ledger lines | |
// --- Audio Functions --- | |
function setupAudio() { | |
try { | |
// Create AudioContext after user interaction (button click) | |
audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
} catch (e) { | |
console.error("Web Audio API is not supported in this browser", e); | |
alert("お使いのブラウザは音の再生に対応していません。"); | |
} | |
} | |
function playNote(midiNote) { | |
if (!audioContext) return; // Don't play if audio context failed | |
// Stop previous sound if playing | |
if (oscillator) { | |
try { | |
oscillator.stop(); | |
} catch (e) { /* Ignore errors if already stopped */ } | |
oscillator.disconnect(); | |
} | |
const frequency = 440 * Math.pow(2, (midiNote - 69) / 12); | |
oscillator = audioContext.createOscillator(); | |
const gainNode = audioContext.createGain(); | |
oscillator.type = 'sine'; // Simple sine wave | |
oscillator.frequency.setValueAtTime(frequency, audioContext.currentTime); | |
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); // Volume | |
gainNode.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.8); // Fade out | |
oscillator.connect(gainNode); | |
gainNode.connect(audioContext.destination); | |
oscillator.start(); | |
// Automatically stop after 1 second | |
oscillator.stop(audioContext.currentTime + 1); | |
} | |
// --- Quiz Logic --- | |
function startQuiz(clef) { | |
// Setup audio context on first interaction | |
if (!audioContext) { | |
setupAudio(); | |
} | |
// Resume context if suspended (required in some browsers after inactivity) | |
if (audioContext && audioContext.state === 'suspended') { | |
audioContext.resume(); | |
} | |
currentClef = clef; | |
currentQuestionIndex = 0; | |
score = 0; | |
notesToPractice = generateNotes(currentClef, totalQuestions); | |
startScreen.classList.remove('active'); | |
resultScreen.classList.remove('active'); | |
quizScreen.classList.add('active'); | |
displayQuestion(); | |
} | |
function generateNotes(clef, count) { | |
const availableNotes = clefRanges[clef]; | |
const selectedNotes = []; | |
for (let i = 0; i < count; i++) { | |
const randomIndex = Math.floor(Math.random() * availableNotes.length); | |
selectedNotes.push(availableNotes[randomIndex]); | |
} | |
return selectedNotes; | |
} | |
function displayQuestion() { | |
if (currentQuestionIndex >= totalQuestions) { | |
showResults(); | |
return; | |
} | |
currentNote = notesToPractice[currentQuestionIndex]; | |
questionInfo.textContent = `問題 ${currentQuestionIndex + 1} / ${totalQuestions} - スコア: ${score}`; | |
feedbackDiv.textContent = ''; | |
feedbackDiv.className = ''; // Reset feedback style | |
drawStaffAndNote(currentClef, currentNote); | |
generateOptions(); | |
} | |
function generateOptions() { | |
optionsContainer.innerHTML = ''; // Clear previous options | |
const correctOption = currentNote.doReMi; | |
let options = [correctOption]; | |
// Generate 3 unique incorrect options | |
while (options.length < 4) { | |
const randomOption = doReMiOptions[Math.floor(Math.random() * doReMiOptions.length)]; | |
if (!options.includes(randomOption)) { | |
options.push(randomOption); | |
} | |
} | |
// Shuffle options | |
options = shuffleArray(options); | |
// Create buttons | |
options.forEach(option => { | |
const button = document.createElement('button'); | |
button.textContent = option; | |
button.onclick = () => handleAnswer(option); | |
optionsContainer.appendChild(button); | |
}); | |
} | |
function handleAnswer(selectedOption) { | |
// Disable buttons immediately | |
const buttons = optionsContainer.querySelectorAll('button'); | |
buttons.forEach(button => button.disabled = true); | |
// Play the correct note sound | |
playNote(currentNote.midi); | |
// Check answer and show feedback | |
if (selectedOption === currentNote.doReMi) { | |
score++; | |
feedbackDiv.textContent = '正解!'; | |
feedbackDiv.className = 'correct'; | |
} else { | |
feedbackDiv.textContent = `残念! 正解は ${currentNote.doReMi} でした。`; | |
feedbackDiv.className = 'incorrect'; | |
} | |
// Update score display immediately in question info | |
questionInfo.textContent = `問題 ${currentQuestionIndex + 1} / ${totalQuestions} - スコア: ${score}`; | |
// Wait a bit before showing the next question | |
setTimeout(() => { | |
currentQuestionIndex++; | |
displayQuestion(); | |
}, 1500); // 1.5 seconds delay | |
} | |
function showResults() { | |
quizScreen.classList.remove('active'); | |
resultScreen.classList.add('active'); | |
finalScoreP.textContent = `最終スコア: ${score} / ${totalQuestions}`; | |
} | |
function restartApp() { | |
resultScreen.classList.remove('active'); | |
startScreen.classList.add('active'); | |
// Reset audio oscillator if it exists | |
if (oscillator) { | |
try { | |
oscillator.stop(); | |
} catch (e) { /* Ignore */ } | |
oscillator.disconnect(); | |
oscillator = null; | |
} | |
} | |
// --- Drawing Functions --- | |
function drawStaffAndNote(clef, note) { | |
// Clear previous drawing | |
noteGroupSvg.innerHTML = ''; | |
clefSymbolDiv.textContent = ''; // Clear clef text | |
// 1. Draw Staff Lines | |
for (let i = 0; i < 5; i++) { | |
const y = staffY + i * lineSpacing; | |
const line = createSvgElement('line', { | |
x1: staffLeftMargin, y1: y, | |
x2: staffLeftMargin + staffWidth, y2: y, | |
class: 'staff-line' | |
}); | |
noteGroupSvg.appendChild(line); | |
} | |
// 2. Draw Clef | |
// Unicode characters for clefs | |
const clefChar = clef === 'treble' ? '\u{1D11E}' : '\u{1D122}'; // G-clef and F-clef | |
clefSymbolDiv.textContent = clefChar; | |
// Adjust clef position based on type (F-clef sits lower) | |
if (clef === 'bass') { | |
clefSymbolDiv.style.top = `${staffY + lineSpacing * 1 - 5}px`; // Position F clef correctly around the F line (2nd from top) | |
} else { | |
clefSymbolDiv.style.top = `${staffY - lineSpacing * 1.5}px`; // Position G clef correctly centered around G line (2nd from bottom) | |
} | |
clefSymbolDiv.style.left = '10px'; | |
// 3. Calculate Note Position | |
// Reference notes: Treble E4 (bottom line), Bass G2 (bottom line) | |
let referenceMidi, referenceY; | |
if (clef === 'treble') { | |
referenceMidi = 64; // E4 | |
referenceY = staffY + 4 * lineSpacing; // Bottom line | |
} else { // bass | |
referenceMidi = 43; // G2 | |
referenceY = staffY + 4 * lineSpacing; // Bottom line | |
} | |
// Calculate position based on half-steps from reference | |
// Each line/space represents two half-steps (usually). | |
// Find the note's position relative to the reference note on the staff. | |
const halfStepsDiff = note.midi - referenceMidi; | |
// Determine the number of line/space steps. Each step is half lineSpacing. | |
// We need a precise mapping from MIDI to vertical position. | |
// Let's map MIDI notes directly to staff positions (0 = bottom line, 1 = space above, etc.) | |
const staffPosition = getStaffPosition(note.midi, clef); // 0 = bottom line, 1 = space above, etc. | |
// Calculate the y-coordinate based on the staff position | |
const noteY = referenceY - staffPosition * (lineSpacing / 2); | |
// 4. Draw Note Head | |
const noteHead = createSvgElement('ellipse', { | |
cx: noteX, cy: noteY, | |
rx: noteRadiusX, ry: noteRadiusY, | |
class: 'note-head' | |
}); | |
noteGroupSvg.appendChild(noteHead); | |
// 5. Draw Ledger Lines (if necessary) | |
// Treble Clef Ledger Lines | |
if (clef === 'treble') { | |
if (note.midi < 64) { // Below E4 (bottom line) | |
if (note.midi === 62) { // D4 (below staff) - needs 1 ledger line | |
drawLedgerLine(noteX, staffY + 5 * lineSpacing); | |
} else if (note.midi === 60) { // C4 (Middle C) - needs 1 ledger line | |
drawLedgerLine(noteX, staffY + 5 * lineSpacing); | |
} | |
} else if (note.midi > 81) { // Above A5 (top line) | |
if (note.midi === 83) { // B5 (above staff) - needs 1 ledger line | |
drawLedgerLine(noteX, staffY - lineSpacing); | |
} else if (note.midi === 84) { // C6 (above staff) - needs 2 ledger lines | |
drawLedgerLine(noteX, staffY - lineSpacing); | |
drawLedgerLine(noteX, staffY - 2 * lineSpacing); | |
} | |
} | |
} | |
// Bass Clef Ledger Lines | |
else if (clef === 'bass') { | |
if (note.midi < 43) { // Below G2 (bottom line) | |
// Add lower ledger lines if range expands | |
} else if (note.midi > 57) { // Above A3 (top line) | |
if (note.midi === 59) { // B3 (above staff) - needs 1 ledger line | |
drawLedgerLine(noteX, staffY - lineSpacing); | |
} else if (note.midi === 60) { // C4 (Middle C) - needs 1 ledger line | |
drawLedgerLine(noteX, staffY - lineSpacing); | |
} else if (note.midi === 62) { // D4 (above staff) - needs 2 ledger lines | |
drawLedgerLine(noteX, staffY - lineSpacing); | |
drawLedgerLine(noteX, staffY - 2*lineSpacing); | |
} else if (note.midi === 64) { // E4 (above staff) - needs 2 ledger lines | |
drawLedgerLine(noteX, staffY - lineSpacing); | |
drawLedgerLine(noteX, staffY - 2 * lineSpacing); | |
} | |
} | |
} | |
} | |
// Helper function to get staff position (0 = bottom line, 1 = space above, ...) | |
function getStaffPosition(midi, clef) { | |
// This requires mapping each MIDI note to its visual position. | |
// Define reference points on the staff | |
const staffPositions = { | |
// Treble Clef (G4 is line 2 from bottom, index 2) | |
treble: { 60: -2, 62: -1, 64: 0, 65: 1, 67: 2, 69: 3, 71: 4, 72: 5, 74: 6, 76: 7, 77: 8, 79: 9, 81: 10, 83: 11, 84: 12 }, | |
// Bass Clef (F3 is line 3 from bottom, index 4) | |
bass: { 43: 0, 45: 1, 47: 2, 48: 3, 50: 4, 52: 5, 53: 6, 55: 7, 57: 8, 59: 9, 60: 10, 62: 11, 64: 12 } | |
}; | |
return staffPositions[clef][midi] !== undefined ? staffPositions[clef][midi] : 0; // Default to bottom line if somehow undefined | |
} | |
function drawLedgerLine(cx, y) { | |
const line = createSvgElement('line', { | |
x1: cx - ledgerLineLength / 2, y1: y, | |
x2: cx + ledgerLineLength / 2, y2: y, | |
class: 'ledger-line' | |
}); | |
noteGroupSvg.appendChild(line); | |
} | |
// --- Utility Functions --- | |
function createSvgElement(tagName, attributes) { | |
const element = document.createElementNS('http://www.w3.org/2000/svg', tagName); | |
for (const key in attributes) { | |
element.setAttribute(key, attributes[key]); | |
} | |
return element; | |
} | |
function shuffleArray(array) { | |
for (let i = array.length - 1; i > 0; i--) { | |
const j = Math.floor(Math.random() * (i + 1)); | |
[array[i], array[j]] = [array[j], array[i]]; // Swap elements | |
} | |
return array; | |
} | |
// --- Initial Setup --- | |
// Show start screen initially | |
startScreen.classList.add('active'); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment