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 = `

❌ Loading Error

${message}

-

The game requires vocabulary and sentences in compatible format.

+

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

`; } - _extractVocabulary(content) { - this._vocabulary = []; - - if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) { - this._vocabulary = Object.entries(content.vocabulary).map(([word, data]) => { - if (typeof data === 'object' && data.translation) { - return { - original: word, - translation: data.translation.split(';')[0], - fullTranslation: data.translation, - type: data.type || 'general', - audio: data.audio, - image: data.image, - examples: data.examples, - pronunciation: data.pronunciation, - category: data.type || 'general' - }; - } else if (typeof data === 'string') { - return { - original: word, - translation: data.split(';')[0], - fullTranslation: data, - type: 'general', - category: 'general' - }; - } - return null; - }).filter(Boolean); - } - - this._vocabulary = this._vocabulary.filter(word => - word && - typeof word.original === 'string' && - typeof word.translation === 'string' && - word.original.trim() !== '' && - word.translation.trim() !== '' - ); - - if (this._vocabulary.length === 0) { - this._vocabulary = [ - { original: 'hello', translation: 'bonjour', category: 'greetings' }, - { original: 'goodbye', translation: 'au revoir', category: 'greetings' }, - { original: 'thank you', translation: 'merci', category: 'greetings' }, - { original: 'cat', translation: 'chat', category: 'animals' }, - { original: 'dog', translation: 'chien', category: 'animals' }, - { original: 'house', translation: 'maison', category: 'objects' }, - { original: 'school', translation: 'école', category: 'places' }, - { original: 'book', translation: 'livre', category: 'objects' } - ]; - } - - console.log(`Fill the Blank: ${this._vocabulary.length} words loaded`); - } - - _extractSentences(content) { - this._sentences = []; - - if (content.story?.chapters) { - content.story.chapters.forEach(chapter => { - if (chapter.sentences) { - chapter.sentences.forEach(sentence => { - if (sentence.original && sentence.translation) { - this._sentences.push({ - original: sentence.original, - translation: sentence.translation, - source: 'story' - }); - } - }); - } - }); - } - - const directSentences = content.sentences; - if (directSentences && Array.isArray(directSentences)) { - directSentences.forEach(sentence => { - if (sentence.english && sentence.chinese) { - this._sentences.push({ - original: sentence.english, - translation: sentence.chinese, - source: 'sentences' - }); - } else if (sentence.original && sentence.translation) { - this._sentences.push({ - original: sentence.original, - translation: sentence.translation, - source: 'sentences' - }); - } - }); - } - - this._sentences = this._sentences.filter(sentence => - sentence.original && - sentence.original.split(' ').length >= 3 && - sentence.original.trim().length > 0 - ); - - this._sentences = this._shuffleArray(this._sentences); - - if (this._sentences.length === 0) { - this._sentences = this._createFallbackSentences(); - } - - this._sentences = this._sentences.slice(0, this._config.maxSentences); - console.log(`Fill the Blank: ${this._sentences.length} sentences loaded`); - } - - _createFallbackSentences() { - const fallback = []; - this._vocabulary.slice(0, 10).forEach(vocab => { - fallback.push({ - original: `This is a ${vocab.original}.`, - translation: `这是一个 ${vocab.translation}。`, - source: 'fallback' - }); - }); - return fallback; - } - _createGameBoard() { this._gameContainer.innerHTML = `
- ${this._currentSentenceIndex + 1} - / ${this._sentences.length} + ${this._currentIndex + 1} + / ${this._exercises.length}
${this._errors} @@ -373,14 +382,10 @@ class FillTheBlank extends Module {
-
- -
-
- +