Class_generator/js/games/quiz-game.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

662 lines
25 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 QUIZ GAME ===
class QuizGame {
constructor(options) {
this.container = options.container;
this.content = options.content;
this.onScoreUpdate = options.onScoreUpdate || (() => {});
this.onGameEnd = options.onGameEnd || (() => {});
// Game state
this.vocabulary = [];
this.currentQuestion = 0;
this.totalQuestions = 10;
this.score = 0;
this.correctAnswers = 0;
this.currentQuestionData = null;
this.hasAnswered = false;
this.quizDirection = 'original_to_translation'; // 'original_to_translation' or 'translation_to_original'
// Extract vocabulary and additional words from texts/stories
this.vocabulary = this.extractVocabulary(this.content);
this.allWords = this.extractAllWords(this.content);
this.init();
}
init() {
// Check if we have enough vocabulary
if (!this.vocabulary || this.vocabulary.length < 6) {
logSh('Not enough vocabulary for Quiz Game', 'ERROR');
this.showInitError();
return;
}
// Adjust total questions based on available vocabulary
this.totalQuestions = Math.min(this.totalQuestions, this.vocabulary.length);
this.createGameInterface();
this.generateQuestion();
}
showInitError() {
this.container.innerHTML = `
<div class="game-error">
<h3>❌ Error loading</h3>
<p>This content doesn't have enough vocabulary for Quiz Game.</p>
<p>The game needs at least 6 vocabulary items.</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 (simple 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: 'car', translation: 'voiture', category: 'objects' },
{ original: 'book', translation: 'livre', category: 'objects' }
];
logSh('🚨 Using demo vocabulary', 'WARN');
}
// Shuffle vocabulary for random questions
vocabulary = this.shuffleArray(vocabulary);
logSh(`✅ Quiz Game: ${vocabulary.length} vocabulary words finalized`, 'INFO');
return vocabulary;
}
extractAllWords(content) {
let allWords = [];
// Add vocabulary words first
allWords = [...this.vocabulary];
// Extract from stories/texts
if (content.rawContent?.story?.chapters) {
content.rawContent.story.chapters.forEach(chapter => {
if (chapter.sentences) {
chapter.sentences.forEach(sentence => {
if (sentence.words && Array.isArray(sentence.words)) {
sentence.words.forEach(wordObj => {
if (wordObj.word && wordObj.translation) {
allWords.push({
original: wordObj.word,
translation: wordObj.translation,
type: wordObj.type || 'word',
pronunciation: wordObj.pronunciation
});
}
});
}
});
}
});
}
// Extract from additional stories (like WTA1B1)
if (content.rawContent?.additionalStories) {
content.rawContent.additionalStories.forEach(story => {
if (story.chapters) {
story.chapters.forEach(chapter => {
if (chapter.sentences) {
chapter.sentences.forEach(sentence => {
if (sentence.words && Array.isArray(sentence.words)) {
sentence.words.forEach(wordObj => {
if (wordObj.word && wordObj.translation) {
allWords.push({
original: wordObj.word,
translation: wordObj.translation,
type: wordObj.type || 'word',
pronunciation: wordObj.pronunciation
});
}
});
}
});
}
});
}
});
}
// Remove duplicates based on original word
const uniqueWords = [];
const seenWords = new Set();
allWords.forEach(word => {
const key = word.original.toLowerCase();
if (!seenWords.has(key)) {
seenWords.add(key);
uniqueWords.push(word);
}
});
logSh(`📚 Extracted ${uniqueWords.length} total words for quiz options`, 'INFO');
return uniqueWords;
}
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;
}
createGameInterface() {
this.container.innerHTML = `
<div class="quiz-game-wrapper">
<!-- Top Controls - Restart button moved to top left -->
<div class="quiz-top-controls">
<button class="control-btn secondary restart-top" id="restart-btn">🔄 Restart</button>
</div>
<!-- Progress Bar -->
<div class="quiz-progress">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<div class="progress-text">
<span id="question-counter">1 / ${this.totalQuestions}</span>
<span id="score-display">Score: 0</span>
</div>
</div>
<!-- Question Area -->
<div class="question-area">
<div class="question-text" id="question-text">
Loading question...
</div>
</div>
<!-- Options Area -->
<div class="options-area" id="options-area">
<!-- Options will be generated here -->
</div>
<!-- Controls -->
<div class="quiz-controls">
<button class="control-btn primary" id="next-btn" style="display: none;">Next Question →</button>
</div>
<!-- Feedback Area -->
<div class="feedback-area" id="feedback-area">
<div class="instruction">
Choose the correct translation!
</div>
</div>
</div>
`;
// Add CSS for top controls
const style = document.createElement('style');
style.textContent = `
.quiz-top-controls {
position: absolute;
top: 10px;
left: 10px;
z-index: 10;
}
.restart-top {
background: rgba(255, 255, 255, 0.9) !important;
border: 2px solid #ccc !important;
color: #666 !important;
font-size: 12px !important;
padding: 8px 12px !important;
border-radius: 6px !important;
box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important;
}
.restart-top:hover {
background: rgba(255, 255, 255, 1) !important;
border-color: #999 !important;
color: #333 !important;
}
`;
document.head.appendChild(style);
this.setupEventListeners();
}
setupEventListeners() {
document.getElementById('next-btn').addEventListener('click', () => this.nextQuestion());
document.getElementById('restart-btn').addEventListener('click', () => this.restart());
}
generateQuestion() {
if (this.currentQuestion >= this.totalQuestions) {
this.gameComplete();
return;
}
this.hasAnswered = false;
// Get current vocabulary item
const correctAnswer = this.vocabulary[this.currentQuestion];
// Randomly choose quiz direction
this.quizDirection = Math.random() < 0.5 ? 'original_to_translation' : 'translation_to_original';
let questionText, correctAnswerText, sourceForWrongAnswers;
if (this.quizDirection === 'original_to_translation') {
questionText = correctAnswer.original;
correctAnswerText = correctAnswer.translation;
sourceForWrongAnswers = 'translation';
} else {
questionText = correctAnswer.translation;
correctAnswerText = correctAnswer.original;
sourceForWrongAnswers = 'original';
}
// Generate 5 wrong answers from allWords (which includes story words)
const availableWords = this.allWords.length >= 6 ? this.allWords : this.vocabulary;
const wrongAnswers = availableWords
.filter(item => item !== correctAnswer)
.sort(() => Math.random() - 0.5)
.slice(0, 5)
.map(item => sourceForWrongAnswers === 'translation' ? item.translation : item.original);
// Combine and shuffle all options (1 correct + 5 wrong = 6 total)
const allOptions = [correctAnswerText, ...wrongAnswers].sort(() => Math.random() - 0.5);
this.currentQuestionData = {
question: questionText,
correctAnswer: correctAnswerText,
options: allOptions,
direction: this.quizDirection
};
this.renderQuestion();
this.updateProgress();
}
renderQuestion() {
const { question, options } = this.currentQuestionData;
// Update question text with direction indicator
const direction = this.currentQuestionData.direction;
const directionText = direction === 'original_to_translation' ?
'What is the translation of' : 'What is the original word for';
document.getElementById('question-text').innerHTML = `
${directionText} "<strong>${question}</strong>"?
`;
// Clear and generate options
const optionsArea = document.getElementById('options-area');
optionsArea.innerHTML = '';
options.forEach((option, index) => {
const optionButton = document.createElement('button');
optionButton.className = 'quiz-option';
optionButton.textContent = option;
optionButton.addEventListener('click', () => this.selectAnswer(option, optionButton));
optionsArea.appendChild(optionButton);
});
// Hide next button
document.getElementById('next-btn').style.display = 'none';
}
selectAnswer(selectedAnswer, buttonElement) {
if (this.hasAnswered) return;
this.hasAnswered = true;
const isCorrect = selectedAnswer === this.currentQuestionData.correctAnswer;
// Disable all option buttons and show results
const allOptions = document.querySelectorAll('.quiz-option');
allOptions.forEach(btn => {
btn.disabled = true;
if (btn.textContent === this.currentQuestionData.correctAnswer) {
btn.classList.add('correct');
} else if (btn === buttonElement && !isCorrect) {
btn.classList.add('wrong');
} else if (btn !== buttonElement && btn.textContent !== this.currentQuestionData.correctAnswer) {
btn.classList.add('disabled');
}
});
// Update score and feedback
if (isCorrect) {
this.correctAnswers++;
this.score += 10;
this.showFeedback('✅ Correct! Well done!', 'success');
} else {
this.score = Math.max(0, this.score - 5);
this.showFeedback(`❌ Wrong! Correct answer: "${this.currentQuestionData.correctAnswer}"`, 'error');
}
this.updateScore();
// Show next button or finish
if (this.currentQuestion < this.totalQuestions - 1) {
document.getElementById('next-btn').style.display = 'block';
} else {
setTimeout(() => this.gameComplete(), 250);
}
}
nextQuestion() {
this.currentQuestion++;
this.generateQuestion();
}
updateProgress() {
const progressFill = document.getElementById('progress-fill');
const progressPercent = ((this.currentQuestion + 1) / this.totalQuestions) * 100;
progressFill.style.width = `${progressPercent}%`;
document.getElementById('question-counter').textContent =
`${this.currentQuestion + 1} / ${this.totalQuestions}`;
}
updateScore() {
document.getElementById('score-display').textContent = `Score: ${this.score}`;
this.onScoreUpdate(this.score);
}
gameComplete() {
const accuracy = Math.round((this.correctAnswers / this.totalQuestions) * 100);
// Bonus for high accuracy
if (accuracy >= 90) {
this.score += 50; // Excellence bonus
} else if (accuracy >= 70) {
this.score += 20; // Good performance bonus
}
this.updateScore();
this.showFeedback(
`🎉 Quiz completed! ${this.correctAnswers}/${this.totalQuestions} correct (${accuracy}%)`,
'success'
);
setTimeout(() => {
this.onGameEnd(this.score);
}, 3000);
}
showFeedback(message, type = 'info') {
const feedbackArea = document.getElementById('feedback-area');
feedbackArea.innerHTML = `<div class="instruction ${type}">${message}</div>`;
}
start() {
logSh('❓ Quiz Game: Starting', 'INFO');
this.showFeedback('Choose the correct translation for each word!', 'info');
}
restart() {
logSh('🔄 Quiz Game: Restarting', 'INFO');
this.reset();
this.start();
}
reset() {
this.currentQuestion = 0;
this.score = 0;
this.correctAnswers = 0;
this.hasAnswered = false;
this.currentQuestionData = null;
// Re-shuffle vocabulary
this.vocabulary = this.shuffleArray(this.vocabulary);
this.generateQuestion();
this.updateScore();
}
destroy() {
this.container.innerHTML = '';
}
// === COMPATIBILITY SYSTEM ===
static getCompatibilityRequirements() {
return {
minimum: {
vocabulary: 8
},
optimal: {
vocabulary: 16
},
name: "Quiz Game",
description: "Needs vocabulary with translations for multiple choice questions"
};
}
static checkContentCompatibility(content) {
const requirements = QuizGame.getCompatibilityRequirements();
// Extract vocabulary using same method as instance
const vocabulary = QuizGame.extractVocabularyStatic(content);
const vocabCount = vocabulary.length;
// Dynamic percentage based on optimal volume (8 min → 16 optimal)
// 0 words = 0%, 8 words = 50%, 16 words = 100%
const score = Math.min(100, (vocabCount / requirements.optimal.vocabulary) * 100);
return {
score: Math.round(score),
details: {
vocabulary: {
found: vocabCount,
minimum: requirements.minimum.vocabulary,
optimal: requirements.optimal.vocabulary,
status: vocabCount >= requirements.minimum.vocabulary ? 'sufficient' : 'insufficient'
}
},
recommendations: vocabCount < requirements.optimal.vocabulary ?
[`Add ${requirements.optimal.vocabulary - vocabCount} more vocabulary words for optimal experience`] :
[]
};
}
static extractVocabularyStatic(content) {
let vocabulary = [];
// Priority 1: Use raw module content (simple format)
if (content.rawContent) {
return QuizGame.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 QuizGame.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 QuizGame.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;
}
}
// Module registration
window.GameModules = window.GameModules || {};
window.GameModules.QuizGame = QuizGame;