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 = `
${message}