import Module from '../core/Module.js'; /** * StoryReader - Interactive story reading game with vocabulary support * Allows reading stories sentence by sentence with word translations and TTS */ class StoryReader extends Module { constructor(name, dependencies, config = {}) { super(name, ['eventBus']); // Validate dependencies if (!dependencies.eventBus || !dependencies.content) { throw new Error('StoryReader requires eventBus and content dependencies'); } this._eventBus = dependencies.eventBus; this._content = dependencies.content; this._config = { container: null, autoPlayTTS: true, fontSize: 'medium', readingMode: 'sentence', ttsSpeed: 0.8, ...config }; // Reading state this._currentStory = null; this._availableStories = []; this._currentStoryIndex = 0; this._currentSentence = 0; this._totalSentences = 0; this._wordsRead = 0; this._vocabulary = {}; // UI state this._showTranslations = false; this._showPronunciations = false; this._readingTimer = null; this._startTime = null; this._totalReadingTime = 0; Object.seal(this); } /** * Get game metadata * @returns {Object} Game metadata */ static getMetadata() { return { name: 'Story Reader', description: 'Read stories with interactive vocabulary and translations', difficulty: 'beginner', category: 'reading', estimatedTime: 15, // minutes skills: ['reading', 'vocabulary', 'comprehension'] }; } /** * Calculate compatibility score with content * @param {Object} content - Content to check compatibility with * @returns {Object} Compatibility score and details */ static getCompatibilityScore(content) { let storyCount = 0; let hasMainStory = !!(content?.story?.title || content?.rawContent?.story?.title); let hasAdditionalStories = !!(content?.additionalStories?.length || content?.rawContent?.additionalStories?.length); let hasTexts = !!(content?.texts?.length || content?.rawContent?.texts?.length); let hasSentences = !!(content?.sentences?.length || content?.rawContent?.sentences?.length); if (hasMainStory) storyCount++; if (hasAdditionalStories) storyCount += (content?.additionalStories?.length || content?.rawContent?.additionalStories?.length); if (hasTexts) storyCount += (content?.texts?.length || content?.rawContent?.texts?.length); if (hasSentences) storyCount++; if (storyCount === 0) { return { score: 0, reason: 'No story content found', requirements: ['story', 'texts', 'or sentences'], details: 'Story Reader needs stories, texts, or sentences to read' }; } // Perfect score for multiple stories, good score for single story const score = Math.min(storyCount / 3, 1); return { score, reason: `${storyCount} story/text sources available`, requirements: ['story content'], optimalSources: 3, details: `Can create reading experience from ${storyCount} content sources` }; } async init() { this._validateNotDestroyed(); try { // Validate container if (!this._config.container) { throw new Error('Game container is required'); } // Discover and prepare story content this._discoverAvailableStories(); if (this._availableStories.length === 0) { throw new Error('No story content found for reading'); } // Select initial story this._selectStory(0); this._vocabulary = this._content?.vocabulary || this._content?.rawContent?.vocabulary || {}; // 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); // Initialize game interface this._injectCSS(); this._createGameInterface(); this._setupEventListeners(); this._loadProgress(); // Start reading session this._startTime = Date.now(); this._startReadingTimer(); // Render initial content this._renderCurrentSentence(); // Emit game ready event this._eventBus.emit('game:ready', { gameId: 'story-reader', instanceId: this.name, stories: this._availableStories.length, sentences: this._totalSentences }, this.name); this._setInitialized(); } catch (error) { this._showError(error.message); throw error; } } async destroy() { this._validateNotDestroyed(); // Save progress before cleanup this._saveProgress(); // Clean up timer if (this._readingTimer) { clearInterval(this._readingTimer); this._readingTimer = null; } // Clean up container if (this._config.container) { this._config.container.innerHTML = ''; } // Remove injected CSS this._removeInjectedCSS(); // Emit game end event this._eventBus.emit('game:ended', { gameId: 'story-reader', instanceId: this.name, wordsRead: this._wordsRead, readingTime: this._totalReadingTime, sentencesRead: this._currentSentence }, this.name); this._setDestroyed(); } /** * Get current game state * @returns {Object} Current game state */ getGameState() { this._validateInitialized(); return { currentStory: this._currentStoryIndex, currentSentence: this._currentSentence, totalSentences: this._totalSentences, wordsRead: this._wordsRead, readingTime: this._totalReadingTime, progress: this._totalSentences > 0 ? (this._currentSentence / this._totalSentences) * 100 : 0, isComplete: this._currentSentence >= this._totalSentences - 1 }; } // Private methods _discoverAvailableStories() { this._availableStories = []; // Check main story field const mainStory = this._content.rawContent?.story || this._content.story; if (mainStory && mainStory.title) { this._availableStories.push({ id: 'main', title: mainStory.title, data: mainStory, source: 'main' }); } // Check additional stories const additionalStories = this._content.rawContent?.additionalStories || this._content.additionalStories; if (additionalStories && Array.isArray(additionalStories)) { additionalStories.forEach((story, index) => { if (story && story.title) { this._availableStories.push({ id: `additional_${index}`, title: story.title, data: story, source: 'additional' }); } }); } // Check texts and convert to stories const texts = this._content.rawContent?.texts || this._content.texts; if (texts && Array.isArray(texts)) { texts.forEach((text, index) => { if (text && (text.title || text.original_language)) { const convertedStory = this._convertTextToStory(text, index); this._availableStories.push({ id: `text_${index}`, title: text.title || `Text ${index + 1}`, data: convertedStory, source: 'text' }); } }); } // Check sentences and create story from them const sentences = this._content.rawContent?.sentences || this._content.sentences; if (sentences && Array.isArray(sentences) && sentences.length > 0 && this._availableStories.length === 0) { const sentencesStory = this._convertSentencesToStory(sentences); this._availableStories.push({ id: 'sentences', title: 'Reading Practice', data: sentencesStory, source: 'sentences' }); } } _selectStory(storyIndex) { if (storyIndex >= 0 && storyIndex < this._availableStories.length) { this._currentStoryIndex = storyIndex; this._currentStory = this._availableStories[storyIndex].data; this._calculateTotalSentences(); // Reset reading position for new story this._currentSentence = 0; this._wordsRead = 0; } } _calculateTotalSentences() { this._totalSentences = 0; if (this._currentStory && this._currentStory.chapters) { this._currentStory.chapters.forEach(chapter => { this._totalSentences += chapter.sentences.length; }); } } _convertTextToStory(text, index) { const sentences = this._splitTextIntoSentences(text.original_language, text.user_language); return { title: text.title || `Text ${index + 1}`, totalSentences: sentences.length, chapters: [{ title: "Reading Text", sentences: sentences }] }; } _convertSentencesToStory(sentences) { const storyTitle = this._content.name || "Reading Practice"; const convertedSentences = sentences.map((sentence, index) => ({ id: index + 1, original: sentence.original_language || sentence.english || sentence.original || '', translation: sentence.user_language || sentence.chinese || sentence.french || sentence.translation || '' })); return { title: storyTitle, totalSentences: convertedSentences.length, chapters: [{ title: "Reading Sentences", sentences: convertedSentences }] }; } _splitTextIntoSentences(originalText, translationText) { const originalSentences = originalText.split(/[.!?]+/).filter(s => s.trim().length > 0); const translationSentences = translationText.split(/[.!?]+/).filter(s => s.trim().length > 0); const sentences = []; const maxSentences = Math.max(originalSentences.length, translationSentences.length); for (let i = 0; i < maxSentences; i++) { const original = (originalSentences[i] || '').trim(); const translation = (translationSentences[i] || '').trim(); if (original || translation) { sentences.push({ id: i + 1, original: original + (original && !original.match(/[.!?]$/) ? '.' : ''), translation: translation + (translation && !translation.match(/[.!?]$/) ? '.' : '') }); } } return sentences; } _injectCSS() { if (document.getElementById('story-reader-styles')) return; const styleSheet = document.createElement('style'); styleSheet.id = 'story-reader-styles'; styleSheet.textContent = ` .story-reader-wrapper { max-width: 800px; margin: 0 auto; padding: 20px; font-family: 'Georgia', serif; line-height: 1.6; height: 100vh; overflow-y: auto; box-sizing: border-box; } .story-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 2px solid #e2e8f0; } .story-title h2 { margin: 0 0 10px 0; color: #2d3748; font-size: 1.6em; } .reading-progress { display: flex; align-items: center; gap: 10px; font-size: 0.9em; color: #718096; } .progress-bar { width: 150px; height: 6px; background: #e2e8f0; border-radius: 3px; overflow: hidden; } .progress-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #10b981); width: 0%; transition: width 0.3s ease; } .story-controls { display: flex; gap: 8px; flex-wrap: wrap; } .control-btn { padding: 6px 12px; border: 1px solid #e2e8f0; background: white; border-radius: 6px; cursor: pointer; font-size: 0.85em; transition: all 0.2s; white-space: nowrap; } .control-btn:hover { background: #f7fafc; border-color: #cbd5e0; } .control-btn.active { background: #3b82f6; color: white; border-color: #3b82f6; } .reading-area { background: white; border: 2px solid #e2e8f0; border-radius: 12px; padding: 30px; margin-bottom: 20px; min-height: 200px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); } .sentence-display { text-align: center; margin-bottom: 20px; } .original-text { font-size: 1.2em; color: #2d3748; margin-bottom: 15px; line-height: 1.8; cursor: pointer; padding: 15px; border-radius: 8px; transition: background-color 0.2s; } .original-text:hover { background-color: #f7fafc; } .original-text.small { font-size: 1em; } .original-text.medium { font-size: 1.2em; } .original-text.large { font-size: 1.4em; } .original-text.extra-large { font-size: 1.6em; } .translation-text { font-style: italic; color: #718096; font-size: 1em; padding: 10px; background: #f0fff4; border-radius: 6px; border-left: 4px solid #10b981; display: none; } .translation-text.show { display: block; } .clickable-word { cursor: pointer; padding: 2px 4px; border-radius: 3px; transition: background-color 0.2s; position: relative; display: inline-block; } .clickable-word:hover { background-color: #fef5e7; color: #d69e2e; } .word-popup { position: fixed; background: white; border: 2px solid #3b82f6; border-radius: 6px; padding: 8px 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 9999; max-width: 200px; min-width: 120px; font-size: 0.9em; line-height: 1.3; display: none; } .word-original { font-weight: bold; color: #2d3748; margin-bottom: 3px; } .word-translation { color: #10b981; font-size: 0.9em; margin-bottom: 2px; } .word-type { font-size: 0.75em; color: #718096; font-style: italic; } .word-tts-btn { position: absolute; top: 5px; right: 5px; background: #3b82f6; color: white; border: none; border-radius: 50%; width: 20px; height: 20px; cursor: pointer; font-size: 10px; display: flex; align-items: center; justify-content: center; } .story-navigation { display: flex; justify-content: center; gap: 15px; margin-bottom: 20px; } .nav-btn { padding: 10px 20px; border: 2px solid #e2e8f0; background: white; border-radius: 8px; cursor: pointer; font-size: 0.95em; transition: all 0.2s; min-width: 100px; } .nav-btn:hover:not(:disabled) { background: #f7fafc; border-color: #cbd5e0; } .nav-btn:disabled { opacity: 0.5; cursor: not-allowed; } .nav-btn.primary { background: #3b82f6; color: white; border-color: #3b82f6; } .nav-btn.primary:hover { background: #2563eb; } .reading-stats { display: flex; justify-content: space-around; background: #f7fafc; padding: 15px; border-radius: 8px; border: 1px solid #e2e8f0; } .stat { text-align: center; } .stat-label { display: block; font-size: 0.8em; color: #718096; margin-bottom: 5px; } .stat-value { display: block; font-weight: bold; font-size: 1em; color: #2d3748; } .story-selector { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 15px; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; } .story-selector select { flex: 1; padding: 6px 10px; border: 1px solid #cbd5e0; border-radius: 4px; background: white; font-size: 0.9em; } @media (max-width: 768px) { .story-reader-wrapper { padding: 15px; } .story-header { flex-direction: column; gap: 15px; } .story-controls { justify-content: center; } .reading-stats { flex-direction: column; gap: 10px; } .story-navigation { flex-wrap: wrap; } .nav-btn { min-width: auto; padding: 8px 16px; } } `; document.head.appendChild(styleSheet); } _removeInjectedCSS() { const styleSheet = document.getElementById('story-reader-styles'); if (styleSheet) { styleSheet.remove(); } } _createGameInterface() { // Create story selector if multiple stories available const storySelector = this._availableStories.length > 1 ? `
` : ''; this._config.container.innerHTML = `
${storySelector}

${this._currentStory.title}

Sentence 1 of ${this._totalSentences}
Loading story...
Translation will appear here...
Progress 0%
Words Read 0
Time 00:00
`; } _setupEventListeners() { // Story selector const storySelect = document.getElementById('story-select'); if (storySelect) { storySelect.addEventListener('change', (e) => { this._changeStory(parseInt(e.target.value)); }); } // Navigation document.getElementById('prev-btn').addEventListener('click', () => this._previousSentence()); document.getElementById('next-btn').addEventListener('click', () => this._nextSentence()); // Controls document.getElementById('play-btn').addEventListener('click', () => this._playSentenceTTS()); document.getElementById('translation-btn').addEventListener('click', () => this._toggleTranslations()); document.getElementById('bookmark-btn').addEventListener('click', () => this._saveBookmark()); document.getElementById('exit-btn').addEventListener('click', () => { this._eventBus.emit('game:exit-request', { instanceId: this.name }, this.name); }); // TTS button in popup document.getElementById('popup-tts-btn').addEventListener('click', () => this._speakWordFromPopup()); // Keyboard shortcuts document.addEventListener('keydown', (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return; switch (e.key) { case 'ArrowLeft': this._previousSentence(); break; case 'ArrowRight': case ' ': e.preventDefault(); this._nextSentence(); break; case 't': case 'T': this._toggleTranslations(); break; case 's': case 'S': this._playSentenceTTS(); break; } }); // Click outside to close popup document.addEventListener('click', (e) => { if (!e.target.closest('.word-popup') && !e.target.closest('.clickable-word')) { this._hideWordPopup(); } }); } _getCurrentSentenceData() { let sentenceCount = 0; for (let chapterIndex = 0; chapterIndex < this._currentStory.chapters.length; chapterIndex++) { const chapter = this._currentStory.chapters[chapterIndex]; if (sentenceCount + chapter.sentences.length > this._currentSentence) { const sentenceInChapter = this._currentSentence - sentenceCount; return { chapter: chapterIndex, sentence: sentenceInChapter, data: chapter.sentences[sentenceInChapter], chapterTitle: chapter.title }; } sentenceCount += chapter.sentences.length; } return null; } _matchWordsWithVocabulary(sentence) { const words = sentence.split(/(\s+|[.,!?;:"'()[\]{}\-–—])/); const matchedWords = []; words.forEach(token => { if (/^\s+$/.test(token)) { matchedWords.push({ original: token, hasVocab: false, isWhitespace: true }); return; } if (/^[.,!?;:"'()[\]{}\-–—]+$/.test(token)) { matchedWords.push({ original: token, hasVocab: false, isPunctuation: true }); return; } const cleanWord = token.toLowerCase().replace(/[.,!?;:"'()[\]{}\-–—]/g, ''); if (!cleanWord) return; let vocabEntry = this._vocabulary[cleanWord]; // Try variations if exact match not found if (!vocabEntry) { if (cleanWord.endsWith('s')) vocabEntry = this._vocabulary[cleanWord.slice(0, -1)]; if (!vocabEntry && cleanWord.endsWith('ed')) vocabEntry = this._vocabulary[cleanWord.slice(0, -2)]; if (!vocabEntry && cleanWord.endsWith('ing')) vocabEntry = this._vocabulary[cleanWord.slice(0, -3)]; } if (vocabEntry) { matchedWords.push({ original: token, hasVocab: true, word: cleanWord, translation: vocabEntry.user_language || vocabEntry.translation, pronunciation: vocabEntry.pronunciation, type: vocabEntry.type || 'unknown' }); } else { matchedWords.push({ original: token, hasVocab: false }); } }); return matchedWords; } _renderCurrentSentence() { const sentenceData = this._getCurrentSentenceData(); if (!sentenceData) { console.warn('StoryReader: No sentence data found for current sentence index', this._currentSentence); return; } const { data } = sentenceData; if (!data || !data.original) { console.warn('StoryReader: Invalid sentence data - missing original text', data); return; } // Update progress const progress = ((this._currentSentence + 1) / this._totalSentences) * 100; const progressFill = document.getElementById('progress-fill'); const progressText = document.getElementById('progress-text'); const progressPercent = document.getElementById('progress-percent'); if (progressFill) progressFill.style.width = `${progress}%`; if (progressText) progressText.textContent = `Sentence ${this._currentSentence + 1} of ${this._totalSentences}`; if (progressPercent) progressPercent.textContent = `${Math.round(progress)}%`; // Render sentence with vocabulary matching const matchedWords = this._matchWordsWithVocabulary(data.original); const wordsHtml = matchedWords.map(wordInfo => { if (wordInfo.isWhitespace) return wordInfo.original; if (wordInfo.isPunctuation) return `${wordInfo.original}`; if (wordInfo.hasVocab) { return `${wordInfo.original}`; } return wordInfo.original; }).join(''); const originalTextEl = document.getElementById('original-text'); const translationTextEl = document.getElementById('translation-text'); const prevBtn = document.getElementById('prev-btn'); const nextBtn = document.getElementById('next-btn'); if (originalTextEl) originalTextEl.innerHTML = wordsHtml; if (translationTextEl) translationTextEl.textContent = data.translation || ''; // Add word click listeners document.querySelectorAll('.clickable-word').forEach(word => { word.addEventListener('click', (e) => this._showWordPopup(e)); }); // Update navigation buttons if (prevBtn) prevBtn.disabled = this._currentSentence === 0; if (nextBtn) nextBtn.disabled = this._currentSentence >= this._totalSentences - 1; // Update stats this._updateStats(); // Auto-play TTS if enabled if (this._config.autoPlayTTS) { setTimeout(() => this._playSentenceTTS(), 300); } } _showWordPopup(event) { const word = event.target.dataset.word; const translation = event.target.dataset.translation; const type = event.target.dataset.type; const pronunciation = event.target.dataset.pronunciation; const popup = document.getElementById('word-popup'); popup.currentWord = word; document.getElementById('popup-word').textContent = word; document.getElementById('popup-translation').textContent = translation; document.getElementById('popup-type').textContent = pronunciation ? `${pronunciation} (${type})` : `(${type})`; // Position popup const rect = event.target.getBoundingClientRect(); popup.style.display = 'block'; const popupLeft = Math.min(rect.left + (rect.width / 2) - 100, window.innerWidth - 210); const popupTop = rect.top - 10; popup.style.left = `${Math.max(10, popupLeft)}px`; popup.style.top = `${popupTop}px`; popup.style.transform = popupTop > 80 ? 'translateY(-100%)' : 'translateY(10px)'; } _hideWordPopup() { document.getElementById('word-popup').style.display = 'none'; } _previousSentence() { if (this._currentSentence > 0) { this._currentSentence--; this._renderCurrentSentence(); this._saveProgress(); } } _nextSentence() { if (this._currentSentence < this._totalSentences - 1) { this._currentSentence++; this._renderCurrentSentence(); this._saveProgress(); } else { this._completeReading(); } } _toggleTranslations() { this._showTranslations = !this._showTranslations; const translationElement = document.getElementById('translation-text'); const btn = document.getElementById('translation-btn'); if (this._showTranslations) { translationElement.classList.add('show'); btn.classList.add('active'); btn.textContent = '🌐 Hide'; } else { translationElement.classList.remove('show'); btn.classList.remove('active'); btn.textContent = '🌐 Translation'; } } _playSentenceTTS() { const sentenceData = this._getCurrentSentenceData(); if (!sentenceData) return; this._speakText(sentenceData.data.original); } _speakText(text, options = {}) { if (!text) return; try { if ('speechSynthesis' in window) { speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); utterance.lang = this._getContentLanguage(); utterance.rate = options.rate || this._config.ttsSpeed; utterance.volume = 1.0; speechSynthesis.speak(utterance); } } catch (error) { console.warn('TTS error:', error); } } _speakWordFromPopup() { const popup = document.getElementById('word-popup'); if (popup && popup.currentWord) { this._speakText(popup.currentWord, { rate: 0.7 }); } } _getContentLanguage() { if (this._content.language) { const langMap = { 'chinese': 'zh-CN', 'english': 'en-US', 'french': 'fr-FR', 'spanish': 'es-ES' }; return langMap[this._content.language] || this._content.language; } return 'en-US'; } _changeStory(storyIndex) { if (storyIndex !== this._currentStoryIndex) { this._saveProgress(); this._selectStory(storyIndex); this._loadProgress(); // Update interface document.querySelector('.story-title h2').textContent = this._currentStory.title; this._renderCurrentSentence(); } } _startReadingTimer() { this._readingTimer = setInterval(() => { this._updateReadingTime(); }, 1000); } _updateReadingTime() { const currentTime = Date.now(); this._totalReadingTime = Math.floor((currentTime - this._startTime) / 1000); const minutes = Math.floor(this._totalReadingTime / 60); const seconds = this._totalReadingTime % 60; const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; document.getElementById('time-count').textContent = timeString; } _updateStats() { const sentenceData = this._getCurrentSentenceData(); if (sentenceData && sentenceData.data.original) { // Count words in current sentence const wordCount = sentenceData.data.original.split(/\s+/).length; this._wordsRead += wordCount; document.getElementById('words-count').textContent = this._wordsRead; } } _saveProgress() { const progressData = { currentSentence: this._currentSentence, wordsRead: this._wordsRead, timestamp: Date.now() }; const progressKey = this._getProgressKey(); localStorage.setItem(progressKey, JSON.stringify(progressData)); } _loadProgress() { const progressKey = this._getProgressKey(); const saved = localStorage.getItem(progressKey); if (saved) { try { const data = JSON.parse(saved); this._currentSentence = data.currentSentence || 0; this._wordsRead = data.wordsRead || 0; } catch (error) { this._currentSentence = 0; this._wordsRead = 0; } } else { this._currentSentence = 0; this._wordsRead = 0; } } _getProgressKey() { const storyId = this._availableStories[this._currentStoryIndex]?.id || 'main'; return `story_progress_${this._content.name}_${storyId}`; } _saveBookmark() { this._saveProgress(); // Show toast notification const toast = document.createElement('div'); toast.textContent = '🔖 Bookmark saved!'; toast.style.cssText = ` position: fixed; top: 20px; right: 20px; background: #10b981; color: white; padding: 10px 20px; border-radius: 6px; z-index: 1000; font-size: 0.9em; `; document.body.appendChild(toast); setTimeout(() => toast.remove(), 2000); } _completeReading() { // Emit completion event this._eventBus.emit('game:completed', { gameId: 'story-reader', instanceId: this.name, wordsRead: this._wordsRead, readingTime: this._totalReadingTime, sentencesRead: this._totalSentences }, this.name); // Show completion message const completionMessage = `

🎉 Story Complete!

You've finished reading "${this._currentStory.title}"

Words read: ${this._wordsRead}

Reading time: ${Math.floor(this._totalReadingTime / 60)}:${(this._totalReadingTime % 60).toString().padStart(2, '0')}

`; document.querySelector('.reading-area').innerHTML = completionMessage; } _showError(message) { if (this._config.container) { this._config.container.innerHTML = `

Story Reader Error

${message}

`; } } _handlePause() { if (this._readingTimer) { clearInterval(this._readingTimer); this._readingTimer = null; } this._eventBus.emit('game:paused', { instanceId: this.name }, this.name); } _handleResume() { this._startReadingTimer(); this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name); } } export default StoryReader;