Class_generator/src/games/StoryReader.js
StillHammer 05142bdfbc Implement comprehensive AI text report/export system
- Add AIReportSystem.js for detailed AI response capture and report generation
- Add AIReportInterface.js UI component for report access and export
- Integrate AI reporting into LLMValidator and SmartPreviewOrchestrator
- Add missing modules to Application.js configuration (unifiedDRS, smartPreviewOrchestrator)
- Create missing content/chapters/sbs.json for book metadata
- Enhance Application.js with debug logging for module loading
- Add multi-format export capabilities (text, HTML, JSON)
- Implement automatic learning insights extraction from AI feedback
- Add session management and performance tracking for AI reports

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-26 21:24:13 +08:00

1168 lines
40 KiB
JavaScript

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 ? `
<div class="story-selector">
<label for="story-select">📚 Story:</label>
<select id="story-select">
${this._availableStories.map((story, index) =>
`<option value="${index}" ${index === this._currentStoryIndex ? 'selected' : ''}>${story.title}</option>`
).join('')}
</select>
</div>
` : '';
this._config.container.innerHTML = `
<div class="story-reader-wrapper">
${storySelector}
<!-- Header -->
<div class="story-header">
<div class="story-title">
<h2>${this._currentStory.title}</h2>
<div class="reading-progress">
<span id="progress-text">Sentence 1 of ${this._totalSentences}</span>
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
</div>
</div>
<div class="story-controls">
<button class="control-btn" id="play-btn">🔊 Play</button>
<button class="control-btn" id="translation-btn">🌐 Translation</button>
<button class="control-btn" id="bookmark-btn">🔖 Save</button>
<button class="control-btn" id="exit-btn">← Exit</button>
</div>
</div>
<!-- Reading Area -->
<div class="reading-area">
<div class="sentence-display">
<div class="original-text ${this._config.fontSize}" id="original-text">
Loading story...
</div>
<div class="translation-text" id="translation-text">
Translation will appear here...
</div>
</div>
</div>
<!-- Word popup -->
<div class="word-popup" id="word-popup">
<div class="word-original" id="popup-word"></div>
<div class="word-translation" id="popup-translation"></div>
<div class="word-type" id="popup-type"></div>
<button class="word-tts-btn" id="popup-tts-btn">🔊</button>
</div>
<!-- Navigation -->
<div class="story-navigation">
<button class="nav-btn" id="prev-btn" disabled>⬅️ Previous</button>
<button class="nav-btn primary" id="next-btn">Next ➡️</button>
</div>
<!-- Stats -->
<div class="reading-stats">
<div class="stat">
<span class="stat-label">Progress</span>
<span class="stat-value" id="progress-percent">0%</span>
</div>
<div class="stat">
<span class="stat-label">Words Read</span>
<span class="stat-value" id="words-count">0</span>
</div>
<div class="stat">
<span class="stat-label">Time</span>
<span class="stat-value" id="time-count">00:00</span>
</div>
</div>
</div>
`;
}
_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 `<span class="punctuation">${wordInfo.original}</span>`;
if (wordInfo.hasVocab) {
return `<span class="clickable-word" data-word="${wordInfo.word}" data-translation="${wordInfo.translation}" data-type="${wordInfo.type}" data-pronunciation="${wordInfo.pronunciation || ''}">${wordInfo.original}</span>`;
}
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 = `
<div style="text-align: center; padding: 40px;">
<h2>🎉 Story Complete!</h2>
<p>You've finished reading "${this._currentStory.title}"</p>
<p><strong>Words read:</strong> ${this._wordsRead}</p>
<p><strong>Reading time:</strong> ${Math.floor(this._totalReadingTime / 60)}:${(this._totalReadingTime % 60).toString().padStart(2, '0')}</p>
<button class="nav-btn primary" onclick="location.reload()">Read Again</button>
</div>
`;
document.querySelector('.reading-area').innerHTML = completionMessage;
}
_showError(message) {
if (this._config.container) {
this._config.container.innerHTML = `
<div class="story-error" style="text-align: center; padding: 40px;">
<div style="font-size: 3em; margin-bottom: 20px;">❌</div>
<h3>Story Reader Error</h3>
<p>${message}</p>
<button class="nav-btn primary" onclick="history.back()">Go Back</button>
</div>
`;
}
}
_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;