- Fix WebSocket server to properly broadcast logs to all connected clients - Integrate professional logging system with real-time WebSocket interface - Add network status indicator with DigitalOcean Spaces connectivity - Implement AWS Signature V4 authentication for private bucket access - Add JSON content loader with backward compatibility to JS modules - Restore navigation breadcrumb system with comprehensive logging - Add multiple content formats: JSON + JS with automatic discovery - Enhance top bar with logger toggle and network status indicator - Remove deprecated temp-games module and clean up unused files 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
950 lines
34 KiB
JavaScript
950 lines
34 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;
|
|
|
|
// 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)) {
|
|
logSh('No content available for Adventure Reader', 'ERROR');
|
|
this.showInitError();
|
|
return;
|
|
}
|
|
|
|
this.createGameInterface();
|
|
this.initializePlayer();
|
|
this.setupEventListeners();
|
|
this.generateGameObjects();
|
|
this.generateDecorations();
|
|
this.startGameLoop();
|
|
}
|
|
|
|
showInitError() {
|
|
this.container.innerHTML = `
|
|
<div class="game-error">
|
|
<h3>❌ Error loading</h3>
|
|
<p>This content doesn't contain texts compatible with Adventure Reader.</p>
|
|
<button onclick="AppNavigation.goBack()" class="back-btn">← Back</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 = `
|
|
<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">
|
|
Click 🏺 pots for vocabulary • Click 👹 enemies for sentences
|
|
</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>
|
|
</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');
|
|
|
|
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 = `
|
|
<div class="sentence-content">
|
|
<p class="english-text">${sentence.english}</p>
|
|
${sentence.translation ? `<p class="translation-text">${sentence.translation}</p>` : ''}
|
|
</div>
|
|
`;
|
|
|
|
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() {
|
|
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();
|
|
}
|
|
|
|
destroy() {
|
|
this.container.innerHTML = '';
|
|
}
|
|
}
|
|
|
|
// Module registration
|
|
window.GameModules = window.GameModules || {};
|
|
window.GameModules.AdventureReader = AdventureReaderGame; |