diff --git a/src/games/FillTheBlank.js b/src/games/FillTheBlank.js index 7ec3e58..976c6e3 100644 --- a/src/games/FillTheBlank.js +++ b/src/games/FillTheBlank.js @@ -1,8 +1,15 @@ 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, ['eventBus']); + super(name || 'fill-the-blank', ['eventBus']); if (!dependencies.eventBus || !dependencies.content) { throw new Error('FillTheBlank requires eventBus and content dependencies'); @@ -14,18 +21,18 @@ class FillTheBlank extends Module { 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._currentSentenceIndex = 0; + this._currentIndex = 0; this._isRunning = false; - this._vocabulary = []; - this._sentences = []; - this._currentSentence = null; - this._blanks = []; - this._userAnswers = []; + this._exercises = []; // All exercises (predefined + auto-generated) + this._currentExercise = null; this._gameContainer = null; Object.seal(this); @@ -46,34 +53,28 @@ class FillTheBlank extends Module { default: 2 }, estimatedDuration: 10, - requiredContent: ['vocabulary', 'sentences'] + requiredContent: ['phrases'] }; } static getCompatibilityScore(content) { - if (!content) { - return 0; - } + if (!content) return 0; let score = 0; - const hasVocabulary = content.vocabulary && ( - typeof content.vocabulary === 'object' || - Array.isArray(content.vocabulary) - ); - const hasSentences = content.sentences || - content.story?.chapters || - content.fillInBlanks; - - if (hasVocabulary) score += 40; - if (hasSentences) score += 40; - - if (content.vocabulary && typeof content.vocabulary === 'object') { - const vocabCount = Object.keys(content.vocabulary).length; - if (vocabCount >= 10) score += 10; - if (vocabCount >= 20) score += 5; + // 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; @@ -86,7 +87,6 @@ class FillTheBlank extends Module { async init() { this._validateNotDestroyed(); - // Validate container if (!this._config.container) { throw new Error('Game container is required'); } @@ -97,7 +97,6 @@ class FillTheBlank extends Module { this._injectCSS(); - // Start game immediately try { this._gameContainer = this._config.container; const content = this._content; @@ -106,27 +105,23 @@ class FillTheBlank extends Module { throw new Error('No content available'); } - this._extractVocabulary(content); - this._extractSentences(content); + // Load exercises (predefined + auto-generated) + this._loadExercises(content); - if (this._vocabulary.length === 0) { - throw new Error('No vocabulary found for Fill the Blank'); + if (this._exercises.length === 0) { + throw new Error('No suitable content found for Fill the Blank'); } - if (this._sentences.length === 0) { - throw new Error('No sentences found for Fill the Blank'); - } + console.log(`Fill the Blank: ${this._exercises.length} exercises loaded`); this._createGameBoard(); this._setupEventListeners(); - this._loadNextSentence(); + this._loadNextExercise(); - // Emit game ready event this._eventBus.emit('game:ready', { gameId: 'fill-the-blank', instanceId: this.name, - vocabulary: this._vocabulary.length, - sentences: this._sentences.length + exercises: this._exercises.length }, this.name); } catch (error) { @@ -182,20 +177,15 @@ class FillTheBlank extends Module { throw new Error('No content available'); } - this._extractVocabulary(content); - this._extractSentences(content); + this._loadExercises(content); - if (this._vocabulary.length === 0) { - throw new Error('No vocabulary found for Fill the Blank'); - } - - if (this._sentences.length === 0) { - throw new Error('No sentences found for Fill the Blank'); + if (this._exercises.length === 0) { + throw new Error('No suitable content found for Fill the Blank'); } this._createGameBoard(); this._setupEventListeners(); - this._loadNextSentence(); + this._loadNextExercise(); } catch (error) { console.error('Error starting Fill the Blank:', error); @@ -214,145 +204,164 @@ class FillTheBlank extends Module { } } + /** + * 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 = `
${message}
-The game requires vocabulary and sentences in compatible format.
+This game requires phrases or predefined fill-the-blank exercises.