// === 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; // Content extraction this.vocabulary = this.extractVocabulary(this.content); this.sentences = this.extractSentences(this.content); this.init(); } init() { if ((!this.vocabulary || this.vocabulary.length === 0) && (!this.sentences || this.sentences.length === 0)) { console.error('No content available for Adventure Reader'); this.showInitError(); return; } this.createGameInterface(); this.initializePlayer(); this.setupEventListeners(); this.generateGameObjects(); this.generateDecorations(); this.startGameLoop(); } showInitError() { this.container.innerHTML = `

❌ Error loading

This content doesn't contain texts compatible with Adventure Reader.

`; } extractVocabulary(content) { let vocabulary = []; if (content.rawContent && content.rawContent.vocabulary) { if (typeof content.rawContent.vocabulary === 'object' && !Array.isArray(content.rawContent.vocabulary)) { vocabulary = Object.entries(content.rawContent.vocabulary).map(([english, translation]) => ({ english: english, translation: translation })); } else if (Array.isArray(content.rawContent.vocabulary)) { vocabulary = content.rawContent.vocabulary; } } return vocabulary.filter(item => item && item.english && item.translation); } extractSentences(content) { let sentences = []; if (content.rawContent) { if (content.rawContent.sentences && Array.isArray(content.rawContent.sentences)) { sentences = content.rawContent.sentences; } else if (content.rawContent.texts && Array.isArray(content.rawContent.texts)) { // Extract sentences from texts content.rawContent.texts.forEach(text => { const textSentences = text.content.split(/[.!?]+/).filter(s => s.trim().length > 10); textSentences.forEach(sentence => { sentences.push({ english: sentence.trim() + '.', translation: sentence.trim() + '.' // Fallback }); }); }); } } return sentences.filter(item => item && item.english); } createGameInterface() { this.container.innerHTML = `
🏆 0
🏺 0
⚔️ 0
Start your adventure!
🧙‍♂️
Click 🏺 pots for vocabulary • Click 👹 enemies for sentences
`; } 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'); wordEl.textContent = vocab.english; translationEl.textContent = vocab.translation; popup.style.display = 'block'; popup.classList.add('show'); 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'); content.innerHTML = `

${sentence.english}

${sentence.translation ? `

${sentence.translation}

` : ''}
`; modal.style.display = 'flex'; modal.classList.add('show'); } 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() { console.log('⚔️ Adventure Reader: Starting'); document.getElementById('progress-text').textContent = 'Click objects to begin your adventure!'; } restart() { console.log('🔄 Adventure Reader: Restarting'); 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(); } destroy() { this.container.innerHTML = ''; } } // Module registration window.GameModules = window.GameModules || {}; window.GameModules.AdventureReader = AdventureReaderGame;