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;