import Module from '../core/Module.js'; /** * Fill the Blank Game * * Works with 2 modes: * 1. Predefined fill-the-blank exercises from content (exercises.fill_in_blanks) * 2. Auto-generated blanks from regular phrases (max 20% of words, max 2 blanks per phrase) */ class FillTheBlank extends Module { constructor(name, dependencies, config = {}) { super(name || 'fill-the-blank', ['eventBus']); if (!dependencies.eventBus || !dependencies.content) { throw new Error('FillTheBlank requires eventBus and content dependencies'); } this._eventBus = dependencies.eventBus; this._content = dependencies.content; this._config = { container: null, difficulty: 'medium', maxSentences: 20, maxBlanksPerSentence: 2, maxBlankPercentage: 0.20, // Max 20% of words can be blanked ...config }; // Game state this._score = 0; this._errors = 0; this._currentIndex = 0; this._isRunning = false; this._exercises = []; // All exercises (predefined + auto-generated) this._currentExercise = null; this._gameContainer = null; Object.seal(this); } static getMetadata() { return { id: 'fill-the-blank', name: 'Fill the Blank', description: 'Complete sentences by filling in missing words', version: '2.0.0', author: 'Class Generator', category: 'vocabulary', tags: ['vocabulary', 'sentences', 'completion', 'learning'], difficulty: { min: 1, max: 4, default: 2 }, estimatedDuration: 10, requiredContent: ['phrases'] }; } static getCompatibilityScore(content) { if (!content) return 0; let score = 0; // Check for predefined fill-in-blanks if (content.exercises?.fill_in_blanks) { score += 50; } // Check for phrases (can be auto-converted) if (content.phrases && typeof content.phrases === 'object') { const phraseCount = Object.keys(content.phrases).length; if (phraseCount >= 5) score += 30; if (phraseCount >= 10) score += 10; } // Check for sentences if (content.sentences && Array.isArray(content.sentences)) { const sentenceCount = content.sentences.length; if (sentenceCount >= 5) score += 5; if (sentenceCount >= 10) score += 5; } return Math.min(score, 100); } async init() { this._validateNotDestroyed(); if (!this._config.container) { throw new Error('Game container is required'); } this._eventBus.on('game:start', this._handleGameStart.bind(this), this.name); this._eventBus.on('game:stop', this._handleGameStop.bind(this), this.name); this._eventBus.on('navigation:change', this._handleNavigationChange.bind(this), this.name); this._injectCSS(); try { this._gameContainer = this._config.container; const content = this._content; if (!content) { throw new Error('No content available'); } // Load exercises (predefined + auto-generated) this._loadExercises(content); if (this._exercises.length === 0) { throw new Error('No suitable content found for Fill the Blank'); } console.log(`Fill the Blank: ${this._exercises.length} exercises loaded`); this._createGameBoard(); this._setupEventListeners(); this._loadNextExercise(); this._eventBus.emit('game:ready', { gameId: 'fill-the-blank', instanceId: this.name, exercises: this._exercises.length }, this.name); } catch (error) { console.error('Error starting Fill the Blank:', error); this._showInitError(error.message); } this._setInitialized(); } async destroy() { this._validateNotDestroyed(); this._cleanup(); this._removeCSS(); this._eventBus.off('game:start', this.name); this._eventBus.off('game:stop', this.name); this._eventBus.off('navigation:change', this.name); this._setDestroyed(); } _handleGameStart(event) { this._validateInitialized(); if (event.gameId === 'fill-the-blank') { this._startGame(); } } _handleGameStop(event) { this._validateInitialized(); if (event.gameId === 'fill-the-blank') { this._stopGame(); } } _handleNavigationChange(event) { this._validateInitialized(); if (event.from === '/games/fill-the-blank') { this._cleanup(); } } async _startGame() { try { this._gameContainer = document.getElementById('game-content'); if (!this._gameContainer) { throw new Error('Game container not found'); } const content = await this._content.getCurrentContent(); if (!content) { throw new Error('No content available'); } this._loadExercises(content); if (this._exercises.length === 0) { throw new Error('No suitable content found for Fill the Blank'); } this._createGameBoard(); this._setupEventListeners(); this._loadNextExercise(); } catch (error) { console.error('Error starting Fill the Blank:', error); this._showInitError(error.message); } } _stopGame() { this._cleanup(); } _cleanup() { this._isRunning = false; if (this._gameContainer) { this._gameContainer.innerHTML = ''; } } /** * Load exercises from content * Priority 1: Predefined fill-in-blanks from exercises.fill_in_blanks * Priority 2: Auto-generate from phrases */ _loadExercises(content) { this._exercises = []; // 1. Load predefined fill-in-blanks if (content.exercises?.fill_in_blanks?.sentences) { content.exercises.fill_in_blanks.sentences.forEach(exercise => { if (exercise.text && exercise.answer) { this._exercises.push({ type: 'predefined', original: exercise.text, translation: exercise.user_language || '', blanks: [{ answer: exercise.answer, position: exercise.text.indexOf('_______') }] }); } }); console.log(`Loaded ${this._exercises.length} predefined fill-the-blank exercises`); } // 2. Auto-generate from phrases if (content.phrases && typeof content.phrases === 'object') { const phrases = Object.entries(content.phrases); phrases.forEach(([phraseText, phraseData]) => { const translation = typeof phraseData === 'object' ? phraseData.user_language : phraseData; // Generate blanks for this phrase const exercise = this._createAutoBlankExercise(phraseText, translation, content.vocabulary); if (exercise) { this._exercises.push(exercise); } }); console.log(`Generated ${this._exercises.length - (content.exercises?.fill_in_blanks?.sentences?.length || 0)} auto-blank exercises from phrases`); } // Shuffle and limit this._exercises = this._shuffleArray(this._exercises); this._exercises = this._exercises.slice(0, this._config.maxSentences); } /** * Create auto-blank exercise from a phrase * Rules: * - Max 20% of words can be blanked * - Max 2 blanks per phrase * - Prefer vocabulary words */ _createAutoBlankExercise(phraseText, translation, vocabulary) { const words = phraseText.split(/\s+/); // Minimum 3 words required if (words.length < 3) { return null; } // Calculate max blanks (20% of words, max 2) // Force 2 blanks for phrases with 6+ words, otherwise 1 blank const maxBlanks = words.length >= 6 ? 2 : 1; // Identify vocabulary words and other words const vocabularyIndices = []; const otherIndices = []; words.forEach((word, index) => { const cleanWord = word.replace(/[.,!?;:"'()[\]{}\-–—]/g, '').toLowerCase(); // Check if it's a vocabulary word let isVocabWord = false; if (vocabulary && typeof vocabulary === 'object') { isVocabWord = Object.keys(vocabulary).some(vocabKey => vocabKey.toLowerCase() === cleanWord ); } // Skip very short words (articles, prepositions) if (cleanWord.length <= 2) { return; } if (isVocabWord) { vocabularyIndices.push(index); } else if (cleanWord.length >= 4) { // Only consider words with 4+ letters otherIndices.push(index); } }); // Select which words to blank const selectedIndices = []; // Prefer vocabulary words const shuffledVocab = this._shuffleArray([...vocabularyIndices]); for (let i = 0; i < Math.min(maxBlanks, shuffledVocab.length); i++) { selectedIndices.push(shuffledVocab[i]); } // Fill remaining slots with other words if needed if (selectedIndices.length < maxBlanks && otherIndices.length > 0) { const shuffledOthers = this._shuffleArray([...otherIndices]); const needed = maxBlanks - selectedIndices.length; for (let i = 0; i < Math.min(needed, shuffledOthers.length); i++) { selectedIndices.push(shuffledOthers[i]); } } // Must have at least 1 blank if (selectedIndices.length === 0) { return null; } // Sort indices selectedIndices.sort((a, b) => a - b); // Create blanks data const blanks = selectedIndices.map(index => { const word = words[index]; return { index: index, answer: word.replace(/[.,!?;:"'()[\]{}\-–—]/g, ''), punctuation: word.match(/[.,!?;:"'()[\]{}\-–—]+$/)?.[0] || '' }; }); return { type: 'auto-generated', original: phraseText, translation: translation, blanks: blanks }; } _showInitError(message) { this._gameContainer.innerHTML = `

❌ Loading Error

${message}

This game requires phrases or predefined fill-the-blank exercises.

`; } _createGameBoard() { this._gameContainer.innerHTML = `
${this._currentIndex + 1} / ${this._exercises.length}
${this._errors} Errors
${this._score} Score
Complete the sentence by filling in the blanks!
`; } _setupEventListeners() { const checkBtn = document.getElementById('check-btn'); const hintBtn = document.getElementById('hint-btn'); const skipBtn = document.getElementById('skip-btn'); if (checkBtn) checkBtn.addEventListener('click', () => this._checkAnswer()); if (hintBtn) hintBtn.addEventListener('click', () => this._showHint()); if (skipBtn) skipBtn.addEventListener('click', () => this._skipSentence()); // Enter key to check answer document.addEventListener('keydown', (e) => { if (e.key === 'Enter' && this._isRunning) { this._checkAnswer(); } }); } _loadNextExercise() { if (this._currentIndex >= this._exercises.length) { this._showFeedback(`🎉 All exercises completed! Final score: ${this._score}`, 'success'); this._isRunning = false; setTimeout(() => { // Restart with shuffled exercises this._currentIndex = 0; this._exercises = this._shuffleArray(this._exercises); this._loadNextExercise(); }, 3000); return; } this._isRunning = true; this._currentExercise = this._exercises[this._currentIndex]; this._displayExercise(); this._updateUI(); } _displayExercise() { const exercise = this._currentExercise; if (exercise.type === 'predefined') { // Display predefined fill-the-blank (with "_______" placeholders) this._displayPredefinedExercise(exercise); } else { // Display auto-generated exercise this._displayAutoGeneratedExercise(exercise); } // Display translation hint const translationHint = document.getElementById('translation-hint'); if (translationHint) { translationHint.innerHTML = exercise.translation ? `💭 ${exercise.translation}` : ''; } // Focus first input setTimeout(() => { const firstInput = document.querySelector('.blank-input'); if (firstInput) firstInput.focus(); }, 100); } _displayPredefinedExercise(exercise) { const sentenceHTML = exercise.original.replace(/_______/g, () => { const inputId = `blank-${exercise.blanks.indexOf(exercise.blanks.find(b => b.answer))}`; return ``; }); const container = document.getElementById('sentence-container'); if (container) { container.innerHTML = sentenceHTML; } } _displayAutoGeneratedExercise(exercise) { const words = exercise.original.split(/\s+/); let sentenceHTML = ''; let blankIndex = 0; words.forEach((word, index) => { const blank = exercise.blanks.find(b => b.index === index); if (blank) { sentenceHTML += `${blank.punctuation} `; blankIndex++; } else { sentenceHTML += `${word} `; } }); const container = document.getElementById('sentence-container'); if (container) { container.innerHTML = sentenceHTML; } } _checkAnswer() { if (!this._isRunning) return; const inputs = document.querySelectorAll('.blank-input'); const blanks = this._currentExercise.blanks; let allCorrect = true; let correctCount = 0; inputs.forEach((input, index) => { const userAnswer = input.value.trim().toLowerCase(); const correctAnswer = (input.dataset.answer || blanks[index]?.answer || '').toLowerCase(); if (userAnswer === correctAnswer) { input.classList.remove('incorrect'); input.classList.add('correct'); correctCount++; } else { input.classList.remove('correct'); input.classList.add('incorrect'); allCorrect = false; } }); if (allCorrect) { const points = 10 * blanks.length; this._score += points; this._showFeedback(`🎉 Perfect! +${points} points`, 'success'); this._speakWord(blanks[0].answer); // Pronounce first blank word setTimeout(() => { this._currentIndex++; this._loadNextExercise(); }, 1500); } else { this._errors++; if (correctCount > 0) { const points = 5 * correctCount; this._score += points; this._showFeedback(`✨ ${correctCount}/${blanks.length} correct! +${points} points. Try again.`, 'partial'); } else { this._showFeedback(`❌ Try again! (${this._errors} errors)`, 'error'); } } this._updateUI(); this._eventBus.emit('game:score-update', { gameId: 'fill-the-blank', score: this._score, errors: this._errors }, this.name); } _showHint() { const inputs = document.querySelectorAll('.blank-input'); inputs.forEach(input => { if (!input.value.trim()) { const correctAnswer = input.dataset.answer || this._currentExercise.blanks[0]?.answer || ''; if (correctAnswer) { input.value = correctAnswer[0]; // First letter input.focus(); } } }); this._showFeedback('💡 First letters added!', 'info'); } _skipSentence() { const inputs = document.querySelectorAll('.blank-input'); const blanks = this._currentExercise.blanks; inputs.forEach((input, index) => { const correctAnswer = input.dataset.answer || blanks[index]?.answer || ''; input.value = correctAnswer; input.classList.add('revealed'); }); this._showFeedback('📖 Answers revealed! Next exercise...', 'info'); setTimeout(() => { this._currentIndex++; this._loadNextExercise(); }, 2000); } _speakWord(word) { if (!window.speechSynthesis || !word) return; try { window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(word); utterance.lang = 'en-US'; utterance.rate = 0.9; utterance.pitch = 1.0; utterance.volume = 1.0; const voices = window.speechSynthesis.getVoices(); const preferredVoice = voices.find(v => v.lang.startsWith('en') && (v.name.includes('Google') || v.name.includes('Neural') || v.name.includes('Microsoft')) ); if (preferredVoice) { utterance.voice = preferredVoice; } window.speechSynthesis.speak(utterance); } catch (error) { console.warn('Speech synthesis failed:', error); } } _showFeedback(message, type = 'info') { const feedbackArea = document.getElementById('feedback-area'); if (feedbackArea) { feedbackArea.innerHTML = `
${message}
`; } } _updateUI() { const currentQuestion = document.getElementById('current-question'); const errorsCount = document.getElementById('errors-count'); const scoreDisplay = document.getElementById('score-display'); if (currentQuestion) currentQuestion.textContent = this._currentIndex + 1; if (errorsCount) errorsCount.textContent = this._errors; if (scoreDisplay) scoreDisplay.textContent = this._score; } _shuffleArray(array) { const shuffled = [...array]; for (let i = shuffled.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; } return shuffled; } _injectCSS() { const cssId = 'fill-the-blank-styles'; if (document.getElementById(cssId)) return; const style = document.createElement('style'); style.id = cssId; style.textContent = ` .fill-blank-wrapper { max-width: 900px; margin: 0 auto; padding: 20px; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; color: white; } .game-info { background: rgba(255, 255, 255, 0.1); border-radius: 15px; padding: 20px; margin-bottom: 25px; backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.2); } .game-stats { display: flex; justify-content: space-around; align-items: center; } .stat-item { text-align: center; } .stat-value { font-size: 2em; font-weight: bold; display: block; color: #fff; } .stat-label { font-size: 0.9em; opacity: 0.8; color: #e0e0e0; } .translation-hint { background: rgba(255, 255, 255, 0.1); border-radius: 12px; padding: 15px; margin-bottom: 20px; text-align: center; backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.2); font-size: 1.1em; color: #f0f0f0; min-height: 50px; } .sentence-container { background: rgba(255, 255, 255, 0.95); border-radius: 15px; padding: 30px; margin-bottom: 25px; font-size: 1.4em; line-height: 2; text-align: center; color: #2c3e50; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); border: 1px solid rgba(255, 255, 255, 0.3); min-height: 120px; } .word { display: inline; color: #2c3e50; font-weight: 500; } .blank-wrapper { display: inline; position: relative; } .blank-input { background: white; border: 3px solid #667eea; border-radius: 8px; padding: 8px 12px; font-size: 1em; text-align: center; min-width: 100px; max-width: 200px; transition: all 0.3s ease; color: #2c3e50; font-weight: bold; margin: 0 5px; } .blank-input:focus { outline: none; border-color: #5a67d8; box-shadow: 0 0 15px rgba(102, 126, 234, 0.5); transform: scale(1.05); } .blank-input.correct { border-color: #27ae60; background: linear-gradient(135deg, #d5f4e6, #a3e6c7); color: #1e8e3e; animation: correctPulse 0.5s ease; } .blank-input.incorrect { border-color: #e74c3c; background: linear-gradient(135deg, #ffeaea, #ffcdcd); color: #c0392b; animation: shake 0.5s ease-in-out; } .blank-input.revealed { border-color: #f39c12; background: linear-gradient(135deg, #fff3cd, #ffeaa7); color: #d35400; } @keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-8px); } 75% { transform: translateX(8px); } } @keyframes correctPulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.1); } } .game-controls { display: flex; justify-content: center; gap: 15px; margin: 25px 0; flex-wrap: wrap; } .control-btn { padding: 14px 30px; border: none; border-radius: 25px; font-size: 1.1em; font-weight: bold; cursor: pointer; transition: all 0.3s ease; min-width: 130px; } .control-btn.primary { background: linear-gradient(135deg, #27ae60, #2ecc71); color: white; box-shadow: 0 4px 15px rgba(39, 174, 96, 0.3); } .control-btn.primary:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(39, 174, 96, 0.4); } .control-btn.secondary { background: rgba(255, 255, 255, 0.9); color: #667eea; border: 2px solid rgba(255, 255, 255, 0.5); } .control-btn.secondary:hover { background: white; transform: translateY(-2px); box-shadow: 0 4px 15px rgba(255, 255, 255, 0.3); } .feedback-area { background: rgba(255, 255, 255, 0.1); border-radius: 12px; padding: 20px; text-align: center; backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.2); min-height: 70px; display: flex; align-items: center; justify-content: center; } .instruction { font-size: 1.1em; font-weight: 500; color: #f0f0f0; } .instruction.success { color: #2ecc71; font-weight: bold; font-size: 1.3em; animation: pulse 0.6s ease-in-out; } .instruction.error { color: #e74c3c; font-weight: bold; animation: pulse 0.6s ease-in-out; } .instruction.partial { color: #f39c12; font-weight: bold; } .instruction.info { color: #3498db; font-weight: bold; } @keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.1); } } .game-error { background: rgba(231, 76, 60, 0.1); border: 2px solid #e74c3c; border-radius: 15px; padding: 30px; text-align: center; color: white; backdrop-filter: blur(10px); } .game-error h3 { color: #e74c3c; margin-bottom: 15px; font-size: 2em; } .back-btn { background: linear-gradient(135deg, #95a5a6, #7f8c8d); color: white; border: none; padding: 12px 25px; border-radius: 25px; font-size: 1.1em; font-weight: bold; cursor: pointer; margin-top: 20px; transition: all 0.3s ease; } .back-btn:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(149, 165, 166, 0.4); } @media (max-width: 768px) { .fill-blank-wrapper { padding: 15px; } .game-stats { flex-direction: row; gap: 10px; } .stat-item { flex: 1; } .sentence-container { font-size: 1.2em; padding: 20px 15px; line-height: 1.8; } .blank-input { min-width: 80px; font-size: 0.9em; padding: 6px 10px; } .game-controls { flex-direction: column; align-items: stretch; } .control-btn { width: 100%; } .translation-hint { font-size: 1em; padding: 12px; } } `; document.head.appendChild(style); } _removeCSS() { const cssElement = document.getElementById('fill-the-blank-styles'); if (cssElement) { cssElement.remove(); } } } export default FillTheBlank;