Class_generator/js/games/fill-the-blank.js
StillHammer 24362165ab Implement dynamic percentage compatibility system across all games
Major architectural update to replace fixed 50%/100% scoring with true dynamic percentages based on content volume:

• Replace old interpolation system with Math.min(100, (count/optimal)*100) formula
• Add embedded compatibility methods to all 14 game modules with static requirements
• Remove compatibility cache system for real-time calculation
• Fix content loading to pass complete modules with vocabulary (not just metadata)
• Clean up duplicate syntax errors in adventure-reader and grammar-discovery
• Update navigation.js module mapping to match actual exported class names

Examples of new dynamic scoring:
- 15 words / 20 optimal = 75% (was 87.5% with old interpolation)
- 5 words / 10 minimum = 50% (was 25% with old linear system)
- 30 words / 20 optimal = 100% (unchanged)

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-20 17:00:52 +08:00

791 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// === 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.extractRealSentences();
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;
}
extractRealSentences() {
let sentences = [];
logSh('🔍 Extracting real sentences from content...', 'INFO');
// Priority 1: Extract from story chapters
if (this.content.story?.chapters) {
this.content.story.chapters.forEach(chapter => {
if (chapter.sentences) {
chapter.sentences.forEach(sentence => {
if (sentence.original && sentence.translation) {
sentences.push({
original: sentence.original,
translation: sentence.translation,
source: 'story'
});
}
});
}
});
}
// Priority 2: Extract from rawContent story
if (this.content.rawContent?.story?.chapters) {
this.content.rawContent.story.chapters.forEach(chapter => {
if (chapter.sentences) {
chapter.sentences.forEach(sentence => {
if (sentence.original && sentence.translation) {
sentences.push({
original: sentence.original,
translation: sentence.translation,
source: 'rawContent.story'
});
}
});
}
});
}
// Priority 3: Extract from sentences array
const directSentences = this.content.sentences || this.content.rawContent?.sentences;
if (directSentences && Array.isArray(directSentences)) {
directSentences.forEach(sentence => {
if (sentence.english && sentence.chinese) {
sentences.push({
original: sentence.english,
translation: sentence.chinese,
source: 'sentences'
});
} else if (sentence.original && sentence.translation) {
sentences.push({
original: sentence.original,
translation: sentence.translation,
source: 'sentences'
});
}
});
}
// Filter sentences that are suitable for fill-the-blank (min 3 words)
sentences = sentences.filter(sentence =>
sentence.original &&
sentence.original.split(' ').length >= 3 &&
sentence.original.trim().length > 0
);
// Shuffle and limit
sentences = this.shuffleArray(sentences);
logSh(`📝 Extracted ${sentences.length} real sentences for fill-the-blank`, 'INFO');
if (sentences.length === 0) {
logSh('❌ No suitable sentences found for fill-the-blank', 'ERROR');
return this.createFallbackSentences();
}
return sentences.slice(0, 20); // Limit to 20 sentences max
}
createFallbackSentences() {
// Simple fallback using vocabulary words in basic sentences
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.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-2 blanks randomly (readable sentences)
const numBlanks = Math.random() < 0.5 ? 1 : 2;
const blankIndices = new Set();
// PRIORITY 1: Words from vocabulary (educational value)
const vocabularyWords = [];
const otherWords = [];
words.forEach((word, index) => {
const cleanWord = word.replace(/[.,!?;:"'()[\]{}\-–—]/g, '').toLowerCase();
const isVocabularyWord = this.vocabulary.some(vocab =>
vocab.original.toLowerCase() === cleanWord
);
if (isVocabularyWord) {
vocabularyWords.push({ word, index, priority: 'vocabulary' });
} else {
otherWords.push({ word, index, priority: 'other', length: cleanWord.length });
}
});
// Select blanks: vocabulary first, then longest words
const selectedWords = [];
// Take vocabulary words first (shuffled)
const shuffledVocab = this.shuffleArray(vocabularyWords);
for (let i = 0; i < Math.min(numBlanks, shuffledVocab.length); i++) {
selectedWords.push(shuffledVocab[i]);
}
// If need more blanks, take longest other words
if (selectedWords.length < numBlanks) {
const sortedOthers = otherWords.sort((a, b) => b.length - a.length);
const needed = numBlanks - selectedWords.length;
for (let i = 0; i < Math.min(needed, sortedOthers.length); i++) {
selectedWords.push(sortedOthers[i]);
}
}
// Add selected indices to blanks
selectedWords.forEach(item => blankIndices.add(item.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 = '';
}
// === COMPATIBILITY SYSTEM ===
static getCompatibilityRequirements() {
return {
minimum: {
vocabulary: 5,
sentences: 3
},
optimal: {
vocabulary: 12,
sentences: 8
},
name: "Fill the Blank",
description: "Needs vocabulary and sentences/texts to create meaningful cloze exercises"
};
}
static checkContentCompatibility(content) {
const requirements = FillTheBlankGame.getCompatibilityRequirements();
// Extract vocabulary and sentences using same method as instance
const vocabulary = FillTheBlankGame.extractVocabularyStatic(content);
const sentences = FillTheBlankGame.extractSentencesStatic(content);
const vocabCount = vocabulary.length;
const sentenceCount = sentences.length;
// Dynamic percentage based on optimal volumes (5→12 vocab, 3→8 sentences)
// Vocabulary: 0=0%, 6=50%, 12=100%
// Sentences: 0=0%, 4=50%, 8=100%
const vocabScore = Math.min(100, (vocabCount / requirements.optimal.vocabulary) * 100);
const sentenceScore = Math.min(100, (sentenceCount / requirements.optimal.sentences) * 100);
// Combined score (weighted average: 60% vocabulary, 40% sentences)
const finalScore = (vocabScore * 0.6) + (sentenceScore * 0.4);
const recommendations = [];
if (vocabCount < requirements.optimal.vocabulary) {
recommendations.push(`Add ${requirements.optimal.vocabulary - vocabCount} more vocabulary words`);
}
if (sentenceCount < requirements.optimal.sentences) {
recommendations.push(`Add ${requirements.optimal.sentences - sentenceCount} more sentences/texts`);
}
return {
score: Math.round(finalScore),
details: {
vocabulary: {
found: vocabCount,
minimum: requirements.minimum.vocabulary,
optimal: requirements.optimal.vocabulary,
status: vocabCount >= requirements.minimum.vocabulary ? 'sufficient' : 'insufficient'
},
sentences: {
found: sentenceCount,
minimum: requirements.minimum.sentences,
optimal: requirements.optimal.sentences,
status: sentenceCount >= requirements.minimum.sentences ? 'sufficient' : 'insufficient'
}
},
recommendations: recommendations
};
}
static extractVocabularyStatic(content) {
let vocabulary = [];
// Priority 1: Use raw module content (simple format)
if (content.rawContent) {
return FillTheBlankGame.extractVocabularyFromRawStatic(content.rawContent);
}
// Priority 2: Ultra-modular format (vocabulary object) - ONLY format supported
if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) {
vocabulary = Object.entries(content.vocabulary).map(([word, data]) => {
// Support ultra-modular format ONLY
if (typeof data === 'object' && data.user_language) {
return {
original: word,
translation: data.user_language.split('')[0],
fullTranslation: data.user_language,
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);
}
return FillTheBlankGame.finalizeVocabularyStatic(vocabulary);
}
static extractVocabularyFromRawStatic(rawContent) {
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,
translation: data.user_language.split('')[0],
fullTranslation: data.user_language,
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);
}
return FillTheBlankGame.finalizeVocabularyStatic(vocabulary);
}
static finalizeVocabularyStatic(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() !== ''
);
return vocabulary;
}
static extractSentencesStatic(content) {
let sentences = [];
// Priority 1: Use raw module content
if (content.rawContent) {
// Extract from sentences array
if (content.rawContent.sentences && Array.isArray(content.rawContent.sentences)) {
content.rawContent.sentences.forEach(sentence => {
if (sentence.english) {
sentences.push(sentence.english);
}
if (sentence.chinese) {
sentences.push(sentence.chinese);
}
if (sentence.french) {
sentences.push(sentence.french);
}
});
}
// Extract from texts array
if (content.rawContent.texts && Array.isArray(content.rawContent.texts)) {
content.rawContent.texts.forEach(text => {
if (text.content) {
// Split text content into sentences
const textSentences = text.content.split(/[.!?]+/).filter(s => s.trim().length > 10);
sentences.push(...textSentences);
}
});
}
}
// Priority 2: Direct content properties
if (content.sentences && Array.isArray(content.sentences)) {
content.sentences.forEach(sentence => {
if (sentence.english) {
sentences.push(sentence.english);
}
if (sentence.chinese) {
sentences.push(sentence.chinese);
}
if (sentence.french) {
sentences.push(sentence.french);
}
});
}
if (content.texts && Array.isArray(content.texts)) {
content.texts.forEach(text => {
if (text.content) {
const textSentences = text.content.split(/[.!?]+/).filter(s => s.trim().length > 10);
sentences.push(...textSentences);
}
});
}
// Filter and validate sentences
sentences = sentences.filter(sentence =>
sentence &&
typeof sentence === 'string' &&
sentence.trim().length > 5 &&
sentence.split(' ').length >= 3
);
return sentences;
}
}
// Module registration
window.GameModules = window.GameModules || {};
window.GameModules.FillTheBlank = FillTheBlankGame;