import Module from '../core/Module.js'; import ttsService from '../services/TTSService.js'; /** * ThematicQuestionsGame - Listening and speaking practice with self-assessment * Students listen to questions (TTS), answer them, can reveal text if needed, * view example responses, and self-assess their spoken answers */ class ThematicQuestionsGame extends Module { constructor(name, dependencies, config = {}) { super(name, ['eventBus']); // Validate dependencies if (!dependencies.eventBus || !dependencies.content) { throw new Error('ThematicQuestionsGame requires eventBus and content dependencies'); } this._eventBus = dependencies.eventBus; this._content = dependencies.content; this._config = { container: null, autoPlayTTS: false, // Auto-play question on load ...config }; // Game state this._questions = []; this._currentIndex = 0; this._score = 0; this._correctCount = 0; this._incorrectCount = 0; this._showingExamples = false; this._hasAnswered = false; this._gameStartTime = null; this._questionStartTime = null; Object.seal(this); } /** * Get game metadata * @returns {Object} Game metadata */ static getMetadata() { return { name: 'Thematic Questions', description: 'Listen to questions and practice speaking with self-assessment', difficulty: 'beginner', category: 'listening', estimatedTime: 10, // minutes skills: ['listening', 'speaking', 'comprehension', 'self-assessment'] }; } /** * Calculate compatibility score with content * @param {Object} content - Content to check compatibility with * @returns {Object} Compatibility score and details */ static getCompatibilityScore(content) { const thematicQuestions = content?.thematic_questions || {}; // Count total questions across all themes let totalQuestions = 0; for (const theme of Object.values(thematicQuestions)) { if (Array.isArray(theme)) { totalQuestions += theme.length; } } if (totalQuestions < 5) { return { score: 0, reason: `Insufficient questions (${totalQuestions}/5 required)`, requirements: ['thematic_questions'], minQuestions: 5, details: 'Thematic Questions needs at least 5 questions to play' }; } // Perfect score at 20+ questions, partial score for 5-19 const score = Math.min(totalQuestions / 20, 1); return { score, reason: `${totalQuestions} thematic questions available`, requirements: ['thematic_questions'], minQuestions: 5, optimalQuestions: 20, details: `Can create practice session with ${totalQuestions} questions` }; } async init() { this._validateNotDestroyed(); try { // Validate container if (!this._config.container) { throw new Error('Game container is required'); } // Extract and validate questions this._questions = this._extractQuestions(); if (this._questions.length === 0) { throw new Error('No thematic questions found in content'); } // 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._setupEventListeners(); // Start the game this._gameStartTime = Date.now(); this._showQuestion(); // Emit game ready event this._eventBus.emit('game:ready', { gameId: 'thematic-questions', instanceId: this.name, questionsCount: this._questions.length }, this.name); this._setInitialized(); } catch (error) { this._showError(error.message); throw error; } } async destroy() { this._validateNotDestroyed(); // 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: 'thematic-questions', instanceId: this.name, score: this._score, questionsAnswered: this._currentIndex, totalQuestions: this._questions.length, correctCount: this._correctCount, incorrectCount: this._incorrectCount, 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._currentIndex, totalQuestions: this._questions.length, correctCount: this._correctCount, incorrectCount: this._incorrectCount, isComplete: this._currentIndex >= this._questions.length, duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0 }; } // Private methods _extractQuestions() { const thematicQuestions = this._content?.thematic_questions || {}; const allQuestions = []; // Flatten all themes into single array for (const [themeName, questions] of Object.entries(thematicQuestions)) { if (Array.isArray(questions)) { questions.forEach(q => { allQuestions.push({ ...q, themeName: themeName }); }); } } // Shuffle questions randomly (Fisher-Yates algorithm) return this._shuffleArray(allQuestions); } _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; } _injectCSS() { const cssId = `thematic-questions-styles-${this.name}`; if (document.getElementById(cssId)) return; const style = document.createElement('style'); style.id = cssId; style.textContent = ` .thematic-questions-game { padding: 20px; max-width: 800px; margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .tq-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; color: white; box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); } .tq-stats { display: flex; gap: 30px; } .tq-stat { text-align: center; } .tq-stat-label { display: block; font-size: 0.8rem; opacity: 0.9; margin-bottom: 5px; } .tq-stat-value { display: block; font-size: 1.5rem; font-weight: bold; } .question-card { background: white; border-radius: 12px; padding: 40px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); margin-bottom: 20px; min-height: 400px; display: flex; flex-direction: column; } .question-theme { display: inline-block; padding: 6px 12px; background: #e3f2fd; color: #1976d2; border-radius: 20px; font-size: 0.85rem; margin-bottom: 20px; font-weight: 500; } .question-progress { text-align: center; color: #6c757d; font-size: 0.9rem; margin-bottom: 10px; } .question-display { flex: 1; display: flex; flex-direction: column; justify-content: center; text-align: center; margin-bottom: 30px; } .listening-prompt { font-size: 1.5rem; color: #667eea; margin-bottom: 40px; font-weight: 600; padding: 20px; background: linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%); border-radius: 12px; animation: pulse 2s ease-in-out infinite; } @keyframes pulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.02); } } .question-text { font-size: 2rem; color: #333; margin-bottom: 15px; font-weight: 600; line-height: 1.4; padding: 20px; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #28a745; transition: all 0.4s ease; } .question-text.hidden { display: none; } .question-text.visible { display: block; animation: slideDown 0.4s ease; } .question-translation { font-size: 1.2rem; color: #6c757d; font-style: italic; margin-bottom: 25px; padding: 15px; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #dc3545; transition: all 0.4s ease; } .question-translation.hidden { display: none; } .question-translation.visible { display: block; animation: slideDown 0.4s ease; } @keyframes slideDown { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } } .reveal-controls { display: flex; justify-content: center; gap: 15px; margin-bottom: 20px; } .btn-reveal { padding: 12px 24px; background: #17a2b8; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 1rem; transition: all 0.3s ease; display: flex; align-items: center; gap: 8px; } .btn-reveal:hover:not(:disabled) { background: #138496; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(23, 162, 184, 0.3); } .btn-reveal:disabled { cursor: not-allowed; opacity: 0.5; } .tts-controls { display: flex; justify-content: center; gap: 15px; margin-bottom: 30px; } .btn-tts { padding: 12px 24px; background: #667eea; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 1rem; transition: all 0.3s ease; display: flex; align-items: center; gap: 8px; } .btn-tts:hover { background: #5568d3; transform: translateY(-2px); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); } .btn-tts:active { transform: translateY(0); } .btn-show-examples { padding: 12px 24px; background: #28a745; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 1rem; transition: all 0.3s ease; } .btn-show-examples:hover { background: #218838; transform: translateY(-2px); } .btn-show-examples.active { background: #ffc107; color: #000; } .examples-container { background: #f8f9fa; border-radius: 8px; padding: 20px; margin-bottom: 30px; border-left: 4px solid #667eea; max-height: 0; overflow: hidden; transition: max-height 0.3s ease, padding 0.3s ease, margin 0.3s ease; } .examples-container.visible { max-height: 500px; margin-bottom: 30px; padding: 20px; } .examples-container.hidden { padding: 0; margin: 0; } .examples-title { font-size: 1.1rem; font-weight: 600; color: #333; margin-bottom: 15px; } .example-item { padding: 10px 15px; background: white; border-radius: 6px; margin-bottom: 10px; font-size: 1rem; color: #495057; border-left: 3px solid #667eea; } .example-item:last-child { margin-bottom: 0; } .assessment-section { text-align: center; margin-top: 20px; } .assessment-title { font-size: 1.1rem; color: #333; margin-bottom: 15px; font-weight: 500; } .assessment-buttons { display: flex; gap: 15px; justify-content: center; } .btn-correct { padding: 15px 40px; background: #28a745; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 1.1rem; font-weight: 600; transition: all 0.3s ease; display: flex; align-items: center; gap: 10px; } .btn-correct:hover { background: #218838; transform: translateY(-2px); box-shadow: 0 6px 20px rgba(40, 167, 69, 0.3); } .btn-incorrect { padding: 15px 40px; background: #dc3545; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 1.1rem; font-weight: 600; transition: all 0.3s ease; display: flex; align-items: center; gap: 10px; } .btn-incorrect:hover { background: #c82333; transform: translateY(-2px); box-shadow: 0 6px 20px rgba(220, 53, 69, 0.3); } .btn-next { display: block; margin: 20px auto 0; padding: 12px 30px; background: #667eea; color: white; border: none; border-radius: 8px; font-size: 1rem; cursor: pointer; transition: all 0.3s ease; } .btn-next:hover { background: #5568d3; transform: translateY(-2px); } .feedback-message { text-align: center; padding: 15px; border-radius: 8px; margin: 20px 0; font-size: 1.1rem; font-weight: 500; } .feedback-message.correct { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .feedback-message.incorrect { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } .tq-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) { .tq-header { flex-direction: column; gap: 20px; } .tq-stats { gap: 20px; } .question-card { padding: 20px; } .question-text { font-size: 1.5rem; } .question-translation { font-size: 1rem; } .assessment-buttons { flex-direction: column; } .btn-correct, .btn-incorrect { width: 100%; } } `; document.head.appendChild(style); } _removeCSS() { const cssId = `thematic-questions-styles-${this.name}`; const existingStyle = document.getElementById(cssId); if (existingStyle) { existingStyle.remove(); } } _createGameInterface() { this._config.container.innerHTML = `
✅ Correct 0
❌ Incorrect 0
Progress 0/${this._questions.length}
`; } _setupEventListeners() { // Exit button const exitButton = this._config.container.querySelector('#exit-game'); if (exitButton) { exitButton.addEventListener('click', () => { this._eventBus.emit('game:exit-request', { instanceId: this.name }, this.name); }); } // Event delegation for dynamic buttons this._config.container.addEventListener('click', (event) => { if (event.target.matches('#tts-btn') || event.target.closest('#tts-btn')) { this._handleTTS(); } if (event.target.matches('#reveal-english-btn') || event.target.closest('#reveal-english-btn')) { this._revealText('english'); } if (event.target.matches('#reveal-chinese-btn') || event.target.closest('#reveal-chinese-btn')) { this._revealText('chinese'); } if (event.target.matches('#show-examples-btn') || event.target.closest('#show-examples-btn')) { this._toggleExamples(); } if (event.target.matches('#btn-correct') || event.target.closest('#btn-correct')) { this._handleSelfAssessment(true); } if (event.target.matches('#btn-incorrect') || event.target.closest('#btn-incorrect')) { this._handleSelfAssessment(false); } if (event.target.matches('#next-btn') || event.target.closest('#next-btn')) { this._nextQuestion(); } }); } _showQuestion() { if (this._currentIndex >= this._questions.length) { this._showResults(); return; } const question = this._questions[this._currentIndex]; const content = document.getElementById('tq-content'); this._showingExamples = false; this._hasAnswered = false; this._questionStartTime = Date.now(); content.innerHTML = `
Question ${this._currentIndex + 1} of ${this._questions.length}
${this._formatThemeName(question.themeName)}
🎧 Listen to the question and answer it
Was your answer correct?
`; // Auto-play TTS on question load for listening exercise if (question.tts_enabled) { setTimeout(() => this._handleTTS(), 200); } } _formatThemeName(theme) { return theme .split('_') .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); } _revealText(language) { if (language === 'english') { const textElement = document.getElementById('question-text-en'); const btn = document.getElementById('reveal-english-btn'); if (textElement && btn) { textElement.classList.remove('hidden'); textElement.classList.add('visible'); btn.disabled = true; btn.style.opacity = '0.5'; } } else if (language === 'chinese') { const textElement = document.getElementById('question-text-zh'); const btn = document.getElementById('reveal-chinese-btn'); if (textElement && btn) { textElement.classList.remove('hidden'); textElement.classList.add('visible'); btn.disabled = true; btn.style.opacity = '0.5'; } } } _toggleExamples() { const container = document.getElementById('examples-container'); const btn = document.getElementById('show-examples-btn'); if (!container || !btn) return; this._showingExamples = !this._showingExamples; if (this._showingExamples) { container.classList.remove('hidden'); container.classList.add('visible'); btn.classList.add('active'); btn.innerHTML = ` 👁️ Hide Examples `; } else { container.classList.remove('visible'); container.classList.add('hidden'); btn.classList.remove('active'); btn.innerHTML = ` 💡 Show Examples `; } } _handleSelfAssessment(isCorrect) { if (this._hasAnswered) return; this._hasAnswered = true; const question = this._questions[this._currentIndex]; // Update stats if (isCorrect) { this._correctCount++; this._score += 100; } else { this._incorrectCount++; } // Show feedback this._showFeedback(isCorrect); this._updateStats(); // Record time spent const timeSpent = this._questionStartTime ? Date.now() - this._questionStartTime : 0; // Emit answer event this._eventBus.emit('thematic-questions:answer', { gameId: 'thematic-questions', instanceId: this.name, questionNumber: this._currentIndex + 1, question: question.question, isCorrect, score: this._score, timeSpent }, this.name); } _showFeedback(isCorrect) { const assessmentSection = document.getElementById('assessment-section'); const feedbackContainer = document.getElementById('feedback-container'); if (!assessmentSection || !feedbackContainer) return; // Hide assessment buttons assessmentSection.style.display = 'none'; // Show feedback const feedbackClass = isCorrect ? 'correct' : 'incorrect'; const feedbackIcon = isCorrect ? '🎉' : '💪'; const feedbackText = isCorrect ? 'Great job! Your answer was correct!' : 'Keep practicing! Try to review the examples.'; feedbackContainer.innerHTML = `
${feedbackIcon} ${feedbackText}
`; } _nextQuestion() { this._currentIndex++; this._showQuestion(); } _showResults() { const accuracy = this._questions.length > 0 ? Math.round((this._correctCount / this._questions.length) * 100) : 0; const totalTime = this._gameStartTime ? Date.now() - this._gameStartTime : 0; // Store best score const gameKey = 'thematic-questions'; 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: 'Thematic Questions', currentScore, bestScore: isNewBest ? currentScore : bestScore, isNewBest, stats: { 'Questions': `${this._questions.length}`, 'Correct': `${this._correctCount}`, 'Incorrect': `${this._incorrectCount}`, 'Accuracy': `${accuracy}%`, 'Total Time': `${Math.round(totalTime / 1000)}s` } }); // Emit completion event this._eventBus.emit('game:completed', { gameId: 'thematic-questions', instanceId: this.name, score: this._score, correctCount: this._correctCount, incorrectCount: this._incorrectCount, totalQuestions: this._questions.length, accuracy, duration: totalTime }, this.name); } _updateStats() { const correctElement = document.getElementById('tq-correct'); const incorrectElement = document.getElementById('tq-incorrect'); const currentElement = document.getElementById('tq-current'); if (correctElement) correctElement.textContent = this._correctCount; if (incorrectElement) incorrectElement.textContent = this._incorrectCount; if (currentElement) currentElement.textContent = this._currentIndex + 1; } _handleTTS() { const question = this._questions[this._currentIndex]; if (question && question.tts_enabled && question.question) { this._playAudio(question.question); } } async _playAudio(text) { // Get language from chapter content, fallback to en-US const chapterLanguage = this._content?.language || 'en-US'; // Visual feedback const ttsBtn = document.getElementById('tts-btn'); let originalHTML = ''; if (ttsBtn) { originalHTML = ttsBtn.innerHTML; ttsBtn.innerHTML = '🔄Speaking...'; ttsBtn.disabled = true; } try { await ttsService.speak(text, chapterLanguage, { rate: 0.85, volume: 1.0 }); } catch (error) { console.warn('🔊 Speech Synthesis error:', error); } finally { if (ttsBtn) { ttsBtn.innerHTML = originalHTML; ttsBtn.disabled = false; } } } _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); requestAnimationFrame(() => { popup.classList.add('show'); }); // Event listeners popup.querySelector('#play-again-btn').addEventListener('click', () => { popup.remove(); this._restartGame(); }); 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 = '/'; } }); 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'; } } }); } _restartGame() { this._currentIndex = 0; this._score = 0; this._correctCount = 0; this._incorrectCount = 0; this._showingExamples = false; this._hasAnswered = false; this._gameStartTime = Date.now(); this._updateStats(); this._showQuestion(); } _showError(message) { if (this._config.container) { this._config.container.innerHTML = `

Game Error

${message}

`; } } _handlePause() { this._eventBus.emit('game:paused', { instanceId: this.name }, this.name); } _handleResume() { this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name); } } export default ThematicQuestionsGame;