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>
1481 lines
58 KiB
JavaScript
1481 lines
58 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.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; |