Class_generator/js/games/adventure-reader.js
StillHammer 24362165ab Implement dynamic percentage compatibility system across all games
Major architectural update to replace fixed 50%/100% scoring with true dynamic percentages based on content volume:

• Replace old interpolation system with Math.min(100, (count/optimal)*100) formula
• Add embedded compatibility methods to all 14 game modules with static requirements
• Remove compatibility cache system for real-time calculation
• Fix content loading to pass complete modules with vocabulary (not just metadata)
• Clean up duplicate syntax errors in adventure-reader and grammar-discovery
• Update navigation.js module mapping to match actual exported class names

Examples of new dynamic scoring:
- 15 words / 20 optimal = 75% (was 87.5% with old interpolation)
- 5 words / 10 minimum = 50% (was 25% with old linear system)
- 30 words / 20 optimal = 100% (unchanged)

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

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

1481 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// === 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.navigateTo('games')" class="back-btn">← Back to Games</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 = '';
}
// === COMPATIBILITY SYSTEM ===
static getCompatibilityRequirements() {
return {
minimum: {
stories: 1,
vocabulary: 15
},
optimal: {
stories: 2,
vocabulary: 30
},
name: "Adventure Reader",
description: "Interactive RPG-style reading with vocabulary learning and TTS support"
};
}
static checkContentCompatibility(content) {
const requirements = AdventureReaderGame.getCompatibilityRequirements();
// Extract stories and vocabulary using same method as instance
const stories = AdventureReaderGame.extractStoriesStatic(content);
const vocabulary = AdventureReaderGame.extractVocabularyStatic(content);
const storyCount = stories.length;
const vocabCount = vocabulary.length;
// Calculate score based on both stories and vocabulary
// Dynamic percentage based on optimal volumes (1→2 stories, 15→30 vocab)
// Stories: 0=0%, 1=50%, 2=100%
// Vocabulary: 0=0%, 15=50%, 30=100%
const storyScore = Math.min(100, (storyCount / requirements.optimal.stories) * 100);
const vocabScore = Math.min(100, (vocabCount / requirements.optimal.vocabulary) * 100);
// Combined score (weighted average: 70% stories, 30% vocabulary)
const finalScore = (storyScore * 0.7) + (vocabScore * 0.3);
const recommendations = [];
if (storyCount < requirements.optimal.stories) {
recommendations.push(`Add ${requirements.optimal.stories - storyCount} more stories for optimal experience`);
}
if (vocabCount < requirements.optimal.vocabulary) {
recommendations.push(`Add ${requirements.optimal.vocabulary - vocabCount} more vocabulary words for enhanced learning`);
}
return {
score: Math.round(finalScore),
details: {
stories: {
found: storyCount,
minimum: requirements.minimum.stories,
optimal: requirements.optimal.stories,
status: storyCount >= requirements.minimum.stories ? 'sufficient' : 'insufficient'
},
vocabulary: {
found: vocabCount,
minimum: requirements.minimum.vocabulary,
optimal: requirements.optimal.vocabulary,
status: vocabCount >= requirements.minimum.vocabulary ? 'sufficient' : 'insufficient'
}
},
recommendations: recommendations
};
}
static extractStoriesStatic(content) {
let stories = [];
// Priority 1: Use raw module content
if (content.rawContent) {
// Extract from story object
if (content.rawContent.story && content.rawContent.story.chapters) {
stories.push(content.rawContent.story);
}
// Extract from additionalStories array
if (content.rawContent.additionalStories && Array.isArray(content.rawContent.additionalStories)) {
stories.push(...content.rawContent.additionalStories);
}
}
// Priority 2: Direct content properties
if (content.story && content.story.chapters) {
stories.push(content.story);
}
if (content.additionalStories && Array.isArray(content.additionalStories)) {
stories.push(...content.additionalStories);
}
// Filter valid stories
stories = stories.filter(story =>
story &&
typeof story === 'object' &&
story.chapters &&
Array.isArray(story.chapters) &&
story.chapters.length > 0
);
return stories;
}
static extractVocabularyStatic(content) {
let vocabulary = [];
// Priority 1: Use raw module content (simple format)
if (content.rawContent) {
return AdventureReaderGame.extractVocabularyFromRawStatic(content.rawContent);
}
// Priority 2: Ultra-modular format (vocabulary object) - ONLY format supported
if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) {
vocabulary = Object.entries(content.vocabulary).map(([word, data]) => {
// Support ultra-modular format ONLY
if (typeof data === 'object' && data.user_language) {
return {
word: word,
translation: data.user_language.split('')[0],
fullTranslation: data.user_language,
type: data.type || 'general',
audio: data.audio,
image: data.image,
examples: data.examples,
pronunciation: data.pronunciation,
category: data.type || 'general'
};
}
// Legacy fallback - simple string (temporary, will be removed)
else if (typeof data === 'string') {
return {
word: word,
translation: data.split('')[0],
fullTranslation: data,
type: 'general',
category: 'general'
};
}
return null;
}).filter(Boolean);
}
return AdventureReaderGame.finalizeVocabularyStatic(vocabulary);
}
static extractVocabularyFromRawStatic(rawContent) {
let vocabulary = [];
// Ultra-modular format (vocabulary object) - ONLY format supported
if (rawContent.vocabulary && typeof rawContent.vocabulary === 'object' && !Array.isArray(rawContent.vocabulary)) {
vocabulary = Object.entries(rawContent.vocabulary).map(([word, data]) => {
// Support ultra-modular format ONLY
if (typeof data === 'object' && data.user_language) {
return {
word: word,
translation: data.user_language.split('')[0],
fullTranslation: data.user_language,
type: data.type || 'general',
audio: data.audio,
image: data.image,
examples: data.examples,
pronunciation: data.pronunciation,
category: data.type || 'general'
};
}
// Legacy fallback - simple string (temporary, will be removed)
else if (typeof data === 'string') {
return {
word: word,
translation: data.split('')[0],
fullTranslation: data,
type: 'general',
category: 'general'
};
}
return null;
}).filter(Boolean);
}
return AdventureReaderGame.finalizeVocabularyStatic(vocabulary);
}
static finalizeVocabularyStatic(vocabulary) {
// Validation and cleanup for ultra-modular format
vocabulary = vocabulary.filter(word =>
word &&
typeof word.word === 'string' &&
typeof word.translation === 'string' &&
word.word.trim() !== '' &&
word.translation.trim() !== ''
);
return vocabulary;
}
}
// Module registration
window.GameModules = window.GameModules || {};
window.GameModules.AdventureReader = AdventureReaderGame;