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 ? `
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')}
${message}