- Enhanced Story Reader with text-to-story conversion methods - Added support for simple texts and sentences in Story Reader - Removed Text Reader game file (js/games/text-reader.js) - Updated all configuration files to remove Text Reader references - Modified game compatibility system to use Story Reader instead - Updated test fixtures to reflect game changes - Cleaned up debug/test HTML files 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
516 lines
20 KiB
JavaScript
516 lines
20 KiB
JavaScript
// === MODULE FILL THE BLANK ===
|
||
|
||
class FillTheBlankGame {
|
||
constructor(options) {
|
||
this.container = options.container;
|
||
this.content = options.content;
|
||
this.onScoreUpdate = options.onScoreUpdate || (() => {});
|
||
this.onGameEnd = options.onGameEnd || (() => {});
|
||
|
||
// Game state
|
||
this.score = 0;
|
||
this.errors = 0;
|
||
this.currentSentenceIndex = 0;
|
||
this.isRunning = false;
|
||
|
||
// Game data
|
||
this.vocabulary = this.extractVocabulary(this.content);
|
||
this.sentences = this.generateSentencesFromVocabulary();
|
||
this.currentSentence = null;
|
||
this.blanks = [];
|
||
this.userAnswers = [];
|
||
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
// Check that we have vocabulary
|
||
if (!this.vocabulary || this.vocabulary.length === 0) {
|
||
logSh('No vocabulary available for Fill the Blank', 'ERROR');
|
||
this.showInitError();
|
||
return;
|
||
}
|
||
|
||
this.createGameBoard();
|
||
this.setupEventListeners();
|
||
// The game will start when start() is called
|
||
}
|
||
|
||
showInitError() {
|
||
this.container.innerHTML = `
|
||
<div class="game-error">
|
||
<h3>❌ Loading Error</h3>
|
||
<p>This content does not contain vocabulary compatible with Fill the Blank.</p>
|
||
<p>The game requires words with their translations in ultra-modular format.</p>
|
||
<button onclick="AppNavigation.navigateTo('games')" class="back-btn">← Back to Games</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
extractVocabulary(content) {
|
||
let vocabulary = [];
|
||
|
||
logSh('🔍 Extracting vocabulary from:', content?.name || 'content', 'INFO');
|
||
|
||
// Priority 1: Use raw module content (ultra-modular format)
|
||
if (content.rawContent) {
|
||
logSh('📦 Using raw module content', 'INFO');
|
||
return this.extractVocabularyFromRaw(content.rawContent);
|
||
}
|
||
|
||
// Priority 2: Ultra-modular format (vocabulary object) - ONLY format supported
|
||
if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) {
|
||
logSh('✨ Ultra-modular format detected (vocabulary object)', 'INFO');
|
||
vocabulary = Object.entries(content.vocabulary).map(([word, data]) => {
|
||
// Support ultra-modular format ONLY
|
||
if (typeof data === 'object' && data.user_language) {
|
||
return {
|
||
original: word, // Clé = original_language
|
||
translation: data.user_language.split(';')[0], // First translation
|
||
fullTranslation: data.user_language, // Complete translation
|
||
type: data.type || 'general',
|
||
audio: data.audio,
|
||
image: data.image,
|
||
examples: data.examples,
|
||
pronunciation: data.pronunciation,
|
||
category: data.type || 'general'
|
||
};
|
||
}
|
||
// Legacy fallback - simple string (temporary, will be removed)
|
||
else if (typeof data === 'string') {
|
||
return {
|
||
original: word,
|
||
translation: data.split(';')[0],
|
||
fullTranslation: data,
|
||
type: 'general',
|
||
category: 'general'
|
||
};
|
||
}
|
||
return null;
|
||
}).filter(Boolean);
|
||
}
|
||
// No other formats supported - ultra-modular only
|
||
|
||
return this.finalizeVocabulary(vocabulary);
|
||
}
|
||
|
||
extractVocabularyFromRaw(rawContent) {
|
||
logSh('🔧 Extracting from raw content:', rawContent.name || 'Module', 'INFO');
|
||
let vocabulary = [];
|
||
|
||
// Ultra-modular format (vocabulary object) - ONLY format supported
|
||
if (rawContent.vocabulary && typeof rawContent.vocabulary === 'object' && !Array.isArray(rawContent.vocabulary)) {
|
||
vocabulary = Object.entries(rawContent.vocabulary).map(([word, data]) => {
|
||
// Support ultra-modular format ONLY
|
||
if (typeof data === 'object' && data.user_language) {
|
||
return {
|
||
original: word, // Clé = original_language
|
||
translation: data.user_language.split(';')[0], // First translation
|
||
fullTranslation: data.user_language, // Complete translation
|
||
type: data.type || 'general',
|
||
audio: data.audio,
|
||
image: data.image,
|
||
examples: data.examples,
|
||
pronunciation: data.pronunciation,
|
||
category: data.type || 'general'
|
||
};
|
||
}
|
||
// Legacy fallback - simple string (temporary, will be removed)
|
||
else if (typeof data === 'string') {
|
||
return {
|
||
original: word,
|
||
translation: data.split(';')[0],
|
||
fullTranslation: data,
|
||
type: 'general',
|
||
category: 'general'
|
||
};
|
||
}
|
||
return null;
|
||
}).filter(Boolean);
|
||
logSh(`✨ ${vocabulary.length} words extracted from ultra-modular vocabulary`, 'INFO');
|
||
}
|
||
// No other formats supported - ultra-modular only
|
||
else {
|
||
logSh('⚠️ Content format not supported - ultra-modular format required', 'WARN');
|
||
}
|
||
|
||
return this.finalizeVocabulary(vocabulary);
|
||
}
|
||
|
||
finalizeVocabulary(vocabulary) {
|
||
// Validation and cleanup for ultra-modular format
|
||
vocabulary = vocabulary.filter(word =>
|
||
word &&
|
||
typeof word.original === 'string' &&
|
||
typeof word.translation === 'string' &&
|
||
word.original.trim() !== '' &&
|
||
word.translation.trim() !== ''
|
||
);
|
||
|
||
if (vocabulary.length === 0) {
|
||
logSh('❌ No valid vocabulary found', 'ERROR');
|
||
// Demo vocabulary as last resort
|
||
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' }
|
||
];
|
||
logSh('🚨 Using demo vocabulary', 'WARN');
|
||
}
|
||
|
||
logSh(`✅ Fill the Blank: ${vocabulary.length} words finalized`, 'INFO');
|
||
return vocabulary;
|
||
}
|
||
|
||
generateSentencesFromVocabulary() {
|
||
// Generate sentences based on word types
|
||
const nounTemplates = [
|
||
{ pattern: 'I see a {word}.', translation: 'Je vois un {translation}.' },
|
||
{ pattern: 'The {word} is here.', translation: 'Le {translation} est ici.' },
|
||
{ pattern: 'I like the {word}.', translation: 'J\'aime le {translation}.' },
|
||
{ pattern: 'Where is the {word}?', translation: 'Où est le {translation}?' },
|
||
{ pattern: 'This is a {word}.', translation: 'C\'est un {translation}.' },
|
||
{ pattern: 'I have a {word}.', translation: 'J\'ai un {translation}.' }
|
||
];
|
||
|
||
const verbTemplates = [
|
||
{ pattern: 'I {word} every day.', translation: 'Je {translation} tous les jours.' },
|
||
{ pattern: 'We {word} together.', translation: 'Nous {translation} ensemble.' },
|
||
{ pattern: 'They {word} quickly.', translation: 'Ils {translation} rapidement.' },
|
||
{ pattern: 'I like to {word}.', translation: 'J\'aime {translation}.' }
|
||
];
|
||
|
||
const adjectiveTemplates = [
|
||
{ pattern: 'The cat is {word}.', translation: 'Le chat est {translation}.' },
|
||
{ pattern: 'This house is {word}.', translation: 'Cette maison est {translation}.' },
|
||
{ pattern: 'I am {word}.', translation: 'Je suis {translation}.' },
|
||
{ pattern: 'The weather is {word}.', translation: 'Le temps est {translation}.' }
|
||
];
|
||
|
||
let sentences = [];
|
||
|
||
// Generate sentences for each vocabulary word based on type
|
||
this.vocabulary.forEach(vocab => {
|
||
let templates;
|
||
|
||
// Choose templates based on word type
|
||
if (vocab.type === 'verb') {
|
||
templates = verbTemplates;
|
||
} else if (vocab.type === 'adjective') {
|
||
templates = adjectiveTemplates;
|
||
} else {
|
||
// Default to noun templates for nouns and unknown types
|
||
templates = nounTemplates;
|
||
}
|
||
|
||
const template = templates[Math.floor(Math.random() * templates.length)];
|
||
const sentence = {
|
||
original: template.pattern.replace('{word}', vocab.original),
|
||
translation: template.translation.replace('{translation}', vocab.translation),
|
||
targetWord: vocab.original,
|
||
wordType: vocab.type || 'noun'
|
||
};
|
||
|
||
// Ensure sentence has at least 3 words for blanks
|
||
if (sentence.original.split(' ').length >= 3) {
|
||
sentences.push(sentence);
|
||
}
|
||
});
|
||
|
||
// Shuffle and limit sentences
|
||
sentences = this.shuffleArray(sentences);
|
||
|
||
logSh(`✅ Generated ${sentences.length} sentences from vocabulary`, 'INFO');
|
||
return sentences;
|
||
}
|
||
|
||
createGameBoard() {
|
||
this.container.innerHTML = `
|
||
<div class="fill-blank-wrapper">
|
||
<!-- Game Info -->
|
||
<div class="game-info">
|
||
<div class="game-stats">
|
||
<div class="stat-item">
|
||
<span class="stat-value" id="current-question">${this.currentSentenceIndex + 1}</span>
|
||
<span class="stat-label">/ ${this.sentences.length}</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-value" id="errors-count">${this.errors}</span>
|
||
<span class="stat-label">Errors</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-value" id="score-display">${this.score}</span>
|
||
<span class="stat-label">Score</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Translation hint -->
|
||
<div class="translation-hint" id="translation-hint">
|
||
<!-- Translation will appear here -->
|
||
</div>
|
||
|
||
<!-- Sentence with blanks -->
|
||
<div class="sentence-container" id="sentence-container">
|
||
<!-- Sentence with blanks will appear here -->
|
||
</div>
|
||
|
||
<!-- Input area -->
|
||
<div class="input-area" id="input-area">
|
||
<!-- Inputs will appear here -->
|
||
</div>
|
||
|
||
<!-- Controls -->
|
||
<div class="game-controls">
|
||
<button class="control-btn secondary" id="hint-btn">💡 Hint</button>
|
||
<button class="control-btn primary" id="check-btn">✓ Check</button>
|
||
<button class="control-btn secondary" id="skip-btn">→ Next</button>
|
||
</div>
|
||
|
||
<!-- Feedback Area -->
|
||
<div class="feedback-area" id="feedback-area">
|
||
<div class="instruction">
|
||
Complete the sentence by filling in the blanks!
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
setupEventListeners() {
|
||
document.getElementById('check-btn').addEventListener('click', () => this.checkAnswer());
|
||
document.getElementById('hint-btn').addEventListener('click', () => this.showHint());
|
||
document.getElementById('skip-btn').addEventListener('click', () => this.skipSentence());
|
||
|
||
// Enter key to check answer
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && this.isRunning) {
|
||
this.checkAnswer();
|
||
}
|
||
});
|
||
}
|
||
|
||
start() {
|
||
logSh('🎮 Fill the Blank: Starting game', 'INFO');
|
||
this.loadNextSentence();
|
||
}
|
||
|
||
restart() {
|
||
logSh('🔄 Fill the Blank: Restarting game', 'INFO');
|
||
this.reset();
|
||
this.start();
|
||
}
|
||
|
||
reset() {
|
||
this.score = 0;
|
||
this.errors = 0;
|
||
this.currentSentenceIndex = 0;
|
||
this.isRunning = false;
|
||
this.currentSentence = null;
|
||
this.blanks = [];
|
||
this.userAnswers = [];
|
||
this.onScoreUpdate(0);
|
||
}
|
||
|
||
loadNextSentence() {
|
||
// If we've finished all sentences, restart from the beginning
|
||
if (this.currentSentenceIndex >= this.sentences.length) {
|
||
this.currentSentenceIndex = 0;
|
||
this.sentences = this.shuffleArray(this.sentences); // Shuffle again
|
||
this.showFeedback(`🎉 All sentences completed! Starting over with a new order.`, 'success');
|
||
setTimeout(() => {
|
||
this.loadNextSentence();
|
||
}, 1500);
|
||
return;
|
||
}
|
||
|
||
this.isRunning = true;
|
||
this.currentSentence = this.sentences[this.currentSentenceIndex];
|
||
this.createBlanks();
|
||
this.displaySentence();
|
||
this.updateUI();
|
||
}
|
||
|
||
createBlanks() {
|
||
const words = this.currentSentence.original.split(' ');
|
||
this.blanks = [];
|
||
|
||
// Create 1-3 blanks depending on sentence length
|
||
const numBlanks = Math.min(Math.max(1, Math.floor(words.length / 4)), 3);
|
||
const blankIndices = new Set();
|
||
|
||
// Select random words (not articles/short prepositions)
|
||
const candidateWords = words.map((word, index) => ({ word, index }))
|
||
.filter(item => item.word.length > 2 && !['the', 'and', 'but', 'for', 'nor', 'or', 'so', 'yet'].includes(item.word.toLowerCase()));
|
||
|
||
// If not enough candidates, take any words
|
||
if (candidateWords.length < numBlanks) {
|
||
candidateWords = words.map((word, index) => ({ word, index }));
|
||
}
|
||
|
||
// Randomly select blank indices
|
||
const shuffledCandidates = this.shuffleArray(candidateWords);
|
||
for (let i = 0; i < Math.min(numBlanks, shuffledCandidates.length); i++) {
|
||
blankIndices.add(shuffledCandidates[i].index);
|
||
}
|
||
|
||
// Create blank structure
|
||
words.forEach((word, index) => {
|
||
if (blankIndices.has(index)) {
|
||
this.blanks.push({
|
||
index: index,
|
||
word: word.replace(/[.,!?;:]$/, ''), // Remove punctuation
|
||
punctuation: word.match(/[.,!?;:]$/) ? word.match(/[.,!?;:]$/)[0] : '',
|
||
userAnswer: ''
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
displaySentence() {
|
||
const words = this.currentSentence.original.split(' ');
|
||
let sentenceHTML = '';
|
||
let blankCounter = 0;
|
||
|
||
words.forEach((word, index) => {
|
||
const blank = this.blanks.find(b => b.index === index);
|
||
if (blank) {
|
||
sentenceHTML += `<span class="blank-wrapper">
|
||
<input type="text" class="blank-input"
|
||
id="blank-${blankCounter}"
|
||
placeholder="___"
|
||
maxlength="${blank.word.length + 2}">
|
||
${blank.punctuation}
|
||
</span> `;
|
||
blankCounter++;
|
||
} else {
|
||
sentenceHTML += `<span class="word">${word}</span> `;
|
||
}
|
||
});
|
||
|
||
document.getElementById('sentence-container').innerHTML = sentenceHTML;
|
||
|
||
// Display translation if available
|
||
const translation = this.currentSentence.translation || '';
|
||
document.getElementById('translation-hint').innerHTML = translation ?
|
||
`<em>💭 ${translation}</em>` : '';
|
||
|
||
// Focus on first input
|
||
const firstInput = document.getElementById('blank-0');
|
||
if (firstInput) {
|
||
setTimeout(() => firstInput.focus(), 100);
|
||
}
|
||
}
|
||
|
||
checkAnswer() {
|
||
if (!this.isRunning) return;
|
||
|
||
let allCorrect = true;
|
||
let correctCount = 0;
|
||
|
||
// Check each blank
|
||
this.blanks.forEach((blank, index) => {
|
||
const input = document.getElementById(`blank-${index}`);
|
||
const userAnswer = input.value.trim().toLowerCase();
|
||
const correctAnswer = blank.word.toLowerCase();
|
||
|
||
blank.userAnswer = input.value.trim();
|
||
|
||
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) {
|
||
// All answers are correct
|
||
this.score += 10 * this.blanks.length;
|
||
this.showFeedback(`🎉 Perfect! +${10 * this.blanks.length} points`, 'success');
|
||
setTimeout(() => {
|
||
this.currentSentenceIndex++;
|
||
this.loadNextSentence();
|
||
}, 1500);
|
||
} else {
|
||
// Some errors
|
||
this.errors++;
|
||
if (correctCount > 0) {
|
||
this.score += 5 * correctCount;
|
||
this.showFeedback(`✨ ${correctCount}/${this.blanks.length} correct! +${5 * correctCount} points. Try again.`, 'partial');
|
||
} else {
|
||
this.showFeedback(`❌ Try again! (${this.errors} errors)`, 'error');
|
||
}
|
||
}
|
||
|
||
this.updateUI();
|
||
this.onScoreUpdate(this.score);
|
||
}
|
||
|
||
showHint() {
|
||
// Show first letter of each empty blank
|
||
this.blanks.forEach((blank, index) => {
|
||
const input = document.getElementById(`blank-${index}`);
|
||
if (!input.value.trim()) {
|
||
input.value = blank.word[0];
|
||
input.focus();
|
||
}
|
||
});
|
||
|
||
this.showFeedback('💡 First letter added!', 'info');
|
||
}
|
||
|
||
skipSentence() {
|
||
// Reveal correct answers
|
||
this.blanks.forEach((blank, index) => {
|
||
const input = document.getElementById(`blank-${index}`);
|
||
input.value = blank.word;
|
||
input.classList.add('revealed');
|
||
});
|
||
|
||
this.showFeedback('📖 Answers revealed! Next sentence...', 'info');
|
||
setTimeout(() => {
|
||
this.currentSentenceIndex++;
|
||
this.loadNextSentence();
|
||
}, 2000);
|
||
}
|
||
|
||
// endGame method removed - game continues indefinitely
|
||
|
||
showFeedback(message, type = 'info') {
|
||
const feedbackArea = document.getElementById('feedback-area');
|
||
feedbackArea.innerHTML = `<div class="instruction ${type}">${message}</div>`;
|
||
}
|
||
|
||
updateUI() {
|
||
document.getElementById('current-question').textContent = this.currentSentenceIndex + 1;
|
||
document.getElementById('errors-count').textContent = this.errors;
|
||
document.getElementById('score-display').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;
|
||
}
|
||
|
||
destroy() {
|
||
this.isRunning = false;
|
||
this.container.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
// Module registration
|
||
window.GameModules = window.GameModules || {};
|
||
window.GameModules.FillTheBlank = FillTheBlankGame; |