Skip to content

Instantly share code, notes, and snippets.

@kazuph
Created April 11, 2025 12:07
Show Gist options
  • Save kazuph/1f0438834631593f3b1df2268b424ca7 to your computer and use it in GitHub Desktop.
Save kazuph/1f0438834631593f3b1df2268b424ca7 to your computer and use it in GitHub Desktop.
音当てゲーム gemini 2.5 pro
<!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