import Module from '../core/Module.js'; /** * QuizGame - Educational vocabulary quiz with multiple choice questions * Supports bidirectional quizzing (English to translation or translation to English) */ class QuizGame extends Module { constructor(name, dependencies, config = {}) { super(name, ['eventBus']); // Validate dependencies if (!dependencies.eventBus || !dependencies.content) { throw new Error('QuizGame requires eventBus and content dependencies'); } this._eventBus = dependencies.eventBus; this._content = dependencies.content; this._config = { container: null, questionCount: 10, optionsCount: 6, timeLimit: 30, // seconds per question ...config }; // Game state this._vocabulary = null; this._currentQuestion = 0; this._score = 0; this._questions = []; this._currentQuestionData = null; this._isAnswering = false; this._gameStartTime = null; this._questionStartTime = null; this._quizDirection = 'en-to-translation'; // or 'translation-to-en' this._timeRemaining = 0; this._timer = null; Object.seal(this); } /** * Get game metadata * @returns {Object} Game metadata */ static getMetadata() { return { name: 'Vocabulary Quiz', description: 'Multiple choice vocabulary quiz with bidirectional testing', difficulty: 'intermediate', category: 'quiz', estimatedTime: 8, // minutes skills: ['vocabulary', 'comprehension', 'recognition', 'speed'] }; } /** * Calculate compatibility score with content * @param {Object} content - Content to check compatibility with * @returns {Object} Compatibility score and details */ static getCompatibilityScore(content) { const vocab = content?.vocabulary || {}; const vocabCount = Object.keys(vocab).length; if (vocabCount < 6) { return { score: 0, reason: `Insufficient vocabulary (${vocabCount}/6 required)`, requirements: ['vocabulary'], minWords: 6, details: 'Quiz Game needs at least 6 vocabulary words for multiple choice options' }; } // Perfect score at 20+ words, partial score for 6-19 const score = Math.min(vocabCount / 20, 1); return { score, reason: `${vocabCount} vocabulary words available`, requirements: ['vocabulary'], minWords: 6, optimalWords: 20, details: `Can create quiz with ${Math.min(vocabCount, 10)} questions` }; } async init() { this._validateNotDestroyed(); try { // Validate container if (!this._config.container) { throw new Error('Game container is required'); } // Extract and validate vocabulary this._vocabulary = this._extractVocabulary(); if (this._vocabulary.length < this._config.optionsCount) { throw new Error(`Insufficient vocabulary: need ${this._config.optionsCount}, got ${this._vocabulary.length}`); } // Set up event listeners this._eventBus.on('game:pause', this._handlePause.bind(this), this.name); this._eventBus.on('game:resume', this._handleResume.bind(this), this.name); // Inject CSS this._injectCSS(); // Initialize game interface this._createGameInterface(); this._generateQuestions(); this._setupEventListeners(); // Start the game this._gameStartTime = Date.now(); this._showQuizDirectionChoice(); // Emit game ready event this._eventBus.emit('game:ready', { gameId: 'quiz-game', instanceId: this.name, vocabulary: this._vocabulary.length, questionsCount: this._questions.length }, this.name); this._setInitialized(); } catch (error) { this._showError(error.message); throw error; } } async destroy() { this._validateNotDestroyed(); // Clear timer if (this._timer) { clearInterval(this._timer); this._timer = null; } // Remove CSS this._removeCSS(); // Clean up event listeners if (this._config.container) { this._config.container.innerHTML = ''; } // Emit game end event this._eventBus.emit('game:ended', { gameId: 'quiz-game', instanceId: this.name, score: this._score, questionsAnswered: this._currentQuestion, totalQuestions: this._questions.length, duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0 }, this.name); this._setDestroyed(); } /** * Get current game state * @returns {Object} Current game state */ getGameState() { this._validateInitialized(); return { score: this._score, currentQuestion: this._currentQuestion, totalQuestions: this._questions.length, isComplete: this._currentQuestion >= this._questions.length, duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0, timeRemaining: this._timeRemaining }; } // Private methods _extractVocabulary() { const vocab = this._content?.vocabulary || {}; const vocabulary = []; for (const [word, data] of Object.entries(vocab)) { if (data.user_language) { vocabulary.push({ english: word, translation: data.user_language, type: data.type || 'unknown' }); } } return this._shuffleArray(vocabulary); } _injectCSS() { const cssId = `quiz-game-styles-${this.name}`; if (document.getElementById(cssId)) return; const style = document.createElement('style'); style.id = cssId; style.textContent = ` .quiz-game { padding: 20px; max-width: 800px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .quiz-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; padding: 20px; background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e0 100%); border-radius: 12px; color: #2d3748; border: 2px solid #4299e1; font-weight: 600; } .quiz-stats { display: flex; gap: 30px; } .quiz-stat { text-align: center; } .stat-label { display: block; font-size: 0.8rem; opacity: 0.9; margin-bottom: 5px; } .stat-value { display: block; font-size: 1.5rem; font-weight: bold; } .quiz-direction-choice { text-align: center; padding: 40px; background: #f8f9fa; border-radius: 12px; margin-bottom: 30px; } .quiz-direction-choice h3 { margin-bottom: 20px; color: #333; } .direction-buttons { display: flex; gap: 20px; justify-content: center; flex-wrap: wrap; } .direction-btn { padding: 15px 30px; border: none; border-radius: 8px; background: #007bff; color: white; font-size: 1rem; cursor: pointer; transition: all 0.3s ease; min-width: 200px; } .direction-btn:hover { background: #0056b3; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 123, 255, 0.3); } .question-container { background: white; border-radius: 12px; padding: 30px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); margin-bottom: 20px; } .question-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; padding-bottom: 15px; border-bottom: 2px solid #e9ecef; } .question-number { font-size: 1.1rem; color: #6c757d; font-weight: 500; } .question-timer { display: flex; align-items: center; gap: 10px; color: #dc3545; font-weight: bold; } .timer-circle { width: 40px; height: 40px; border-radius: 50%; background: #dc3545; color: white; display: flex; align-items: center; justify-content: center; font-size: 0.9rem; } .timer-circle.warning { background: #ffc107; color: #000; } .timer-circle.safe { background: #28a745; } .question-text { font-size: 1.5rem; text-align: center; margin-bottom: 30px; padding: 20px; background: #f8f9fa; border-radius: 8px; color: #333; font-weight: 500; } .quiz-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 15px; margin-bottom: 30px; } .quiz-option { padding: 20px; border: 2px solid #e9ecef; border-radius: 8px; background: white; cursor: pointer; transition: all 0.3s ease; text-align: center; font-size: 1.1rem; position: relative; } .quiz-option:hover { border-color: #007bff; background: #f8f9fa; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 123, 255, 0.2); } .quiz-option.selected { border-color: #007bff; background: #e3f2fd; } .quiz-option.correct { border-color: #28a745; background: #d4edda; color: #155724; } .quiz-option.incorrect { border-color: #dc3545; background: #f8d7da; color: #721c24; } .quiz-option.disabled { pointer-events: none; opacity: 0.7; } .quiz-feedback { text-align: center; padding: 20px; border-radius: 8px; margin: 20px 0; font-size: 1.1rem; font-weight: 500; } .quiz-feedback.correct { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .quiz-feedback.incorrect { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } .next-question-btn { display: block; margin: 20px auto; padding: 12px 30px; background: #28a745; color: white; border: none; border-radius: 8px; font-size: 1rem; cursor: pointer; transition: all 0.3s ease; } .next-question-btn:hover { background: #218838; transform: translateY(-2px); } .quiz-complete { text-align: center; padding: 40px; background: linear-gradient(135deg, #28a745 0%, #20c997 100%); color: white; border-radius: 12px; } .quiz-complete h2 { margin-bottom: 20px; } .final-score { font-size: 2rem; font-weight: bold; margin: 20px 0; } .quiz-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 20px; margin: 30px 0; background: rgba(255, 255, 255, 0.1); padding: 20px; border-radius: 8px; } .summary-stat { text-align: center; } .summary-stat-value { display: block; font-size: 1.5rem; font-weight: bold; margin-bottom: 5px; } .summary-stat-label { font-size: 0.9rem; opacity: 0.9; } .restart-btn, .exit-btn { margin: 10px; padding: 12px 25px; border: 2px solid white; border-radius: 8px; background: transparent; color: white; font-size: 1rem; cursor: pointer; transition: all 0.3s ease; } .restart-btn:hover, .exit-btn:hover { background: white; color: #28a745; } .quiz-error { text-align: center; padding: 40px; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 12px; color: #721c24; } .error-icon { font-size: 3rem; margin-bottom: 20px; } @media (max-width: 768px) { .quiz-header { flex-direction: column; gap: 20px; } .quiz-stats { gap: 20px; } .quiz-options { grid-template-columns: 1fr; } .direction-buttons { flex-direction: column; align-items: center; } } `; document.head.appendChild(style); } _removeCSS() { const cssId = `quiz-game-styles-${this.name}`; const existingStyle = document.getElementById(cssId); if (existingStyle) { existingStyle.remove(); } } _createGameInterface() { this._config.container.innerHTML = `
Score 0
Question 0/${this._config.questionCount}
Accuracy 0%
`; } _generateQuestions() { this._questions = []; const questionCount = Math.min(this._config.questionCount, this._vocabulary.length); const selectedVocab = this._shuffleArray([...this._vocabulary]).slice(0, questionCount); selectedVocab.forEach((vocab, index) => { this._questions.push({ id: index, vocabulary: vocab, options: this._generateOptions(vocab), correctAnswer: null, // Will be set based on quiz direction userAnswer: null, timeSpent: 0, answered: false }); }); } _generateOptions(correctVocab) { const options = []; const otherVocab = this._vocabulary.filter(v => v !== correctVocab); const shuffledOthers = this._shuffleArray(otherVocab); // Add correct answer options.push(correctVocab); // Add incorrect options for (let i = 0; i < this._config.optionsCount - 1 && i < shuffledOthers.length; i++) { options.push(shuffledOthers[i]); } return this._shuffleArray(options); } _showQuizDirectionChoice() { const content = document.getElementById('quiz-content'); content.innerHTML = `

Choose Quiz Direction

How would you like to be tested?

`; } _startQuiz(direction) { this._quizDirection = direction; this._currentQuestion = 0; this._score = 0; // Set correct answers based on direction this._questions.forEach(question => { if (direction === 'en-to-translation') { question.correctAnswer = question.vocabulary.translation; } else { question.correctAnswer = question.vocabulary.english; } }); this._showQuestion(); } _showQuestion() { if (this._currentQuestion >= this._questions.length) { this._showResults(); return; } const question = this._questions[this._currentQuestion]; const content = document.getElementById('quiz-content'); // Determine question text based on direction const questionText = this._quizDirection === 'en-to-translation' ? question.vocabulary.english : question.vocabulary.translation; content.innerHTML = `
Question ${this._currentQuestion + 1} of ${this._questions.length}
${this._config.timeLimit}
${questionText}
${this._generateOptionHTML(question)}
`; this._startQuestionTimer(); this._questionStartTime = Date.now(); } _generateOptionHTML(question) { return question.options.map((vocab, index) => { const optionText = this._quizDirection === 'en-to-translation' ? vocab.translation : vocab.english; return `
${optionText}
`; }).join(''); } _startQuestionTimer() { this._timeRemaining = this._config.timeLimit; this._timer = setInterval(() => { this._timeRemaining--; this._updateTimer(); if (this._timeRemaining <= 0) { this._handleTimeout(); } }, 1000); } _updateTimer() { const timerValue = document.getElementById('timer-value'); const timerCircle = document.getElementById('timer-circle'); if (timerValue) { timerValue.textContent = this._timeRemaining; } if (timerCircle) { timerCircle.className = 'timer-circle'; if (this._timeRemaining <= 5) { timerCircle.classList.add('warning'); } else if (this._timeRemaining > 15) { timerCircle.classList.add('safe'); } } } _setupEventListeners() { // Direction choice listeners this._config.container.addEventListener('click', (event) => { if (event.target.matches('.direction-btn')) { const direction = event.target.dataset.direction; this._startQuiz(direction); } if (event.target.matches('.quiz-option')) { this._handleAnswerClick(event.target); } if (event.target.matches('.next-question-btn')) { this._nextQuestion(); } if (event.target.matches('.restart-btn')) { this._restartQuiz(); } }); // Exit button const exitButton = this._config.container.querySelector('#exit-quiz'); if (exitButton) { exitButton.addEventListener('click', () => { this._eventBus.emit('game:exit-request', { instanceId: this.name }, this.name); }); } } _handleAnswerClick(optionElement) { if (this._isAnswering || optionElement.classList.contains('disabled')) { return; } this._isAnswering = true; const question = this._questions[this._currentQuestion]; const selectedAnswer = optionElement.dataset.value; const isCorrect = selectedAnswer === question.correctAnswer; // Clear timer if (this._timer) { clearInterval(this._timer); this._timer = null; } // Record answer question.userAnswer = selectedAnswer; question.answered = true; question.timeSpent = this._questionStartTime ? Date.now() - this._questionStartTime : 0; // Update score if (isCorrect) { const timeBonus = Math.max(0, this._timeRemaining * 2); this._score += 100 + timeBonus; } // Show feedback this._showAnswerFeedback(isCorrect, question); this._updateStats(); // Emit answer event this._eventBus.emit('quiz:answer', { gameId: 'quiz-game', instanceId: this.name, questionNumber: this._currentQuestion + 1, isCorrect, score: this._score, timeSpent: question.timeSpent }, this.name); } _handleTimeout() { if (this._timer) { clearInterval(this._timer); this._timer = null; } const question = this._questions[this._currentQuestion]; question.answered = true; question.timeSpent = this._config.timeLimit * 1000; this._showAnswerFeedback(false, question, true); this._updateStats(); } _showAnswerFeedback(isCorrect, question, timeout = false) { const optionsContainer = document.getElementById('quiz-options'); const feedbackContainer = document.getElementById('quiz-feedback'); // Disable all options optionsContainer.querySelectorAll('.quiz-option').forEach(option => { option.classList.add('disabled'); const optionValue = option.dataset.value; if (optionValue === question.correctAnswer) { option.classList.add('correct'); } else if (optionValue === question.userAnswer) { option.classList.add('incorrect'); } }); // Show feedback message let feedbackMessage; if (timeout) { feedbackMessage = `⏰ Time's up! The correct answer was: ${question.correctAnswer}`; } else if (isCorrect) { feedbackMessage = `🎉 Correct! +${100 + Math.max(0, this._timeRemaining * 2)} points`; } else { feedbackMessage = `❌ Incorrect. The correct answer was: ${question.correctAnswer}`; } feedbackContainer.innerHTML = `
${feedbackMessage}
`; } _nextQuestion() { this._currentQuestion++; this._isAnswering = false; this._showQuestion(); } _showResults() { const correctAnswers = this._questions.filter(q => q.userAnswer === q.correctAnswer).length; const accuracy = Math.round((correctAnswers / this._questions.length) * 100); const totalTime = this._gameStartTime ? Date.now() - this._gameStartTime : 0; const avgTimePerQuestion = Math.round(totalTime / this._questions.length / 1000); // Store best score const gameKey = 'quiz-game'; const currentScore = this._score; const bestScore = parseInt(localStorage.getItem(`${gameKey}-best-score`) || '0'); const isNewBest = currentScore > bestScore; if (isNewBest) { localStorage.setItem(`${gameKey}-best-score`, currentScore.toString()); } // Show victory popup this._showVictoryPopup({ gameTitle: 'Vocabulary Quiz', currentScore, bestScore: isNewBest ? currentScore : bestScore, isNewBest, stats: { 'Questions': `${correctAnswers}/${this._questions.length}`, 'Accuracy': `${accuracy}%`, 'Avg Time': `${avgTimePerQuestion}s`, 'Total Time': `${Math.round(totalTime / 1000)}s` } }); // Emit completion event this._eventBus.emit('game:completed', { gameId: 'quiz-game', instanceId: this.name, score: this._score, correctAnswers, totalQuestions: this._questions.length, accuracy, duration: totalTime }, this.name); } _restartQuiz() { this._currentQuestion = 0; this._score = 0; this._isAnswering = false; this._generateQuestions(); this._showQuizDirectionChoice(); this._updateStats(); } _updateStats() { const scoreElement = document.getElementById('quiz-score'); const questionElement = document.getElementById('current-question'); const accuracyElement = document.getElementById('quiz-accuracy'); if (scoreElement) scoreElement.textContent = this._score; if (questionElement) questionElement.textContent = this._currentQuestion + 1; if (accuracyElement && this._currentQuestion > 0) { const answeredQuestions = this._questions.slice(0, this._currentQuestion + 1).filter(q => q.answered); const correctAnswers = answeredQuestions.filter(q => q.userAnswer === q.correctAnswer).length; const accuracy = answeredQuestions.length > 0 ? Math.round((correctAnswers / answeredQuestions.length) * 100) : 0; accuracyElement.textContent = `${accuracy}%`; } } _showError(message) { if (this._config.container) { this._config.container.innerHTML = `

Quiz Error

${message}

`; } } _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; } _handlePause() { if (this._timer) { clearInterval(this._timer); this._timer = null; } this._eventBus.emit('game:paused', { instanceId: this.name }, this.name); } _handleResume() { if (this._timeRemaining > 0 && !this._isAnswering) { this._startQuestionTimer(); } this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name); } _showVictoryPopup({ gameTitle, currentScore, bestScore, isNewBest, stats }) { const popup = document.createElement('div'); popup.className = 'victory-popup'; popup.innerHTML = `
🏆

${gameTitle} Complete!

${isNewBest ? '
🎉 New Best Score!
' : ''}
Your Score
${currentScore}
Best Score
${bestScore}
${Object.entries(stats).map(([key, value]) => `
${key}
${value}
`).join('')}
`; document.body.appendChild(popup); // Animate in requestAnimationFrame(() => { popup.classList.add('show'); }); // Add event listeners popup.querySelector('#play-again-btn').addEventListener('click', () => { popup.remove(); this._restartQuiz(); }); popup.querySelector('#different-game-btn').addEventListener('click', () => { popup.remove(); if (window.app && window.app.getCore().router) { window.app.getCore().router.navigate('/games'); } else { window.location.href = '/#/games'; } }); popup.querySelector('#main-menu-btn').addEventListener('click', () => { popup.remove(); if (window.app && window.app.getCore().router) { window.app.getCore().router.navigate('/'); } else { window.location.href = '/'; } }); // Close on backdrop click popup.addEventListener('click', (e) => { if (e.target === popup) { popup.remove(); if (window.app && window.app.getCore().router) { window.app.getCore().router.navigate('/games'); } else { window.location.href = '/#/games'; } } }); } } export default QuizGame;