Class_generator/js/games/adventure-reader.js
StillHammer e50dd624a0 Add comprehensive game suite with TTS integration and content modules
🎮 NEW GAMES:
- River Run: Endless runner with word collection and guaranteed target spawning
- Grammar Discovery: Focused grammar learning with 8-step rotation cycles
- Letter Discovery: Letter-first alphabet learning with progression system
- Enhanced Word Discovery: Shuffled practice mode with image support and auto-TTS

📚 NEW CONTENT MODULES:
- WTA1B-1: English letters U,V,T with pets vocabulary and Chinese translation
- SBS-1: English "To Be" introduction with comprehensive grammar lessons
- French Beginner Story: Story content for English speakers learning French

🔊 TTS ENHANCEMENTS:
- Story Reader: Multi-story support with TTS for sentences and individual words
- Adventure Reader: Auto-TTS for vocabulary popups and sentence modals
- Word Discovery: Immediate TTS playback with difficulty-based speed control
- Integrated SettingsManager compatibility across all games

🎯 GAMEPLAY IMPROVEMENTS:
- River Run: Target word guaranteed within 10 spawns, progressive spacing
- Story Reader: Story selector dropdown with independent progress tracking
- Adventure Reader: Fixed HUD overlap issue with proper viewport spacing
- Enhanced punctuation preservation in Story Reader word parsing

 SYSTEM UPDATES:
- Content scanner integration for all new modules
- Game loader mappings for seamless content discovery
- Simplified content titles: "WTA1B-1" and "SBS-1" for easy identification
- Comprehensive test files for isolated game development

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-20 09:15:01 +08:00

1287 lines
50 KiB
JavaScript

// === MODULE ADVENTURE READER (ZELDA-STYLE) ===
class AdventureReaderGame {
constructor(options) {
this.container = options.container;
this.content = options.content;
this.onScoreUpdate = options.onScoreUpdate || (() => {});
this.onGameEnd = options.onGameEnd || (() => {});
// Game state
this.score = 0;
this.currentSentenceIndex = 0;
this.currentVocabIndex = 0;
this.potsDestroyed = 0;
this.enemiesDefeated = 0;
this.isGamePaused = false;
// Game objects
this.pots = [];
this.enemies = [];
this.player = { x: 0, y: 0 }; // Will be set when map is created
this.isPlayerMoving = false;
this.isPlayerInvulnerable = false;
this.invulnerabilityTimeout = null;
// TTS settings
this.autoPlayTTS = true;
this.ttsEnabled = true;
// Expose content globally for SettingsManager TTS language detection
window.currentGameContent = this.content;
// Content extraction
this.vocabulary = this.extractVocabulary(this.content);
this.sentences = this.extractSentences(this.content);
this.stories = this.extractStories(this.content);
this.dialogues = this.extractDialogues(this.content);
this.init();
}
init() {
const hasVocabulary = this.vocabulary && this.vocabulary.length > 0;
const hasSentences = this.sentences && this.sentences.length > 0;
const hasStories = this.stories && this.stories.length > 0;
const hasDialogues = this.dialogues && this.dialogues.length > 0;
if (!hasVocabulary && !hasSentences && !hasStories && !hasDialogues) {
logSh('No compatible content found for Adventure Reader', 'ERROR');
this.showInitError();
return;
}
logSh(`Adventure Reader initialized with: ${this.vocabulary.length} vocab, ${this.sentences.length} sentences, ${this.stories.length} stories, ${this.dialogues.length} dialogues`, 'INFO');
this.createGameInterface();
this.initializePlayer();
this.setupEventListeners();
this.updateContentInfo();
this.generateGameObjects();
this.generateDecorations();
this.startGameLoop();
}
showInitError() {
this.container.innerHTML = `
<div class="game-error">
<h3>❌ No Adventure Content Found</h3>
<p>This content module needs adventure-compatible content:</p>
<ul style="text-align: left; margin: 1rem 0;">
<li><strong>📚 texts:</strong> Stories with original_language and user_language</li>
<li><strong>💬 dialogues:</strong> Character conversations with speakers</li>
<li><strong>📝 vocabulary:</strong> Words with translations for discovery</li>
<li><strong>📖 sentences:</strong> Individual phrases for reading practice</li>
</ul>
<p>Add adventure content to enable this game mode.</p>
<button onclick="AppNavigation.goBack()" class="back-btn">← Back</button>
</div>
`;
}
extractVocabulary(content) {
let vocabulary = [];
// Support pour Dragon's Pearl vocabulary structure
if (content.vocabulary && typeof content.vocabulary === 'object') {
vocabulary = Object.entries(content.vocabulary).map(([original_language, vocabData]) => {
if (typeof vocabData === 'string') {
// Simple format: "word": "translation"
return {
original_language: original_language,
user_language: vocabData,
type: 'unknown'
};
} else if (typeof vocabData === 'object') {
// Rich format: "word": { user_language: "translation", type: "noun", ... }
return {
original_language: original_language,
user_language: vocabData.user_language || vocabData.translation || 'No translation',
type: vocabData.type || 'unknown',
pronunciation: vocabData.pronunciation,
difficulty: vocabData.difficulty
};
}
return null;
}).filter(item => item !== null);
}
// Ultra-modular format support
else if (content.rawContent && content.rawContent.vocabulary) {
if (typeof content.rawContent.vocabulary === 'object' && !Array.isArray(content.rawContent.vocabulary)) {
vocabulary = Object.entries(content.rawContent.vocabulary).map(([original_language, vocabData]) => {
if (typeof vocabData === 'string') {
// Simple format: "word": "translation"
return {
original_language: original_language,
user_language: vocabData,
type: 'unknown'
};
} else if (typeof vocabData === 'object') {
// Rich format: "word": { user_language: "translation", type: "noun", ... }
return {
original_language: original_language,
user_language: vocabData.user_language || vocabData.translation || 'No translation',
type: vocabData.type || 'unknown',
pronunciation: vocabData.pronunciation,
difficulty: vocabData.difficulty
};
}
return null;
}).filter(item => item !== null);
}
}
return vocabulary.filter(item => item && item.original_language && item.user_language);
}
extractSentences(content) {
let sentences = [];
logSh('🐉 Adventure Reader: Extracting sentences from content...', 'DEBUG');
logSh(`🐉 Content structure: story=${!!content.story}, rawContent=${!!content.rawContent}`, 'DEBUG');
// Support pour Dragon's Pearl structure: content.story.chapters[].sentences[]
if (content.story && content.story.chapters && Array.isArray(content.story.chapters)) {
logSh(`🐉 Dragon's Pearl structure detected, ${content.story.chapters.length} chapters`, 'DEBUG');
content.story.chapters.forEach((chapter, chapterIndex) => {
logSh(`🐉 Processing chapter ${chapterIndex}: ${chapter.title}`, 'DEBUG');
if (chapter.sentences && Array.isArray(chapter.sentences)) {
logSh(`🐉 Chapter ${chapterIndex} has ${chapter.sentences.length} sentences`, 'DEBUG');
chapter.sentences.forEach((sentence, sentenceIndex) => {
if (sentence.original && sentence.translation) {
// Construire la prononciation depuis les mots si pas disponible directement
let pronunciation = sentence.pronunciation || '';
if (!pronunciation && sentence.words && Array.isArray(sentence.words)) {
pronunciation = sentence.words
.map(wordObj => wordObj.pronunciation || '')
.filter(p => p.trim().length > 0)
.join(' ');
}
sentences.push({
original_language: sentence.original,
user_language: sentence.translation,
pronunciation: pronunciation,
chapter: chapter.title || '',
id: sentence.id || sentences.length
});
} else {
logSh(`🐉 WARNING: Skipping sentence ${sentenceIndex} in chapter ${chapterIndex} - missing original/translation`, 'WARN');
}
});
} else {
logSh(`🐉 WARNING: Chapter ${chapterIndex} has no sentences array`, 'WARN');
}
});
logSh(`🐉 Dragon's Pearl extraction complete: ${sentences.length} sentences extracted`, 'INFO');
}
// Support pour la structure ultra-modulaire existante
else if (content.rawContent) {
// Ultra-modular format: Extract from texts (stories/adventures)
if (content.rawContent.texts && Array.isArray(content.rawContent.texts)) {
content.rawContent.texts.forEach(text => {
if (text.original_language && text.user_language) {
// Split long texts into sentences for adventure reading
const originalSentences = text.original_language.split(/[.!?]+/).filter(s => s.trim().length > 10);
const userSentences = text.user_language.split(/[.!?]+/).filter(s => s.trim().length > 10);
// Match sentences by index
originalSentences.forEach((originalSentence, index) => {
const userSentence = userSentences[index] || originalSentence;
sentences.push({
original_language: originalSentence.trim() + '.',
user_language: userSentence.trim() + '.',
title: text.title || 'Adventure Text',
id: text.id || `text_${index}`
});
});
}
});
}
// Ultra-modular format: Extract from dialogues
if (content.rawContent.dialogues && Array.isArray(content.rawContent.dialogues)) {
content.rawContent.dialogues.forEach(dialogue => {
if (dialogue.conversation && Array.isArray(dialogue.conversation)) {
dialogue.conversation.forEach(line => {
if (line.original_language && line.user_language) {
sentences.push({
original_language: line.original_language,
user_language: line.user_language,
speaker: line.speaker || 'Character',
title: dialogue.title || 'Dialogue',
id: line.id || dialogue.id
});
}
});
}
});
}
// Legacy format support for backward compatibility
if (content.rawContent.sentences && Array.isArray(content.rawContent.sentences)) {
content.rawContent.sentences.forEach(sentence => {
sentences.push({
original_language: sentence.english || sentence.original_language || '',
user_language: sentence.chinese || sentence.french || sentence.user_language || sentence.translation || '',
pronunciation: sentence.prononciation || sentence.pronunciation
});
});
}
}
return sentences.filter(item => item && item.original_language && item.user_language);
}
extractStories(content) {
let stories = [];
// Support pour Dragon's Pearl structure
if (content.story && content.story.chapters && Array.isArray(content.story.chapters)) {
// Créer une histoire depuis les chapitres de Dragon's Pearl
stories.push({
title: content.story.title || content.name || "Dragon's Pearl",
original_language: content.story.chapters.map(ch =>
ch.sentences.map(s => s.original).join(' ')
).join('\n\n'),
user_language: content.story.chapters.map(ch =>
ch.sentences.map(s => s.translation).join(' ')
).join('\n\n'),
chapters: content.story.chapters.map(chapter => ({
title: chapter.title,
sentences: chapter.sentences
}))
});
}
// Support pour la structure ultra-modulaire existante
else if (content.rawContent && content.rawContent.texts && Array.isArray(content.rawContent.texts)) {
stories = content.rawContent.texts.filter(text =>
text.original_language && text.user_language && text.title
).map(text => ({
id: text.id || `story_${Date.now()}_${Math.random()}`,
title: text.title,
original_language: text.original_language,
user_language: text.user_language,
description: text.description || '',
difficulty: text.difficulty || 'medium'
}));
}
return stories;
}
extractDialogues(content) {
let dialogues = [];
if (content.rawContent && content.rawContent.dialogues && Array.isArray(content.rawContent.dialogues)) {
dialogues = content.rawContent.dialogues.filter(dialogue =>
dialogue.conversation && Array.isArray(dialogue.conversation) && dialogue.conversation.length > 0
).map(dialogue => ({
id: dialogue.id || `dialogue_${Date.now()}_${Math.random()}`,
title: dialogue.title || 'Character Dialogue',
conversation: dialogue.conversation.filter(line =>
line.original_language && line.user_language
).map(line => ({
id: line.id || `line_${Date.now()}_${Math.random()}`,
speaker: line.speaker || 'Character',
original_language: line.original_language,
user_language: line.user_language,
emotion: line.emotion || 'neutral'
}))
}));
}
return dialogues.filter(dialogue => dialogue.conversation.length > 0);
}
updateContentInfo() {
const contentInfoEl = document.getElementById('content-info');
if (!contentInfoEl) return;
const contentTypes = [];
if (this.stories && this.stories.length > 0) {
contentTypes.push(`📚 ${this.stories.length} stories`);
}
if (this.dialogues && this.dialogues.length > 0) {
contentTypes.push(`💬 ${this.dialogues.length} dialogues`);
}
if (this.vocabulary && this.vocabulary.length > 0) {
contentTypes.push(`📝 ${this.vocabulary.length} words`);
}
if (this.sentences && this.sentences.length > 0) {
contentTypes.push(`📖 ${this.sentences.length} sentences`);
}
if (contentTypes.length > 0) {
contentInfoEl.innerHTML = `
<div class="content-summary">
<strong>Adventure Content:</strong> ${contentTypes.join(' • ')}
</div>
`;
}
}
createGameInterface() {
this.container.innerHTML = `
<div class="adventure-reader-wrapper">
<!-- Game HUD -->
<div class="game-hud">
<div class="hud-left">
<div class="stat-item">
<span class="stat-icon">🏆</span>
<span id="score-display">0</span>
</div>
<div class="stat-item">
<span class="stat-icon">🏺</span>
<span id="pots-counter">0</span>
</div>
<div class="stat-item">
<span class="stat-icon">⚔️</span>
<span id="enemies-counter">0</span>
</div>
</div>
<div class="hud-right">
<div class="progress-info">
<span id="progress-text">Start your adventure!</span>
</div>
</div>
</div>
<!-- Game Map -->
<div class="game-map" id="game-map">
<!-- Player -->
<div class="player" id="player">🧙‍♂️</div>
<!-- Game objects will be generated here -->
</div>
<!-- Game Controls -->
<div class="game-controls">
<div class="instructions" id="game-instructions">
Click 🏺 pots for vocabulary • Click 👹 enemies for sentences
</div>
<div class="content-info" id="content-info">
<!-- Content type info will be populated here -->
</div>
<button class="control-btn secondary" id="restart-btn">🔄 Restart Adventure</button>
</div>
<!-- Reading Modal -->
<div class="reading-modal" id="reading-modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-title">Enemy Defeated!</h3>
</div>
<div class="modal-body">
<div class="reading-content" id="reading-content">
<!-- Sentence content -->
</div>
</div>
<div class="modal-footer">
<button class="control-btn primary" id="continue-btn">Continue Adventure →</button>
</div>
</div>
</div>
<!-- Vocab Popup -->
<div class="vocab-popup" id="vocab-popup" style="display: none;">
<div class="popup-content">
<div class="vocab-word" id="vocab-word"></div>
<div class="vocab-translation" id="vocab-translation"></div>
<div class="vocab-pronunciation" id="vocab-pronunciation"></div>
</div>
</div>
</div>
`;
}
initializePlayer() {
// Set player initial position to center of map
const gameMap = document.getElementById('game-map');
const mapRect = gameMap.getBoundingClientRect();
this.player.x = mapRect.width / 2 - 20; // -20 for half player width
this.player.y = mapRect.height / 2 - 20; // -20 for half player height
const playerElement = document.getElementById('player');
playerElement.style.left = this.player.x + 'px';
playerElement.style.top = this.player.y + 'px';
}
setupEventListeners() {
document.getElementById('restart-btn').addEventListener('click', () => this.restart());
document.getElementById('continue-btn').addEventListener('click', () => this.closeModal());
// Map click handler
const gameMap = document.getElementById('game-map');
gameMap.addEventListener('click', (e) => this.handleMapClick(e));
// Window resize handler
window.addEventListener('resize', () => {
setTimeout(() => this.initializePlayer(), 100);
});
}
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(8, 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) - spawn across entire viewport
const numEnemies = Math.min(8, 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); // Force away from player
enemy.style.left = position.x + 'px';
enemy.style.top = position.y + 'px';
// Random movement pattern for each enemy
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: 0.6 + Math.random() * 0.6, // Reduced speed
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
};
}
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);
// Check distance from player
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 };
}
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 (only if no pot was clicked)
if (!targetFound) {
this.enemies.forEach(enemy => {
if (!enemy.defeated && this.isNearPosition(clickX, clickY, enemy)) {
this.movePlayerToTarget(enemy, 'enemy');
targetFound = true;
}
});
}
// If no target found, 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; // Larger clickable area
}
movePlayerToTarget(target, type) {
this.isPlayerMoving = true;
const playerElement = document.getElementById('player');
// Grant invulnerability IMMEDIATELY when attacking an enemy
if (type === 'enemy') {
this.grantAttackInvulnerability();
}
// Calculate target position (near the object)
const targetX = target.x;
const targetY = target.y;
// Update player position
this.player.x = targetX;
this.player.y = targetY;
// Animate player movement
playerElement.style.left = targetX + 'px';
playerElement.style.top = targetY + 'px';
// Add walking animation
playerElement.style.transform = 'scale(1.1)';
// Wait for movement animation to complete, then interact
setTimeout(() => {
playerElement.style.transform = 'scale(1)';
this.isPlayerMoving = false;
if (type === 'pot') {
this.destroyPot(target);
} else if (type === 'enemy') {
this.defeatEnemy(target);
}
}, 800); // Match CSS transition duration
}
movePlayerToPosition(targetX, targetY) {
this.isPlayerMoving = true;
const playerElement = document.getElementById('player');
// Update player position
this.player.x = targetX - 20; // Center the player on click point
this.player.y = targetY - 20;
// Keep player within bounds
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));
// Animate player movement
playerElement.style.left = this.player.x + 'px';
playerElement.style.top = this.player.y + 'px';
// Add walking animation
playerElement.style.transform = 'scale(1.1)';
// Reset animation after movement
setTimeout(() => {
playerElement.style.transform = 'scale(1)';
this.isPlayerMoving = false;
}, 800);
}
destroyPot(pot) {
pot.destroyed = true;
pot.element.classList.add('destroyed');
// Animation
pot.element.innerHTML = '💥';
setTimeout(() => {
pot.element.style.opacity = '0.3';
pot.element.innerHTML = '💨';
}, 200);
this.potsDestroyed++;
this.score += 10;
// Show vocabulary
if (this.currentVocabIndex < this.vocabulary.length) {
this.showVocabPopup(this.vocabulary[this.currentVocabIndex]);
this.currentVocabIndex++;
}
this.updateHUD();
this.checkGameComplete();
}
defeatEnemy(enemy) {
enemy.defeated = true;
enemy.element.classList.add('defeated');
// Animation
enemy.element.innerHTML = '☠️';
setTimeout(() => {
enemy.element.style.opacity = '0.3';
}, 300);
this.enemiesDefeated++;
this.score += 25;
// Invulnerability is already granted at start of movement
// Just refresh the timer to ensure full 2 seconds from now
this.refreshAttackInvulnerability();
// Show sentence (pause game)
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;
// Afficher la prononciation si disponible
if (vocab.pronunciation) {
pronunciationEl.textContent = `🗣️ ${vocab.pronunciation}`;
pronunciationEl.style.display = 'block';
} else {
pronunciationEl.style.display = 'none';
}
popup.style.display = 'block';
popup.classList.add('show');
// Auto-play TTS for vocabulary
if (this.autoPlayTTS && this.ttsEnabled) {
setTimeout(() => {
this.speakText(vocab.original_language, { rate: 0.8 });
}, 400); // Small delay to let popup appear
}
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');
// Determine content type and set appropriate modal title
let modalTitleText = 'Adventure Text';
if (sentence.speaker) {
modalTitleText = `💬 ${sentence.speaker} says...`;
} else if (sentence.title) {
modalTitleText = `📚 ${sentence.title}`;
}
modalTitle.textContent = modalTitleText;
// Create content with appropriate styling based on type
const speakerInfo = sentence.speaker ? `<div class="speaker-info">🎭 ${sentence.speaker}</div>` : '';
const titleInfo = sentence.title && !sentence.speaker ? `<div class="story-title">📖 ${sentence.title}</div>` : '';
const emotionInfo = sentence.emotion && sentence.emotion !== 'neutral' ? `<div class="emotion-info">😊 ${sentence.emotion}</div>` : '';
content.innerHTML = `
<div class="sentence-content ${sentence.speaker ? 'dialogue-content' : 'story-content'}">
${titleInfo}
${speakerInfo}
${emotionInfo}
<div class="text-content">
<p class="original-text">${sentence.original_language}</p>
<p class="translation-text">${sentence.user_language}</p>
${sentence.pronunciation ? `<p class="pronunciation-text">🗣️ ${sentence.pronunciation}</p>` : ''}
</div>
</div>
`;
modal.style.display = 'flex';
modal.classList.add('show');
// Auto-play TTS for sentence
if (this.autoPlayTTS && this.ttsEnabled) {
setTimeout(() => {
this.speakText(sentence.original_language, { rate: 0.8 });
}, 600); // Longer delay for modal animation
}
}
closeModal() {
const modal = document.getElementById('reading-modal');
modal.classList.remove('show');
setTimeout(() => {
modal.style.display = 'none';
this.isGamePaused = false;
}, 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() {
// Bonus for completion
this.score += 100;
this.updateHUD();
document.getElementById('progress-text').textContent = '🏆 Adventure Complete!';
setTimeout(() => {
this.onGameEnd(this.score);
}, 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`;
this.onScoreUpdate(this.score);
}
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 (fewer, larger)
const numTrees = 4 + Math.floor(Math.random() * 4); // 4-7 trees
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); // Keep away from objects
tree.style.left = position.x + 'px';
tree.style.top = position.y + 'px';
tree.style.fontSize = (25 + Math.random() * 15) + 'px'; // Random size
gameMap.appendChild(tree);
}
// Generate grass patches (many, small)
const numGrass = 15 + Math.floor(Math.random() * 10); // 15-24 grass
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); // Smaller keepaway
grass.style.left = position.x + 'px';
grass.style.top = position.y + 'px';
grass.style.fontSize = (15 + Math.random() * 8) + 'px'; // Smaller size
gameMap.appendChild(grass);
}
// Generate rocks (medium amount)
const numRocks = 3 + Math.floor(Math.random() * 3); // 3-5 rocks
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) + 'px';
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);
// Check distance from player
const distFromPlayer = Math.sqrt(
Math.pow(x - this.player.x, 2) + Math.pow(y - this.player.y, 2)
);
// Check distance from pots and enemies
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.isGamePaused) {
this.moveEnemies();
}
requestAnimationFrame(animate);
};
animate();
}
moveEnemies() {
const gameMap = document.getElementById('game-map');
const mapRect = gameMap.getBoundingClientRect();
const mapWidth = mapRect.width;
const mapHeight = mapRect.height;
this.enemies.forEach(enemy => {
if (enemy.defeated) return;
// Apply movement pattern
this.applyMovementPattern(enemy, mapWidth, mapHeight);
// Bounce off walls (using dynamic map size)
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';
// Check collision with player
this.checkPlayerEnemyCollision(enemy);
});
}
applyMovementPattern(enemy, mapWidth, mapHeight) {
enemy.changeDirectionTimer++;
switch (enemy.pattern) {
case 'patrol':
// Patrol back and forth
const distanceFromStart = Math.sqrt(
Math.pow(enemy.x - enemy.patrolStartX, 2) + Math.pow(enemy.y - enemy.patrolStartY, 2)
);
if (distanceFromStart > enemy.patrolDistance) {
// Turn around and head back to start
const angleToStart = Math.atan2(
enemy.patrolStartY - enemy.y,
enemy.patrolStartX - enemy.x
);
enemy.moveDirection = angleToStart;
}
if (enemy.changeDirectionTimer > 120) { // Change direction every ~2 seconds
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':
enemy.dashCooldown--;
if (enemy.isDashing) {
// Continue dash movement with very high speed
enemy.x += Math.cos(enemy.moveDirection) * (enemy.speed * 6);
enemy.y += Math.sin(enemy.moveDirection) * (enemy.speed * 6);
enemy.dashCooldown--;
if (enemy.dashCooldown <= 0) {
enemy.isDashing = false;
enemy.dashCooldown = 120 + Math.random() * 60; // Reset cooldown
}
} else {
// Normal chase behavior
const angleToPlayer = Math.atan2(
this.player.y - enemy.y,
this.player.x - enemy.x
);
// Sometimes do a perpendicular dash
if (enemy.dashCooldown <= 0 && Math.random() < 0.3) {
enemy.isDashing = true;
enemy.dashCooldown = 50; // Much longer dash duration
// Perpendicular angle (90 degrees from player direction)
const perpAngle = angleToPlayer + (Math.random() < 0.5 ? Math.PI/2 : -Math.PI/2);
enemy.moveDirection = perpAngle;
// Start dash with visual effect
enemy.element.style.filter = 'drop-shadow(0 0 8px red)';
setTimeout(() => {
enemy.element.style.filter = 'drop-shadow(1px 1px 2px rgba(0,0,0,0.3))';
}, 300);
} else {
// Mix chasing with some randomness
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':
// Random wandering
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':
// Move in circular pattern
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;
// Occasionally change circle center
if (enemy.changeDirectionTimer > 180) {
enemy.circleCenter.x += (Math.random() - 0.5) * 100;
enemy.circleCenter.y += (Math.random() - 0.5) * 100;
// Keep circle center within bounds
enemy.circleCenter.x = Math.max(enemy.circleRadius + 20,
Math.min(mapWidth - enemy.circleRadius - 20, enemy.circleCenter.x));
enemy.circleCenter.y = Math.max(enemy.circleRadius + 20,
Math.min(mapHeight - enemy.circleRadius - 20, enemy.circleCenter.y));
enemy.changeDirectionTimer = 0;
}
break;
}
}
checkPlayerEnemyCollision(enemy) {
if (this.isPlayerInvulnerable || enemy.defeated) return;
const distance = Math.sqrt(
Math.pow(this.player.x - enemy.x, 2) + Math.pow(this.player.y - enemy.y, 2)
);
// Collision detected
if (distance < 35) {
this.takeDamage();
}
}
takeDamage() {
if (this.isPlayerInvulnerable) return;
// Apply damage
this.score = Math.max(0, this.score - 20);
this.updateHUD();
// Clear any existing invulnerability timeout
if (this.invulnerabilityTimeout) {
clearTimeout(this.invulnerabilityTimeout);
}
// Start damage invulnerability
this.isPlayerInvulnerable = true;
const playerElement = document.getElementById('player');
// Visual feedback - blinking effect
let blinkCount = 0;
const blinkInterval = setInterval(() => {
playerElement.style.opacity = playerElement.style.opacity === '0.3' ? '1' : '0.3';
blinkCount++;
if (blinkCount >= 8) { // 4 blinks in 2 seconds
clearInterval(blinkInterval);
playerElement.style.opacity = '1';
playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))';
playerElement.style.transform = 'scale(1)';
this.isPlayerInvulnerable = false;
}
}, 250);
// Show damage feedback
this.showDamagePopup();
}
grantAttackInvulnerability() {
// Always grant invulnerability after attack, even if already invulnerable
this.isPlayerInvulnerable = true;
const playerElement = document.getElementById('player');
// Clear any existing timeout
if (this.invulnerabilityTimeout) {
clearTimeout(this.invulnerabilityTimeout);
}
// Different visual effect for attack invulnerability (golden glow)
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);
// Show invulnerability feedback
this.showInvulnerabilityPopup();
}
refreshAttackInvulnerability() {
// Refresh the invulnerability timer without changing visual state
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.position = 'fixed';
popup.style.left = '50%';
popup.style.top = '25%';
popup.style.transform = 'translate(-50%, -50%)';
popup.style.color = '#FFD700';
popup.style.fontSize = '1.5rem';
popup.style.fontWeight = 'bold';
popup.style.zIndex = '999';
popup.style.pointerEvents = 'none';
popup.style.animation = 'protectionFloat 2s ease-out forwards';
document.body.appendChild(popup);
setTimeout(() => {
popup.remove();
}, 2000);
}
showDamagePopup() {
// Create damage popup
const damagePopup = document.createElement('div');
damagePopup.className = 'damage-popup';
damagePopup.innerHTML = '-20';
damagePopup.style.position = 'fixed';
damagePopup.style.left = '50%';
damagePopup.style.top = '30%';
damagePopup.style.transform = 'translate(-50%, -50%)';
damagePopup.style.color = '#EF4444';
damagePopup.style.fontSize = '2rem';
damagePopup.style.fontWeight = 'bold';
damagePopup.style.zIndex = '999';
damagePopup.style.pointerEvents = 'none';
damagePopup.style.animation = 'damageFloat 1.5s ease-out forwards';
document.body.appendChild(damagePopup);
setTimeout(() => {
damagePopup.remove();
}, 1500);
}
start() {
logSh('⚔️ Adventure Reader: Starting', 'INFO');
document.getElementById('progress-text').textContent = 'Click objects to begin your adventure!';
}
restart() {
logSh('🔄 Adventure Reader: Restarting', 'INFO');
this.reset();
this.start();
}
reset() {
this.score = 0;
this.currentSentenceIndex = 0;
this.currentVocabIndex = 0;
this.potsDestroyed = 0;
this.enemiesDefeated = 0;
this.isGamePaused = false;
this.isPlayerMoving = false;
this.isPlayerInvulnerable = false;
// Clear any existing timeout
if (this.invulnerabilityTimeout) {
clearTimeout(this.invulnerabilityTimeout);
this.invulnerabilityTimeout = null;
}
this.generateGameObjects();
this.initializePlayer();
this.generateDecorations();
}
// TTS Methods
speakText(text, options = {}) {
if (!text || !this.ttsEnabled) return;
// Use SettingsManager if available for better language support
if (window.SettingsManager && window.SettingsManager.speak) {
const ttsOptions = {
lang: this.getContentLanguage(),
rate: options.rate || 0.8,
...options
};
window.SettingsManager.speak(text, ttsOptions)
.catch(error => {
console.warn('🔊 SettingsManager TTS failed:', error);
this.fallbackTTS(text, ttsOptions);
});
} else {
this.fallbackTTS(text, options);
}
}
fallbackTTS(text, options = {}) {
if ('speechSynthesis' in window && text) {
// Cancel any ongoing speech
speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = this.getContentLanguage();
utterance.rate = options.rate || 0.8;
utterance.volume = 1.0;
speechSynthesis.speak(utterance);
}
}
getContentLanguage() {
// Get language from content or use sensible defaults
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'; // Default fallback
}
destroy() {
// Cancel any ongoing TTS
if ('speechSynthesis' in window) {
speechSynthesis.cancel();
}
this.container.innerHTML = '';
}
}
// Module registration
window.GameModules = window.GameModules || {};
window.GameModules.AdventureReader = AdventureReaderGame;