import Module from '../core/Module.js'; import ttsService from '../services/TTSService.js'; /** * AdventureReader - Zelda-style RPG adventure with vocabulary and sentence reading * Players move around a map, click pots for vocabulary and defeat enemies for reading content */ class AdventureReader extends Module { constructor(name, dependencies, config = {}) { super(name, ['eventBus']); // Validate dependencies if (!dependencies.eventBus || !dependencies.content) { throw new Error('AdventureReader requires eventBus and content dependencies'); } this._eventBus = dependencies.eventBus; this._content = dependencies.content; this._config = { container: null, autoPlayTTS: true, ttsEnabled: true, maxPots: 8, maxEnemies: 8, ...config }; // Game state this._score = 0; this._currentSentenceIndex = 0; this._currentVocabIndex = 0; this._potsDestroyed = 0; this._enemiesDefeated = 0; this._isGamePaused = false; this._gameStartTime = null; // Game objects this._pots = []; this._enemies = []; this._player = { x: 0, y: 0 }; this._isPlayerMoving = false; this._isPlayerInvulnerable = false; this._invulnerabilityTimeout = null; // Content this._vocabulary = null; this._sentences = null; this._stories = null; this._dialogues = null; Object.seal(this); } /** * Get game metadata * @returns {Object} Game metadata */ static getMetadata() { return { name: 'Adventure Reader', description: 'Zelda-style RPG adventure with vocabulary discovery and reading quests', difficulty: 'intermediate', category: 'adventure', estimatedTime: 12, // minutes skills: ['vocabulary', 'reading', 'exploration', 'comprehension'] }; } /** * Calculate compatibility score with content * @param {Object} content - Content to check compatibility with * @returns {Object} Compatibility score and details */ static getCompatibilityScore(content) { const vocab = content?.vocabulary || {}; const dialogues = content?.dialogues || []; const stories = content?.story?.chapters || content?.texts || []; const vocabCount = Object.keys(vocab).length; const dialogueCount = dialogues.length; const storyCount = stories.length; // Count sentences from ALL possible sources (matching _extractSentences logic) let sentenceCount = 0; // From story chapters if (content?.story?.chapters) { content.story.chapters.forEach(chapter => { if (chapter.sentences) { sentenceCount += chapter.sentences.filter(s => s.original && s.translation).length; } }); } // From direct sentences array if (content?.sentences) { sentenceCount += content.sentences.length; } // From phrases (array or object format) if (content?.phrases) { if (Array.isArray(content.phrases)) { sentenceCount += content.phrases.filter(p => p.chinese && p.english).length; } else if (typeof content.phrases === 'object') { sentenceCount += Object.keys(content.phrases).length; } } // From lessons if (content?.lessons) { content.lessons.forEach(lesson => { if (lesson.sentences) { sentenceCount += lesson.sentences.filter(s => s.chinese && s.english).length; } }); } const totalContent = vocabCount + sentenceCount + storyCount + dialogueCount; if (totalContent < 5) { return { score: 0, reason: `Insufficient adventure content (${totalContent}/5 required)`, requirements: ['vocabulary', 'sentences', 'stories', 'dialogues'], minContent: 5, details: 'Adventure Reader needs vocabulary, sentences, stories, or dialogues for exploration' }; } // Calculate weighted score based on content diversity and quantity let score = 0; // Vocabulary: 0.3 points max (reach 100% at 8+ items) if (vocabCount > 0) score += Math.min(vocabCount / 8, 1) * 0.3; // Sentences: 0.4 points max (reach 100% at 8+ items) - most important for gameplay if (sentenceCount > 0) score += Math.min(sentenceCount / 8, 1) * 0.4; // Stories: 0.15 points max (reach 100% at 3+ items) if (storyCount > 0) score += Math.min(storyCount / 3, 1) * 0.15; // Dialogues: 0.15 points max (reach 100% at 3+ items) if (dialogueCount > 0) score += Math.min(dialogueCount / 3, 1) * 0.15; return { score: Math.min(score, 1), reason: `Adventure content: ${vocabCount} vocab, ${sentenceCount} sentences, ${storyCount} stories, ${dialogueCount} dialogues`, requirements: ['vocabulary', 'sentences', 'stories', 'dialogues'], optimalContent: { vocab: 8, sentences: 8, stories: 3, dialogues: 3 }, details: `Rich adventure content with ${totalContent} total elements` }; } async init() { this._validateNotDestroyed(); try { // Validate container if (!this._config.container) { throw new Error('Game container is required'); } // Extract content this._extractContent(); // Validate content if (!this._hasValidContent()) { throw new Error('No compatible adventure content found'); } // 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(); // Wait for DOM to render before initializing player requestAnimationFrame(() => { this._initializePlayer(); this._setupEventListeners(); this._updateContentInfo(); this._generateGameObjects(); this._generateDecorations(); this._startGameLoop(); }); // Start the game this._gameStartTime = Date.now(); // Emit game ready event this._eventBus.emit('game:ready', { gameId: 'adventure-reader', instanceId: this.name, vocabulary: this._vocabulary.length, sentences: this._sentences.length, stories: this._stories.length, dialogues: this._dialogues.length }, this.name); this._setInitialized(); } catch (error) { this._showError(error.message); throw error; } } async destroy() { this._validateNotDestroyed(); // Clear timeouts if (this._invulnerabilityTimeout) { clearTimeout(this._invulnerabilityTimeout); this._invulnerabilityTimeout = null; } // Cancel any ongoing TTS ttsService.cancel(); // 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: 'adventure-reader', instanceId: this.name, score: this._score, potsDestroyed: this._potsDestroyed, enemiesDefeated: this._enemiesDefeated, 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, potsDestroyed: this._potsDestroyed, enemiesDefeated: this._enemiesDefeated, totalPots: this._pots.length, totalEnemies: this._enemies.length, isComplete: this._isGameComplete(), isPaused: this._isGamePaused, duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0 }; } // Private methods _extractContent() { this._vocabulary = this._extractVocabulary(); this._sentences = this._extractSentences(); this._stories = this._extractStories(); this._dialogues = this._extractDialogues(); } _extractVocabulary() { const vocab = this._content?.vocabulary || {}; const vocabulary = []; for (const [word, data] of Object.entries(vocab)) { if (data.user_language || (typeof data === 'string')) { vocabulary.push({ original_language: word, user_language: data.user_language || data, type: data.type || 'unknown', pronunciation: data.pronunciation }); } } return vocabulary; } _extractSentences() { let sentences = []; console.log('AdventureReader: Extracting sentences from content', this._content); // Support for Dragon's Pearl structure if (this._content.story?.chapters) { this._content.story.chapters.forEach(chapter => { if (chapter.sentences) { chapter.sentences.forEach(sentence => { if (sentence.original && sentence.translation) { sentences.push({ original_language: sentence.original, user_language: sentence.translation, pronunciation: sentence.pronunciation, chapter: chapter.title || '' }); } }); } }); } // Support for modular format if (this._content.sentences) { this._content.sentences.forEach(sentence => { sentences.push({ original_language: sentence.english || sentence.original_language || sentence.target_language, user_language: sentence.chinese || sentence.french || sentence.user_language || sentence.translation, pronunciation: sentence.pronunciation || sentence.prononciation }); }); } // Support for LEDU format with phrases/lessons if (this._content.phrases) { // Check if phrases is an array or object if (Array.isArray(this._content.phrases)) { this._content.phrases.forEach(phrase => { if (phrase.chinese && phrase.english) { sentences.push({ original_language: phrase.chinese, user_language: phrase.english, pronunciation: phrase.pinyin }); } }); } else if (typeof this._content.phrases === 'object') { // Handle object format (key-value pairs) Object.entries(this._content.phrases).forEach(([phraseText, phraseData]) => { const translation = typeof phraseData === 'object' ? phraseData.user_language : phraseData; const pronunciation = typeof phraseData === 'object' ? phraseData.pronunciation : undefined; if (phraseText && translation) { sentences.push({ original_language: phraseText, user_language: translation, pronunciation: pronunciation }); } }); } } // Support for lessons with sentences if (this._content.lessons) { this._content.lessons.forEach(lesson => { if (lesson.sentences) { lesson.sentences.forEach(sentence => { if (sentence.chinese && sentence.english) { sentences.push({ original_language: sentence.chinese, user_language: sentence.english, pronunciation: sentence.pinyin }); } }); } }); } console.log('AdventureReader: Extracted sentences:', sentences.length); return sentences.filter(s => s.original_language && s.user_language); } _extractStories() { let stories = []; // Support for Dragon's Pearl structure if (this._content.story?.chapters) { stories.push({ title: this._content.story.title || this._content.name || "Adventure Story", chapters: this._content.story.chapters }); } // Support for modular texts if (this._content.texts) { stories = stories.concat(this._content.texts.filter(text => text.original_language && text.user_language )); } return stories; } _extractDialogues() { let dialogues = []; if (this._content.dialogues) { dialogues = this._content.dialogues.filter(dialogue => dialogue.conversation && dialogue.conversation.length > 0 ); } return dialogues; } _hasValidContent() { const hasVocab = this._vocabulary.length > 0; const hasSentences = this._sentences.length > 0; const hasStories = this._stories.length > 0; const hasDialogues = this._dialogues.length > 0; return hasVocab || hasSentences || hasStories || hasDialogues; } _injectCSS() { const cssId = `adventure-reader-styles-${this.name}`; if (document.getElementById(cssId)) return; const style = document.createElement('style'); style.id = cssId; style.textContent = ` .adventure-reader-wrapper { height: 75vh; display: flex; flex-direction: column; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%); overflow: hidden; } .adventure-hud { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); color: white; border-bottom: 3px solid rgba(255, 255, 255, 0.1); z-index: 100; } .hud-section { display: flex; gap: 20px; align-items: center; } .stat-item { display: flex; align-items: center; gap: 8px; background: rgba(255, 255, 255, 0.1); padding: 8px 12px; border-radius: 20px; font-weight: 500; } .stat-icon { font-size: 0.72rem; /* 1.2 / 1.66 */ } .progress-info { background: rgba(255, 255, 255, 0.1); padding: 8px 15px; border-radius: 15px; font-size: 0.54rem; /* 0.9 / 1.66 */ } .game-map { flex: 1; position: relative; background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%); overflow: hidden; cursor: crosshair; } .player { position: absolute; font-size: 1.51rem; /* 2.5 / 1.66 */ z-index: 50; transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1); filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.3)); user-select: none; } .pot, .enemy { position: absolute; font-size: 1.2rem; /* 2 / 1.66 */ cursor: pointer; z-index: 30; transition: all 0.3s ease; filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.3)); user-select: none; } .pot:hover, .enemy:hover { transform: scale(1.1); filter: drop-shadow(2px 2px 6px rgba(0,0,0,0.5)); } .pot.destroyed, .enemy.defeated { pointer-events: none; transition: all 0.5s ease; } .decoration { position: absolute; z-index: 10; pointer-events: none; user-select: none; opacity: 0.8; } .decoration.tree { filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.2)); } .decoration.grass { opacity: 0.6; } .decoration.rock { filter: drop-shadow(1px 1px 3px rgba(0,0,0,0.3)); } .adventure-controls { padding: 15px 20px; background: rgba(0, 0, 0, 0.8); color: white; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px; } .instructions { font-size: 0.54rem; /* 0.9 / 1.66 */ opacity: 0.9; } .content-summary { font-size: 0.51rem; /* 0.85 / 1.66 */ background: rgba(255, 255, 255, 0.1); padding: 8px 12px; border-radius: 8px; } .control-btn { padding: 8px 15px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.54rem; /* 0.9 / 1.66 */ font-weight: 500; transition: all 0.3s ease; } .control-btn.primary { background: #3b82f6; color: white; } .control-btn.primary:hover { background: #2563eb; transform: translateY(-2px); } .control-btn.secondary { background: #10b981; color: white; } .control-btn.secondary:hover { background: #059669; transform: translateY(-2px); } .reading-modal, .vocab-popup { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.8); display: flex; align-items: center; justify-content: center; z-index: 1000; opacity: 0; visibility: hidden; transition: all 0.3s ease; } .reading-modal.show, .vocab-popup.show { opacity: 1; visibility: visible; } .modal-content { background: white; border-radius: 15px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); transform: translateY(20px); transition: transform 0.3s ease; } .reading-modal.show .modal-content { transform: translateY(0); } .modal-header { padding: 20px 25px 15px; border-bottom: 2px solid #e5e7eb; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border-radius: 15px 15px 0 0; } .modal-header h3 { margin: 0; font-size: 0.78rem; /* 1.3 / 1.66 */ } .modal-body { padding: 25px; } .sentence-content { text-align: center; } .sentence-content.dialogue-content { background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); padding: 20px; border-radius: 12px; margin-bottom: 15px; } .speaker-info, .story-title, .emotion-info { font-weight: 600; margin-bottom: 10px; color: #374151; } .text-content { margin: 20px 0; } .original-text { font-size: 0.84rem; /* 1.4 / 1.66 */ font-weight: 600; color: #1f2937; margin-bottom: 15px; line-height: 1.4; } .translation-text { font-size: 0.66rem; /* 1.1 / 1.66 */ color: #6b7280; margin-bottom: 10px; line-height: 1.3; } .pronunciation-text { font-size: 0.60rem; /* 1.0 / 1.66 */ color: #7c3aed; font-style: italic; } .modal-footer { padding: 15px 25px 25px; text-align: center; } .popup-content { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px 25px; border-radius: 15px; text-align: center; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); transform: scale(0.9); transition: transform 0.3s ease; } .vocab-popup.show .popup-content { transform: scale(1); } .vocab-word { font-size: 1.2rem; /* 2.0 / 1.66 */ font-weight: bold; margin-bottom: 10px; } .vocab-translation { font-size: 0.78rem; /* 1.3 / 1.66 */ margin-bottom: 10px; opacity: 0.9; } .vocab-pronunciation { font-size: 0.60rem; /* 1.0 / 1.66 */ opacity: 0.8; font-style: italic; } .game-error { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; text-align: center; padding: 40px; background: linear-gradient(135deg, #f87171 0%, #ef4444 100%); color: white; } .game-error h3 { font-size: 2rem; margin-bottom: 20px; } .game-error ul { text-align: left; background: rgba(255, 255, 255, 0.1); padding: 20px; border-radius: 10px; margin: 20px 0; } .back-btn { padding: 12px 25px; background: white; color: #ef4444; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.3s ease; } .back-btn:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(255, 255, 255, 0.3); } /* Animations */ @keyframes protectionFloat { 0% { transform: translate(-50%, -50%) scale(0.8); opacity: 0; } 20% { transform: translate(-50%, -50%) scale(1.2); opacity: 1; } 80% { transform: translate(-50%, -50%) scale(1); opacity: 1; } 100% { transform: translate(-50%, -50%) scale(0.8); opacity: 0; } } @keyframes damageFloat { 0% { transform: translate(-50%, -50%) scale(1); opacity: 1; } 50% { transform: translate(-50%, -80%) scale(1.2); opacity: 1; } 100% { transform: translate(-50%, -120%) scale(0.8); opacity: 0; } } @media (max-width: 768px) { .adventure-hud { flex-direction: column; gap: 15px; padding: 12px 15px; } .hud-section { gap: 15px; } .stat-item { padding: 6px 10px; font-size: 0.9rem; } .player { font-size: 2rem; } .pot, .enemy { font-size: 1.8rem; } .adventure-controls { flex-direction: column; gap: 10px; padding: 12px 15px; } .modal-content { width: 95%; } .modal-body { padding: 20px 15px; } } /* Victory Popup Styles */ .victory-popup { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.8); display: flex; align-items: center; justify-content: center; z-index: 10000; animation: fadeIn 0.3s ease-out; } .victory-content { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 20px; padding: 40px; text-align: center; color: white; max-width: 500px; width: 90%; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); animation: slideUp 0.4s ease-out; } .victory-header { margin-bottom: 30px; } .victory-icon { font-size: 4rem; margin-bottom: 15px; animation: bounce 0.6s ease-out; } .victory-title { font-size: 2rem; font-weight: bold; margin: 0 0 10px 0; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); } .new-best-badge { background: linear-gradient(45deg, #f093fb 0%, #f5576c 100%); color: white; padding: 8px 20px; border-radius: 25px; font-size: 0.9rem; font-weight: bold; display: inline-block; margin-top: 10px; animation: glow 1s ease-in-out infinite alternate; } .victory-scores { display: flex; justify-content: space-around; margin: 30px 0; gap: 20px; } .score-display { background: rgba(255, 255, 255, 0.1); border-radius: 15px; padding: 20px; flex: 1; backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.2); } .score-label { font-size: 0.9rem; opacity: 0.9; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 1px; } .score-value { font-size: 2rem; font-weight: bold; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); } .victory-stats { background: rgba(255, 255, 255, 0.1); border-radius: 15px; padding: 20px; margin: 30px 0; backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.2); } .stat-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .stat-row:last-child { border-bottom: none; } .stat-name { font-size: 0.95rem; opacity: 0.9; } .stat-value { font-weight: bold; font-size: 1rem; } .victory-buttons { display: flex; flex-direction: column; gap: 12px; margin-top: 30px; } .victory-btn { padding: 15px 30px; border: none; border-radius: 25px; font-size: 1rem; font-weight: bold; cursor: pointer; transition: all 0.3s ease; text-transform: uppercase; letter-spacing: 1px; } .victory-btn.primary { background: linear-gradient(45deg, #4facfe 0%, #00f2fe 100%); color: white; box-shadow: 0 8px 25px rgba(79, 172, 254, 0.3); } .victory-btn.primary:hover { transform: translateY(-2px); box-shadow: 0 12px 35px rgba(79, 172, 254, 0.4); } .victory-btn.secondary { background: linear-gradient(45deg, #a8edea 0%, #fed6e3 100%); color: #333; box-shadow: 0 8px 25px rgba(168, 237, 234, 0.3); } .victory-btn.secondary:hover { transform: translateY(-2px); box-shadow: 0 12px 35px rgba(168, 237, 234, 0.4); } .victory-btn.tertiary { background: rgba(255, 255, 255, 0.2); color: white; border: 2px solid rgba(255, 255, 255, 0.3); backdrop-filter: blur(10px); } .victory-btn.tertiary:hover { background: rgba(255, 255, 255, 0.3); transform: translateY(-2px); } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideUp { from { opacity: 0; transform: translateY(30px) scale(0.9); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes bounce { 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } 40% { transform: translateY(-10px); } 60% { transform: translateY(-5px); } } @keyframes glow { from { box-shadow: 0 0 20px rgba(245, 87, 108, 0.5); } to { box-shadow: 0 0 30px rgba(245, 87, 108, 0.8); } } @media (max-width: 768px) { .victory-content { padding: 30px 20px; width: 95%; } .victory-scores { flex-direction: column; gap: 15px; } .victory-icon { font-size: 3rem; } .victory-title { font-size: 1.5rem; } .victory-buttons { gap: 10px; } .victory-btn { padding: 12px 25px; font-size: 0.9rem; } } `; document.head.appendChild(style); } _removeCSS() { const cssId = `adventure-reader-styles-${this.name}`; const existingStyle = document.getElementById(cssId); if (existingStyle) { existingStyle.remove(); } } _createGameInterface() { this._config.container.innerHTML = `
🏆 0
🏺 0
⚔️ 0
Start your adventure!
🧙‍♂️
Click 🏺 pots for vocabulary • Click 👹 enemies for sentences
`; } _initializePlayer() { const gameMap = document.getElementById('game-map'); if (!gameMap) { console.error('AdventureReader: game-map element not found for player initialization'); return; } const mapRect = gameMap.getBoundingClientRect(); this._player.x = mapRect.width / 2 - 20; this._player.y = mapRect.height / 2 - 20; const playerElement = document.getElementById('player'); if (!playerElement) { console.error('AdventureReader: player element not found for positioning'); return; } playerElement.style.left = this._player.x + 'px'; playerElement.style.top = this._player.y + 'px'; } _setupEventListeners() { // Control buttons document.getElementById('restart-btn').addEventListener('click', () => this._restart()); // Exit button const exitButton = document.getElementById('exit-adventure'); if (exitButton) { exitButton.addEventListener('click', () => { this._eventBus.emit('game:exit-request', { instanceId: this.name }, this.name); }); } // Map click handler const gameMap = document.getElementById('game-map'); gameMap.addEventListener('click', (e) => this._handleMapClick(e)); // Window resize handler window.addEventListener('resize', () => { setTimeout(() => { if (!this._isDestroyed) { this._initializePlayer(); } }, 100); }); } _updateContentInfo() { const contentInfoEl = document.getElementById('content-info'); if (!contentInfoEl) return; const contentTypes = []; if (this._stories.length > 0) { contentTypes.push(`📚 ${this._stories.length} stories`); } if (this._dialogues.length > 0) { contentTypes.push(`💬 ${this._dialogues.length} dialogues`); } if (this._vocabulary.length > 0) { contentTypes.push(`📝 ${this._vocabulary.length} words`); } if (this._sentences.length > 0) { contentTypes.push(`📖 ${this._sentences.length} sentences`); } if (contentTypes.length > 0) { contentInfoEl.innerHTML = `
Adventure Content: ${contentTypes.join(' • ')}
`; } } _generateGameObjects() { const gameMap = document.getElementById('game-map'); // Clear existing objects gameMap.querySelectorAll('.pot, .enemy').forEach(el => el.remove()); this._pots = []; this._enemies = []; // Generate pots (for vocabulary) const numPots = Math.min(this._config.maxPots, this._vocabulary.length); for (let i = 0; i < numPots; i++) { const pot = this._createPot(); this._pots.push(pot); gameMap.appendChild(pot.element); } // Generate enemies (for sentences) const numEnemies = Math.min(this._config.maxEnemies, this._sentences.length); for (let i = 0; i < numEnemies; i++) { const enemy = this._createEnemy(); this._enemies.push(enemy); gameMap.appendChild(enemy.element); } this._updateHUD(); } _createPot() { const pot = document.createElement('div'); pot.className = 'pot'; pot.innerHTML = '🏺'; const position = this._getRandomPosition(); pot.style.left = position.x + 'px'; pot.style.top = position.y + 'px'; return { element: pot, x: position.x, y: position.y, destroyed: false }; } _createEnemy() { const enemy = document.createElement('div'); enemy.className = 'enemy'; enemy.innerHTML = '👹'; const position = this._getRandomPosition(true); enemy.style.left = position.x + 'px'; enemy.style.top = position.y + 'px'; const patterns = ['patrol', 'chase', 'wander', 'circle']; const pattern = patterns[Math.floor(Math.random() * patterns.length)]; return { element: enemy, x: position.x, y: position.y, defeated: false, moveDirection: Math.random() * Math.PI * 2, speed: 1.2 + Math.random() * 1.2, // 2x faster (was 0.6 + 0.6) pattern: pattern, patrolStartX: position.x, patrolStartY: position.y, patrolDistance: 80 + Math.random() * 60, circleCenter: { x: position.x, y: position.y }, circleRadius: 60 + Math.random() * 40, circleAngle: Math.random() * Math.PI * 2, changeDirectionTimer: 0, dashCooldown: 0, isDashing: false, dashDuration: 0 }; } _getRandomPosition(forceAwayFromPlayer = false) { const gameMap = document.getElementById('game-map'); const mapRect = gameMap.getBoundingClientRect(); const mapWidth = mapRect.width; const mapHeight = mapRect.height; const margin = 40; let x, y; let tooClose; const minDistance = forceAwayFromPlayer ? 150 : 80; do { x = margin + Math.random() * (mapWidth - margin * 2); y = margin + Math.random() * (mapHeight - margin * 2); const distFromPlayer = Math.sqrt( Math.pow(x - this._player.x, 2) + Math.pow(y - this._player.y, 2) ); tooClose = distFromPlayer < minDistance; } while (tooClose); return { x, y }; } _generateDecorations() { const gameMap = document.getElementById('game-map'); const mapRect = gameMap.getBoundingClientRect(); const mapWidth = mapRect.width; const mapHeight = mapRect.height; // Remove existing decorations gameMap.querySelectorAll('.decoration').forEach(el => el.remove()); // Generate trees const numTrees = 4 + Math.floor(Math.random() * 4); for (let i = 0; i < numTrees; i++) { const tree = document.createElement('div'); tree.className = 'decoration tree'; tree.innerHTML = Math.random() < 0.5 ? '🌳' : '🌲'; const position = this._getDecorationPosition(mapWidth, mapHeight, 60); tree.style.left = position.x + 'px'; tree.style.top = position.y + 'px'; tree.style.fontSize = ((25 + Math.random() * 15) / 1.66) + 'px'; // Reduced by 1.66 gameMap.appendChild(tree); } // Generate grass patches const numGrass = 15 + Math.floor(Math.random() * 10); for (let i = 0; i < numGrass; i++) { const grass = document.createElement('div'); grass.className = 'decoration grass'; const grassTypes = ['🌿', '🌱', '🍀', '🌾']; grass.innerHTML = grassTypes[Math.floor(Math.random() * grassTypes.length)]; const position = this._getDecorationPosition(mapWidth, mapHeight, 30); grass.style.left = position.x + 'px'; grass.style.top = position.y + 'px'; grass.style.fontSize = ((15 + Math.random() * 8) / 1.66) + 'px'; // Reduced by 1.66 gameMap.appendChild(grass); } // Generate rocks const numRocks = 3 + Math.floor(Math.random() * 3); for (let i = 0; i < numRocks; i++) { const rock = document.createElement('div'); rock.className = 'decoration rock'; rock.innerHTML = Math.random() < 0.5 ? '🪨' : '⛰️'; const position = this._getDecorationPosition(mapWidth, mapHeight, 40); rock.style.left = position.x + 'px'; rock.style.top = position.y + 'px'; rock.style.fontSize = ((20 + Math.random() * 10) / 1.66) + 'px'; // Reduced by 1.66 gameMap.appendChild(rock); } } _getDecorationPosition(mapWidth, mapHeight, keepAwayDistance) { const margin = 20; let x, y; let attempts = 0; let validPosition = false; do { x = margin + Math.random() * (mapWidth - margin * 2); y = margin + Math.random() * (mapHeight - margin * 2); const distFromPlayer = Math.sqrt( Math.pow(x - this._player.x, 2) + Math.pow(y - this._player.y, 2) ); let tooClose = distFromPlayer < keepAwayDistance; if (!tooClose) { this._pots.forEach(pot => { const dist = Math.sqrt(Math.pow(x - pot.x, 2) + Math.pow(y - pot.y, 2)); if (dist < keepAwayDistance) tooClose = true; }); } if (!tooClose) { this._enemies.forEach(enemy => { const dist = Math.sqrt(Math.pow(x - enemy.x, 2) + Math.pow(y - enemy.y, 2)); if (dist < keepAwayDistance) tooClose = true; }); } validPosition = !tooClose; attempts++; } while (!validPosition && attempts < 50); return { x, y }; } _startGameLoop() { const animate = () => { if (this._isDestroyed) return; // Stop animation if game is destroyed if (!this._isGamePaused) { this._moveEnemies(); } requestAnimationFrame(animate); }; animate(); } _moveEnemies() { const gameMap = document.getElementById('game-map'); if (!gameMap) return; // Exit if game map doesn't exist const mapRect = gameMap.getBoundingClientRect(); const mapWidth = mapRect.width; const mapHeight = mapRect.height; this._enemies.forEach(enemy => { if (enemy.defeated) return; this._applyMovementPattern(enemy, mapWidth, mapHeight); // Bounce off walls if (enemy.x < 10 || enemy.x > mapWidth - 50) { enemy.moveDirection = Math.PI - enemy.moveDirection; enemy.x = Math.max(10, Math.min(mapWidth - 50, enemy.x)); } if (enemy.y < 10 || enemy.y > mapHeight - 50) { enemy.moveDirection = -enemy.moveDirection; enemy.y = Math.max(10, Math.min(mapHeight - 50, enemy.y)); } enemy.element.style.left = enemy.x + 'px'; enemy.element.style.top = enemy.y + 'px'; // Add red shadow effect during dash if (enemy.isDashing) { enemy.element.style.filter = 'drop-shadow(0 0 10px rgba(255, 0, 0, 0.8)) drop-shadow(0 0 20px rgba(255, 0, 0, 0.5))'; enemy.element.style.transform = 'scale(1.1)'; // Slightly larger during dash } else { enemy.element.style.filter = ''; enemy.element.style.transform = ''; } this._checkPlayerEnemyCollision(enemy); }); } _applyMovementPattern(enemy, mapWidth, mapHeight) { enemy.changeDirectionTimer++; switch (enemy.pattern) { case 'patrol': const distanceFromStart = Math.sqrt( Math.pow(enemy.x - enemy.patrolStartX, 2) + Math.pow(enemy.y - enemy.patrolStartY, 2) ); if (distanceFromStart > enemy.patrolDistance) { const angleToStart = Math.atan2( enemy.patrolStartY - enemy.y, enemy.patrolStartX - enemy.x ); enemy.moveDirection = angleToStart; } if (enemy.changeDirectionTimer > 120) { enemy.moveDirection += (Math.random() - 0.5) * Math.PI * 0.5; enemy.changeDirectionTimer = 0; } enemy.x += Math.cos(enemy.moveDirection) * enemy.speed; enemy.y += Math.sin(enemy.moveDirection) * enemy.speed; break; case 'chase': const angleToPlayer = Math.atan2( this._player.y - enemy.y, this._player.x - enemy.x ); const distanceToPlayer = Math.sqrt( Math.pow(this._player.x - enemy.x, 2) + Math.pow(this._player.y - enemy.y, 2) ); // Decrease dash cooldown if (enemy.dashCooldown > 0) { enemy.dashCooldown--; } // Trigger dash if close enough and cooldown is ready if (!enemy.isDashing && enemy.dashCooldown <= 0 && distanceToPlayer < 300 && distanceToPlayer > 80) { enemy.isDashing = true; enemy.dashDuration = 30; // 30 frames of dash enemy.dashCooldown = 120; // 120 frames cooldown (~2 seconds) // Choose perpendicular direction (90° or -90° randomly) const perpendicularOffset = Math.random() < 0.5 ? Math.PI / 2 : -Math.PI / 2; enemy.dashAngle = angleToPlayer + perpendicularOffset; } // Handle dashing (perpendicular to player direction - evasive maneuver) if (enemy.isDashing) { // Use stored dash angle (perpendicular to player at dash start) enemy.moveDirection = enemy.dashAngle; enemy.x += Math.cos(enemy.dashAngle) * (enemy.speed * 3.5); // 3.5x speed during dash enemy.y += Math.sin(enemy.dashAngle) * (enemy.speed * 3.5); enemy.dashDuration--; if (enemy.dashDuration <= 0) { enemy.isDashing = false; } } else { // Normal chase movement enemy.moveDirection = angleToPlayer + (Math.random() - 0.5) * 0.3; enemy.x += Math.cos(enemy.moveDirection) * (enemy.speed * 0.8); enemy.y += Math.sin(enemy.moveDirection) * (enemy.speed * 0.8); } break; case 'wander': if (enemy.changeDirectionTimer > 60 + Math.random() * 60) { enemy.moveDirection += (Math.random() - 0.5) * Math.PI; enemy.changeDirectionTimer = 0; } enemy.x += Math.cos(enemy.moveDirection) * enemy.speed; enemy.y += Math.sin(enemy.moveDirection) * enemy.speed; break; case 'circle': enemy.circleAngle += 0.03 + (enemy.speed * 0.01); enemy.x = enemy.circleCenter.x + Math.cos(enemy.circleAngle) * enemy.circleRadius; enemy.y = enemy.circleCenter.y + Math.sin(enemy.circleAngle) * enemy.circleRadius; break; } } _handleMapClick(e) { if (this._isGamePaused || this._isPlayerMoving) return; const rect = e.currentTarget.getBoundingClientRect(); const clickX = e.clientX - rect.left; const clickY = e.clientY - rect.top; // Check pot clicks let targetFound = false; this._pots.forEach(pot => { if (!pot.destroyed && this._isNearPosition(clickX, clickY, pot)) { this._movePlayerToTarget(pot, 'pot'); targetFound = true; } }); // Check enemy clicks if (!targetFound) { this._enemies.forEach(enemy => { if (!enemy.defeated && this._isNearPosition(clickX, clickY, enemy)) { this._movePlayerToTarget(enemy, 'enemy'); targetFound = true; } }); } // Move to empty area if (!targetFound) { this._movePlayerToPosition(clickX, clickY); } } _isNearPosition(clickX, clickY, object) { const distance = Math.sqrt( Math.pow(clickX - (object.x + 20), 2) + Math.pow(clickY - (object.y + 20), 2) ); return distance < 60; } _movePlayerToTarget(target, type) { this._isPlayerMoving = true; const playerElement = document.getElementById('player'); if (type === 'enemy') { this._grantAttackInvulnerability(); } const targetX = target.x; const targetY = target.y; this._player.x = targetX; this._player.y = targetY; playerElement.style.left = targetX + 'px'; playerElement.style.top = targetY + 'px'; playerElement.style.transform = 'scale(1.1)'; setTimeout(() => { playerElement.style.transform = 'scale(1)'; this._isPlayerMoving = false; if (type === 'pot') { this._destroyPot(target); } else if (type === 'enemy') { this._defeatEnemy(target); } }, 800); } _movePlayerToPosition(targetX, targetY) { this._isPlayerMoving = true; const playerElement = document.getElementById('player'); this._player.x = targetX - 20; this._player.y = targetY - 20; const gameMap = document.getElementById('game-map'); const mapRect = gameMap.getBoundingClientRect(); const margin = 20; this._player.x = Math.max(margin, Math.min(mapRect.width - 60, this._player.x)); this._player.y = Math.max(margin, Math.min(mapRect.height - 60, this._player.y)); playerElement.style.left = this._player.x + 'px'; playerElement.style.top = this._player.y + 'px'; playerElement.style.transform = 'scale(1.1)'; setTimeout(() => { playerElement.style.transform = 'scale(1)'; this._isPlayerMoving = false; }, 800); } _destroyPot(pot) { pot.destroyed = true; pot.element.classList.add('destroyed'); pot.element.innerHTML = '💥'; setTimeout(() => { pot.element.style.opacity = '0.3'; pot.element.innerHTML = '💨'; }, 200); this._potsDestroyed++; this._score += 10; if (this._currentVocabIndex < this._vocabulary.length) { this._showVocabPopup(this._vocabulary[this._currentVocabIndex]); this._currentVocabIndex++; } this._updateHUD(); this._checkGameComplete(); } _defeatEnemy(enemy) { // CRITICAL: Mark enemy as defeated FIRST to prevent any further damage enemy.defeated = true; enemy.element.classList.add('defeated'); enemy.element.innerHTML = '☠️'; setTimeout(() => { enemy.element.style.opacity = '0.3'; }, 300); this._enemiesDefeated++; this._score += 25; // Clear any existing invulnerability timeout to prevent conflicts // The reading modal will provide protection via pause, // and post-reading invulnerability will be granted after modal closes if (this._invulnerabilityTimeout) { clearTimeout(this._invulnerabilityTimeout); this._invulnerabilityTimeout = null; } // Keep player invulnerable until modal shows this._isPlayerInvulnerable = true; if (this._currentSentenceIndex < this._sentences.length) { this._showReadingModal(this._sentences[this._currentSentenceIndex]); this._currentSentenceIndex++; } this._updateHUD(); } _showVocabPopup(vocab) { const popup = document.getElementById('vocab-popup'); const wordEl = document.getElementById('vocab-word'); const translationEl = document.getElementById('vocab-translation'); const pronunciationEl = document.getElementById('vocab-pronunciation'); wordEl.textContent = vocab.original_language; translationEl.textContent = vocab.user_language; if (vocab.pronunciation) { pronunciationEl.textContent = `🗣️ ${vocab.pronunciation}`; pronunciationEl.style.display = 'block'; } else { pronunciationEl.style.display = 'none'; } popup.style.display = 'flex'; popup.classList.add('show'); if (this._config.autoPlayTTS && this._config.ttsEnabled) { setTimeout(() => { this._speakText(vocab.original_language, { rate: 0.8 }); }, 400); } setTimeout(() => { popup.classList.remove('show'); setTimeout(() => { popup.style.display = 'none'; }, 300); }, 2000); } _showReadingModal(sentence) { this._isGamePaused = true; const modal = document.getElementById('reading-modal'); const content = document.getElementById('reading-content'); const modalTitle = document.getElementById('modal-title'); let modalTitleText = 'Adventure Text'; if (sentence.speaker) { modalTitleText = `💬 ${sentence.speaker} says...`; } else if (sentence.title) { modalTitleText = `📚 ${sentence.title}`; } modalTitle.textContent = modalTitleText; const speakerInfo = sentence.speaker ? `
🎭 ${sentence.speaker}
` : ''; const titleInfo = sentence.title && !sentence.speaker ? `
📖 ${sentence.title}
` : ''; content.innerHTML = `
${titleInfo} ${speakerInfo}

${sentence.original_language}

${sentence.user_language}

${sentence.pronunciation ? `

🗣️ ${sentence.pronunciation}

` : ''}
`; modal.style.display = 'flex'; modal.classList.add('show'); // Calculate reading time based on text length and TTS const textLength = sentence.original_language.length; // Average reading speed: ~5 chars/second at 0.8 rate // Add base delay of 800ms (600ms initial + 200ms buffer) const ttsDelay = 600; // Initial delay before TTS starts const readingTime = (textLength / 5) * 1000; // Characters to milliseconds const bufferTime = 500; // Extra buffer after TTS ends const totalTime = ttsDelay + readingTime + bufferTime; if (this._config.autoPlayTTS && this._config.ttsEnabled) { setTimeout(() => { this._speakText(sentence.original_language, { rate: 0.8 }); }, ttsDelay); } // Auto-close modal after TTS completes setTimeout(() => { this._closeModal(); }, totalTime); } _closeModal() { const modal = document.getElementById('reading-modal'); modal.classList.remove('show'); setTimeout(() => { modal.style.display = 'none'; this._isGamePaused = false; // Grant 1 second invulnerability after closing reading modal this._grantPostReadingInvulnerability(); }, 300); this._checkGameComplete(); } _checkGameComplete() { const allPotsDestroyed = this._pots.every(pot => pot.destroyed); const allEnemiesDefeated = this._enemies.every(enemy => enemy.defeated); if (allPotsDestroyed && allEnemiesDefeated) { setTimeout(() => { this._gameComplete(); }, 1000); } } _gameComplete() { this._score += 100; this._updateHUD(); document.getElementById('progress-text').textContent = '🏆 Adventure Complete!'; // Calculate duration const duration = Math.round((Date.now() - this._gameStartTime) / 1000); // Handle localStorage best score const currentScore = this._score; const bestScore = parseInt(localStorage.getItem('adventure-reader-best-score') || '0'); const isNewBest = currentScore > bestScore; if (isNewBest) { localStorage.setItem('adventure-reader-best-score', currentScore.toString()); } setTimeout(() => { this._showVictoryPopup({ gameTitle: 'Adventure Reader', currentScore, bestScore: isNewBest ? currentScore : bestScore, isNewBest, stats: { 'Pots Destroyed': this._potsDestroyed, 'Enemies Defeated': this._enemiesDefeated, 'Duration': `${duration}s`, 'Bonus Score': '100' } }); }, 2000); } _updateHUD() { document.getElementById('score-display').textContent = this._score; document.getElementById('pots-counter').textContent = this._potsDestroyed; document.getElementById('enemies-counter').textContent = this._enemiesDefeated; const totalObjects = this._pots.length + this._enemies.length; const destroyedObjects = this._potsDestroyed + this._enemiesDefeated; document.getElementById('progress-text').textContent = `Progress: ${destroyedObjects}/${totalObjects} objects`; } _checkPlayerEnemyCollision(enemy) { // CRITICAL SAFETY CHECKS - Skip collision in ANY of these conditions: // 1. Game is paused (reading modal open) // 2. Player is invulnerable // 3. Enemy is defeated // 4. Player is currently moving (attacking) if (this._isGamePaused || this._isPlayerInvulnerable || enemy.defeated || this._isPlayerMoving) { return; } const distance = Math.sqrt( Math.pow(this._player.x - enemy.x, 2) + Math.pow(this._player.y - enemy.y, 2) ); if (distance < 35) { this._takeDamage(); } } _takeDamage() { if (this._isPlayerInvulnerable) return; this._score = Math.max(0, this._score - 20); this._updateHUD(); if (this._invulnerabilityTimeout) { clearTimeout(this._invulnerabilityTimeout); } this._isPlayerInvulnerable = true; const playerElement = document.getElementById('player'); // Blinking animation (visual only) let blinkCount = 0; const blinkInterval = setInterval(() => { playerElement.style.opacity = playerElement.style.opacity === '0.3' ? '1' : '0.3'; blinkCount++; if (blinkCount >= 8) { clearInterval(blinkInterval); playerElement.style.opacity = '1'; playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))'; } }, 250); // Actual invulnerability duration (independent of blink animation) this._invulnerabilityTimeout = setTimeout(() => { this._isPlayerInvulnerable = false; }, 2000); // 2 seconds of actual invulnerability this._showDamagePopup(); } _grantAttackInvulnerability() { this._isPlayerInvulnerable = true; const playerElement = document.getElementById('player'); if (this._invulnerabilityTimeout) { clearTimeout(this._invulnerabilityTimeout); } playerElement.style.filter = 'drop-shadow(0 0 15px gold) brightness(1.4)'; this._invulnerabilityTimeout = setTimeout(() => { playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))'; this._isPlayerInvulnerable = false; }, 2000); this._showInvulnerabilityPopup(); } _grantPostReadingInvulnerability() { this._isPlayerInvulnerable = true; const playerElement = document.getElementById('player'); if (this._invulnerabilityTimeout) { clearTimeout(this._invulnerabilityTimeout); } // Brief blue glow to indicate post-reading protection playerElement.style.filter = 'drop-shadow(0 0 10px rgba(100, 150, 255, 0.8)) brightness(1.2)'; this._invulnerabilityTimeout = setTimeout(() => { playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))'; this._isPlayerInvulnerable = false; }, 1000); // 1 second protection } _refreshAttackInvulnerability() { if (this._invulnerabilityTimeout) { clearTimeout(this._invulnerabilityTimeout); } const playerElement = document.getElementById('player'); this._isPlayerInvulnerable = true; this._invulnerabilityTimeout = setTimeout(() => { playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))'; this._isPlayerInvulnerable = false; }, 2000); } _showInvulnerabilityPopup() { const popup = document.createElement('div'); popup.className = 'invulnerability-popup'; popup.innerHTML = 'Protected!'; popup.style.cssText = ` position: fixed; left: 50%; top: 25%; transform: translate(-50%, -50%); color: #FFD700; font-size: 1.5rem; font-weight: bold; z-index: 999; pointer-events: none; animation: protectionFloat 2s ease-out forwards; `; document.body.appendChild(popup); setTimeout(() => { popup.remove(); }, 2000); } _showDamagePopup() { const damagePopup = document.createElement('div'); damagePopup.className = 'damage-popup'; damagePopup.innerHTML = '-20'; damagePopup.style.cssText = ` position: fixed; left: 50%; top: 30%; transform: translate(-50%, -50%); color: #EF4444; font-size: 2rem; font-weight: bold; z-index: 999; pointer-events: none; animation: damageFloat 1.5s ease-out forwards; `; document.body.appendChild(damagePopup); setTimeout(() => { damagePopup.remove(); }, 1500); } _restart() { this._score = 0; this._currentSentenceIndex = 0; this._currentVocabIndex = 0; this._potsDestroyed = 0; this._enemiesDefeated = 0; this._isGamePaused = false; this._isPlayerMoving = false; this._isPlayerInvulnerable = false; if (this._invulnerabilityTimeout) { clearTimeout(this._invulnerabilityTimeout); this._invulnerabilityTimeout = null; } this._generateGameObjects(); this._initializePlayer(); this._generateDecorations(); document.getElementById('progress-text').textContent = 'Click objects to begin your adventure!'; } _isGameComplete() { const allPotsDestroyed = this._pots.every(pot => pot.destroyed); const allEnemiesDefeated = this._enemies.every(enemy => enemy.defeated); return allPotsDestroyed && allEnemiesDefeated; } _speakText(text, options = {}) { if (!text || !this._config.ttsEnabled) return; const language = this._getContentLanguage(); const rate = options.rate || 0.8; ttsService.speak(text, language, { rate, volume: 1.0 }); } _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'; } _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); // Emit completion event after showing popup this._eventBus.emit('game:completed', { gameId: 'adventure-reader', instanceId: this.name, score: currentScore, potsDestroyed: stats['Pots Destroyed'], enemiesDefeated: stats['Enemies Defeated'], duration: parseInt(stats['Duration'].replace('s', '')) * 1000 }, this.name); } _showError(message) { if (this._config.container) { this._config.container.innerHTML = `

❌ Adventure Reader Error

${message}

This content module needs adventure-compatible content:

`; } } _handlePause() { this._isGamePaused = true; this._eventBus.emit('game:paused', { instanceId: this.name }, this.name); } _handleResume() { this._isGamePaused = false; this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name); } } export default AdventureReader;