Major Changes: - Moved legacy system to Legacy/ folder for archival - Built new modular architecture with strict separation of concerns - Created core system: Module, EventBus, ModuleLoader, Router - Added Application bootstrap with auto-start functionality - Implemented development server with ES6 modules support - Created comprehensive documentation and project context - Converted SBS-7-8 content to JSON format - Copied all legacy games and content to new structure New Architecture Features: - Sealed modules with WeakMap private data - Strict dependency injection system - Event-driven communication only - Inviolable responsibility patterns - Auto-initialization without commands - Component-based UI foundation ready Technical Stack: - Vanilla JS/HTML/CSS only - ES6 modules with proper imports/exports - HTTP development server (no file:// protocol) - Modular CSS with component scoping - Comprehensive error handling and debugging Ready for Phase 2: Converting legacy modules to new architecture 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
529 lines
20 KiB
JavaScript
529 lines
20 KiB
JavaScript
// === 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 = '';
|
||
}
|
||
}
|
||
|
||
// Module registration
|
||
window.GameModules = window.GameModules || {};
|
||
window.GameModules.QuizGame = QuizGame; |