Enhance game modules with visual effects and improvements
Add visual enhancements including fireball animations, improved rendering, and physics updates across multiple game modules (WizardSpellCaster, MarioEducational, WordDiscovery, WordStorm, RiverRun, GrammarDiscovery). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4714a4a1c6
commit
e4d7e838d5
@ -240,7 +240,7 @@ window.ContentModules.SBSLevel1 = {
|
||||
|
||||
story: {
|
||||
title: "To Be: Introduction - 动词Be的介绍",
|
||||
totalSentences: 50,
|
||||
totalSentences: 13,
|
||||
chapters: [
|
||||
{
|
||||
title: "Chapter 1: Vocabulary Preview - 第一章:词汇预览",
|
||||
|
||||
@ -223,10 +223,19 @@ export class PhysicsEngine {
|
||||
if (this.isColliding(mario, enemy)) {
|
||||
// Check if Mario jumped on enemy
|
||||
if (mario.velocityY > 0 && mario.y < enemy.y + enemy.height / 2) {
|
||||
// Enemy defeated
|
||||
// Bounce on enemy
|
||||
mario.velocityY = -8; // Bounce
|
||||
if (onEnemyDefeat) onEnemyDefeat(index);
|
||||
if (onAddParticles) onAddParticles(enemy.x, enemy.y, '#FFD700');
|
||||
|
||||
// Only defeat enemy if it doesn't have a helmet
|
||||
if (!enemy.hasHelmet) {
|
||||
if (onEnemyDefeat) onEnemyDefeat(index);
|
||||
if (onAddParticles) onAddParticles(enemy.x, enemy.y, '#FFD700');
|
||||
console.log(`👾 Mario defeated enemy`);
|
||||
} else {
|
||||
// Helmet enemy - just bounce, don't defeat
|
||||
if (onAddParticles) onAddParticles(enemy.x, enemy.y, '#4169E1');
|
||||
console.log(`🛡️ Mario bounced on helmet enemy (not defeated)`);
|
||||
}
|
||||
} else {
|
||||
// Mario hit by enemy
|
||||
console.log(`👾 Mario hit by enemy - restarting level`);
|
||||
@ -253,9 +262,14 @@ export class PhysicsEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if stepping on flattened plant
|
||||
// Check if stepping on flattened plant (acts as platform)
|
||||
if (plant.flattened && this.isColliding(mario, plant)) {
|
||||
mario.onGround = true;
|
||||
// Mario lands on top of flattened plant
|
||||
if (mario.velocityY > 0 && mario.y < plant.y + plant.height / 2) {
|
||||
mario.y = plant.y - mario.height;
|
||||
mario.velocityY = 0;
|
||||
mario.onGround = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -148,7 +148,7 @@ export class Renderer {
|
||||
renderEnemies(ctx, enemies) {
|
||||
enemies.forEach(enemy => {
|
||||
// Enemy body
|
||||
ctx.fillStyle = '#FF6B6B';
|
||||
ctx.fillStyle = enemy.color || '#FF6B6B';
|
||||
ctx.fillRect(enemy.x, enemy.y, enemy.width, enemy.height);
|
||||
|
||||
// Eyes
|
||||
@ -161,6 +161,15 @@ export class Renderer {
|
||||
ctx.fillRect(enemy.x + 7, enemy.y + 7, 4, 4);
|
||||
ctx.fillRect(enemy.x + enemy.width - 11, enemy.y + 7, 4, 4);
|
||||
|
||||
// Helmet (for koopa type)
|
||||
if (enemy.hasHelmet) {
|
||||
ctx.fillStyle = '#FFD700'; // Gold helmet
|
||||
ctx.fillRect(enemy.x + 2, enemy.y - 4, enemy.width - 4, 6);
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(enemy.x + 2, enemy.y - 4, enemy.width - 4, 6);
|
||||
}
|
||||
|
||||
// Border
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2;
|
||||
@ -205,7 +214,17 @@ export class Renderer {
|
||||
plants.forEach(plant => {
|
||||
if (!plant.visible) return;
|
||||
|
||||
const pipeHeight = 60;
|
||||
// If flattened, render as flat green patch (platform)
|
||||
if (plant.flattened) {
|
||||
ctx.fillStyle = '#228B22';
|
||||
ctx.fillRect(plant.x, plant.y + plant.height - 5, plant.width, 5);
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(plant.x, plant.y + plant.height - 5, plant.width, 5);
|
||||
return;
|
||||
}
|
||||
|
||||
const pipeHeight = 40; // Reduced from 60
|
||||
const pipeY = plant.y + plant.height - pipeHeight;
|
||||
|
||||
// Pipe
|
||||
@ -214,18 +233,18 @@ export class Renderer {
|
||||
|
||||
// Pipe rim
|
||||
ctx.fillStyle = '#3A9F3A';
|
||||
ctx.fillRect(plant.x - 5, pipeY, plant.width + 10, 10);
|
||||
ctx.fillRect(plant.x - 3, pipeY, plant.width + 6, 8); // Reduced from -5, +10, 10
|
||||
|
||||
// Pipe border
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(plant.x, pipeY, plant.width, pipeHeight);
|
||||
ctx.strokeRect(plant.x - 5, pipeY, plant.width + 10, 10);
|
||||
ctx.strokeRect(plant.x - 3, pipeY, plant.width + 6, 8);
|
||||
|
||||
// Plant head (if extended)
|
||||
if (plant.extended > 0) {
|
||||
const headY = pipeY - plant.extended;
|
||||
const headSize = 30;
|
||||
const headSize = 20; // Reduced from 30
|
||||
|
||||
// Head
|
||||
ctx.fillStyle = '#FF0000';
|
||||
@ -237,20 +256,20 @@ export class Renderer {
|
||||
// Spots
|
||||
ctx.fillStyle = '#FFF';
|
||||
ctx.beginPath();
|
||||
ctx.arc(plant.x + plant.width / 2 - 10, headY - 5, 6, 0, Math.PI * 2);
|
||||
ctx.arc(plant.x + plant.width / 2 + 10, headY - 5, 6, 0, Math.PI * 2);
|
||||
ctx.arc(plant.x + plant.width / 2 - 7, headY - 3, 4, 0, Math.PI * 2);
|
||||
ctx.arc(plant.x + plant.width / 2 + 7, headY - 3, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Mouth (open/close animation)
|
||||
const mouthOpen = Math.sin(Date.now() / 200) > 0;
|
||||
if (mouthOpen) {
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(plant.x + plant.width / 2 - 12, headY + 8, 24, 8);
|
||||
ctx.fillRect(plant.x + plant.width / 2 - 8, headY + 5, 16, 6);
|
||||
}
|
||||
|
||||
// Stem
|
||||
ctx.fillStyle = '#2D882D';
|
||||
ctx.fillRect(plant.x + plant.width / 2 - 5, headY, 10, plant.extended);
|
||||
ctx.fillRect(plant.x + plant.width / 2 - 3, headY, 6, plant.extended);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -27,16 +27,16 @@ export class PiranhaPlant {
|
||||
|
||||
plants.push({
|
||||
x: plantX,
|
||||
y: platform.y - 40, // Plant height above platform
|
||||
width: 30,
|
||||
height: 40,
|
||||
y: platform.y - 25, // Plant height above platform
|
||||
width: 20,
|
||||
height: 25,
|
||||
color: '#228B22', // Forest green
|
||||
lastShot: 0,
|
||||
shootCooldown: 2000 + Math.random() * 1000, // 2-3 second intervals
|
||||
type: 'piranha',
|
||||
visible: true,
|
||||
extended: 0, // For animation (how much plant extends from pipe)
|
||||
maxExtension: 40,
|
||||
maxExtension: 25,
|
||||
extending: true
|
||||
});
|
||||
|
||||
@ -57,6 +57,19 @@ export class PiranhaPlant {
|
||||
const currentTime = Date.now();
|
||||
|
||||
plants.forEach(plant => {
|
||||
// If plant is flattened, count down and make it a platform
|
||||
if (plant.flattened) {
|
||||
if (plant.flattenedTimer > 0) {
|
||||
plant.flattenedTimer--;
|
||||
} else {
|
||||
// Plant recovers after timer expires
|
||||
plant.flattened = false;
|
||||
plant.extended = 0;
|
||||
plant.extending = true;
|
||||
}
|
||||
return; // Don't animate or shoot while flattened
|
||||
}
|
||||
|
||||
// Animate plant extension/retraction
|
||||
if (plant.extending) {
|
||||
plant.extended = Math.min(plant.extended + 1, plant.maxExtension);
|
||||
@ -109,9 +122,9 @@ export class PiranhaPlant {
|
||||
if (!plant.visible) continue;
|
||||
|
||||
// Only check collision when plant is extended
|
||||
if (plant.extended > 20) {
|
||||
if (plant.extended > 15) {
|
||||
const headY = plant.y - plant.extended;
|
||||
const headRadius = 30;
|
||||
const headRadius = 20;
|
||||
|
||||
// Simple circle-rectangle collision
|
||||
const closestX = Math.max(mario.x, Math.min(plant.x + plant.width / 2, mario.x + mario.width));
|
||||
|
||||
@ -788,7 +788,7 @@ class GrammarDiscovery extends Module {
|
||||
<div class="pronunciation">${example.pronunciation || example.prononciation || ''}</div>
|
||||
<div class="explanation-text">${example.explanation || example.breakdown || ''}</div>
|
||||
<div class="tts-controls">
|
||||
<button class="tts-btn" onclick="window.currentGrammarGame._speakText('${(example.chinese || example.text || example.sentence).replace(/'/g, "\\'")}', 'zh-CN')">
|
||||
<button class="tts-btn" onclick="window.currentGrammarGame._speakText('${(example.chinese || example.text || example.sentence).replace(/'/g, "\\'")}')">
|
||||
🔊 Pronunciation
|
||||
</button>
|
||||
</div>
|
||||
@ -877,7 +877,7 @@ class GrammarDiscovery extends Module {
|
||||
<div class="pronunciation">${example.pronunciation || example.prononciation || ''}</div>
|
||||
<div class="explanation-text">${example.explanation || example.breakdown || ''}</div>
|
||||
<div class="tts-controls">
|
||||
<button class="tts-btn" onclick="window.currentGrammarGame._speakText('${(example.chinese || example.text || example.sentence).replace(/'/g, "\\'")}', 'zh-CN')">
|
||||
<button class="tts-btn" onclick="window.currentGrammarGame._speakText('${(example.chinese || example.text || example.sentence).replace(/'/g, "\\'")}')">
|
||||
🔊 Pronunciation
|
||||
</button>
|
||||
</div>
|
||||
@ -1073,15 +1073,75 @@ class GrammarDiscovery extends Module {
|
||||
this._showConceptSelector();
|
||||
}
|
||||
|
||||
_speakText(text, lang = 'zh-CN') {
|
||||
async _speakText(text, lang = null) {
|
||||
if ('speechSynthesis' in window) {
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = lang;
|
||||
utterance.rate = 0.8;
|
||||
speechSynthesis.speak(utterance);
|
||||
try {
|
||||
// Cancel any ongoing speech
|
||||
speechSynthesis.cancel();
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
|
||||
// Get target language from content, fallback to parameter or default
|
||||
const targetLanguage = this._content?.language || lang || 'zh-CN';
|
||||
utterance.lang = targetLanguage;
|
||||
utterance.rate = 0.8; // Slightly slower for clarity
|
||||
utterance.pitch = 1.0;
|
||||
utterance.volume = 0.8;
|
||||
|
||||
// Wait for voices to be loaded before selecting one
|
||||
const voices = await this._getVoices();
|
||||
const langPrefix = targetLanguage.split('-')[0];
|
||||
const matchingVoice = voices.find(voice =>
|
||||
voice.lang.startsWith(langPrefix) && voice.default
|
||||
) || voices.find(voice => voice.lang.startsWith(langPrefix));
|
||||
|
||||
if (matchingVoice) {
|
||||
utterance.voice = matchingVoice;
|
||||
console.log(`🔊 Using voice: ${matchingVoice.name} (${matchingVoice.lang}) for language: ${targetLanguage}`);
|
||||
} else {
|
||||
console.warn(`🔊 No voice found for language: ${targetLanguage}, available:`, voices.map(v => v.lang));
|
||||
}
|
||||
|
||||
speechSynthesis.speak(utterance);
|
||||
} catch (error) {
|
||||
console.warn('TTS error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available TTS voices, waiting for them to load if necessary
|
||||
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||
*/
|
||||
_getVoices() {
|
||||
return new Promise((resolve) => {
|
||||
let voices = window.speechSynthesis.getVoices();
|
||||
|
||||
// If voices are already loaded, return them immediately
|
||||
if (voices.length > 0) {
|
||||
resolve(voices);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, wait for voiceschanged event
|
||||
const voicesChangedHandler = () => {
|
||||
voices = window.speechSynthesis.getVoices();
|
||||
if (voices.length > 0) {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(voices);
|
||||
}
|
||||
};
|
||||
|
||||
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||
|
||||
// Fallback timeout in case voices never load
|
||||
setTimeout(() => {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(window.speechSynthesis.getVoices());
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
_shuffleArray(array) {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
|
||||
@ -112,6 +112,14 @@ class MarioEducational extends Module {
|
||||
// Input handlers (need to be declared before seal)
|
||||
this._handleKeyDown = null;
|
||||
this._handleKeyUp = null;
|
||||
this._handleMouseDown = null;
|
||||
this._handleMouseUp = null;
|
||||
this._handleMouseMove = null;
|
||||
|
||||
// Mouse control state
|
||||
this._mousePressed = false;
|
||||
this._mouseTarget = { x: null, y: null };
|
||||
this._mouseWorldPos = { x: null, y: null };
|
||||
|
||||
// UI elements (need to be declared before seal)
|
||||
this._uiOverlay = null;
|
||||
@ -229,7 +237,7 @@ class MarioEducational extends Module {
|
||||
this._generateAllLevels();
|
||||
|
||||
// Start first level
|
||||
this._startLevel(5); // Start at level 6 (index 5) to continue boss work
|
||||
this._startLevel(0); // Start at level 1
|
||||
|
||||
// Setup game UI
|
||||
this._setupGameUI();
|
||||
@ -260,6 +268,12 @@ class MarioEducational extends Module {
|
||||
// Remove event listeners
|
||||
document.removeEventListener('keydown', this._handleKeyDown);
|
||||
document.removeEventListener('keyup', this._handleKeyUp);
|
||||
if (this._canvas) {
|
||||
this._canvas.removeEventListener('mousedown', this._handleMouseDown);
|
||||
this._canvas.removeEventListener('mouseup', this._handleMouseUp);
|
||||
this._canvas.removeEventListener('mousemove', this._handleMouseMove);
|
||||
this._canvas.removeEventListener('mouseleave', this._handleMouseUp);
|
||||
}
|
||||
|
||||
// Clear canvas
|
||||
if (this._canvas && this._canvas.parentNode) {
|
||||
@ -419,8 +433,55 @@ class MarioEducational extends Module {
|
||||
this._keys[e.code] = false;
|
||||
};
|
||||
|
||||
// Mouse handlers for click and drag controls
|
||||
this._handleMouseDown = (e) => {
|
||||
if (this._isGameOver || this._isPaused || this._isQuestionActive) return;
|
||||
|
||||
const rect = this._canvas.getBoundingClientRect();
|
||||
const canvasX = e.clientX - rect.left;
|
||||
const canvasY = e.clientY - rect.top;
|
||||
|
||||
// Convert to world coordinates (account for camera)
|
||||
this._mouseWorldPos.x = canvasX + this._camera.x;
|
||||
this._mouseWorldPos.y = canvasY;
|
||||
this._mousePressed = true;
|
||||
|
||||
console.log(`🖱️ Mouse down at world: (${this._mouseWorldPos.x.toFixed(0)}, ${this._mouseWorldPos.y.toFixed(0)})`);
|
||||
};
|
||||
|
||||
this._handleMouseUp = (e) => {
|
||||
this._mousePressed = false;
|
||||
this._mouseTarget.x = null;
|
||||
this._mouseTarget.y = null;
|
||||
console.log(`🖱️ Mouse released`);
|
||||
};
|
||||
|
||||
this._handleMouseMove = (e) => {
|
||||
if (!this._mousePressed) return;
|
||||
if (this._isGameOver || this._isPaused || this._isQuestionActive) return;
|
||||
|
||||
const rect = this._canvas.getBoundingClientRect();
|
||||
const canvasX = e.clientX - rect.left;
|
||||
const canvasY = e.clientY - rect.top;
|
||||
|
||||
// Convert to world coordinates
|
||||
this._mouseWorldPos.x = canvasX + this._camera.x;
|
||||
this._mouseWorldPos.y = canvasY;
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', this._handleKeyDown);
|
||||
document.addEventListener('keyup', this._handleKeyUp);
|
||||
|
||||
if (this._canvas) {
|
||||
this._canvas.addEventListener('mousedown', this._handleMouseDown);
|
||||
this._canvas.addEventListener('mouseup', this._handleMouseUp);
|
||||
this._canvas.addEventListener('mousemove', this._handleMouseMove);
|
||||
// Also handle mouse leaving canvas
|
||||
this._canvas.addEventListener('mouseleave', this._handleMouseUp);
|
||||
console.log('🖱️ Mouse event listeners attached to canvas');
|
||||
} else {
|
||||
console.error('❌ Canvas not found when setting up mouse handlers!');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -541,6 +602,7 @@ class MarioEducational extends Module {
|
||||
|
||||
// Generate simple enemies (avoid walls)
|
||||
let helmetEnemyPlaced = false;
|
||||
console.log(`🎮 Level ${index + 1}: Generating ${difficulty} enemies (helmetEnemy condition: index=${index} >= 1)`);
|
||||
|
||||
for (let i = 0; i < difficulty; i++) {
|
||||
let enemyPlaced = false;
|
||||
@ -570,7 +632,7 @@ class MarioEducational extends Module {
|
||||
|
||||
if (!wouldOverlapWall && !wouldOverlapHole) {
|
||||
// Level 2+ gets exactly ONE helmet enemy per level
|
||||
const isHelmetEnemy = index >= 1 && !helmetEnemyPlaced && i === 0; // Only first enemy can be helmet
|
||||
const isHelmetEnemy = index >= 1 && !helmetEnemyPlaced;
|
||||
|
||||
if (isHelmetEnemy) {
|
||||
helmetEnemyPlaced = true;
|
||||
@ -587,7 +649,7 @@ class MarioEducational extends Module {
|
||||
hasHelmet: isHelmetEnemy
|
||||
});
|
||||
enemyPlaced = true;
|
||||
console.log(`✅ Enemy ${i} placed at x=${enemyX.toFixed(0)}, y=${enemyY.toFixed(0)} on platform`);
|
||||
console.log(`✅ Enemy ${i} placed at x=${enemyX.toFixed(0)}, y=${enemyY.toFixed(0)} on platform - Type: ${isHelmetEnemy ? '🔵 KOOPA' : '🔴 Goomba'}`);
|
||||
} else {
|
||||
const reason = wouldOverlapWall ? 'wall' : 'hole';
|
||||
console.log(`🚫 Enemy ${i} attempt ${attempts} failed: ${reason}`);
|
||||
@ -1666,7 +1728,51 @@ class MarioEducational extends Module {
|
||||
}
|
||||
|
||||
_updateMarioMovement() {
|
||||
PhysicsEngine.updateMarioMovement(this._mario, this._keys, this._config, this._isCelebrating, (sound) => soundSystem.play(sound));
|
||||
// Mouse controls take priority when mouse is pressed
|
||||
if (this._mousePressed && this._mouseWorldPos.x !== null) {
|
||||
this._handleMouseMovement();
|
||||
} else {
|
||||
// Fallback to keyboard controls
|
||||
PhysicsEngine.updateMarioMovement(this._mario, this._keys, this._config, this._isCelebrating, (sound) => soundSystem.play(sound));
|
||||
}
|
||||
}
|
||||
|
||||
_handleMouseMovement() {
|
||||
if (this._isCelebrating) return;
|
||||
|
||||
const targetX = this._mouseWorldPos.x;
|
||||
const targetY = this._mouseWorldPos.y;
|
||||
const marioX = this._mario.x + this._mario.width / 2; // Mario center
|
||||
const marioY = this._mario.y + this._mario.height / 2;
|
||||
|
||||
const distanceX = targetX - marioX;
|
||||
const distanceY = targetY - marioY;
|
||||
const totalDistance = Math.sqrt(distanceX * distanceX + distanceY * distanceY);
|
||||
|
||||
// Dead zone - don't move if target is very close
|
||||
if (totalDistance < 10) {
|
||||
this._mario.velocityX *= 0.8; // Friction
|
||||
return;
|
||||
}
|
||||
|
||||
// Horizontal movement
|
||||
if (Math.abs(distanceX) > 5) {
|
||||
if (distanceX > 0) {
|
||||
this._mario.velocityX = this._config.moveSpeed;
|
||||
this._mario.facing = 'right';
|
||||
} else {
|
||||
this._mario.velocityX = -this._config.moveSpeed;
|
||||
this._mario.facing = 'left';
|
||||
}
|
||||
}
|
||||
|
||||
// Jump if target is above Mario and Mario is on ground
|
||||
if (distanceY < -20 && this._mario.onGround && Math.abs(distanceX) < 100) {
|
||||
this._mario.velocityY = this._config.jumpForce;
|
||||
this._mario.onGround = false;
|
||||
soundSystem.play('jump');
|
||||
console.log(`🖱️ Auto-jump towards target`);
|
||||
}
|
||||
}
|
||||
|
||||
_updateMarioPhysics() {
|
||||
@ -2363,6 +2469,38 @@ class MarioEducational extends Module {
|
||||
|
||||
// Delegate rendering to helper
|
||||
renderer.render(this._ctx, gameState, this._config);
|
||||
|
||||
// Draw mouse target indicator if mouse is pressed
|
||||
if (this._mousePressed && this._mouseWorldPos.x !== null) {
|
||||
this._drawMouseTarget();
|
||||
}
|
||||
}
|
||||
|
||||
_drawMouseTarget() {
|
||||
const ctx = this._ctx;
|
||||
const targetX = this._mouseWorldPos.x - this._camera.x;
|
||||
const targetY = this._mouseWorldPos.y;
|
||||
|
||||
// Draw a crosshair at the target position
|
||||
ctx.save();
|
||||
ctx.strokeStyle = '#FFD700';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.globalAlpha = 0.8;
|
||||
|
||||
// Draw circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(targetX, targetY, 15, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw crosshair lines
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(targetX - 20, targetY);
|
||||
ctx.lineTo(targetX + 20, targetY);
|
||||
ctx.moveTo(targetX, targetY - 20);
|
||||
ctx.lineTo(targetX, targetY + 20);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -62,6 +62,10 @@ class RiverRun extends Module {
|
||||
this._isMusicPlaying = false;
|
||||
this._musicLoopTimeout = null;
|
||||
|
||||
// Power-up state
|
||||
this._slowTimeActive = false;
|
||||
this._slowTimeTimeout = null;
|
||||
|
||||
Object.seal(this);
|
||||
}
|
||||
|
||||
@ -228,6 +232,13 @@ class RiverRun extends Module {
|
||||
this._animationFrame = null;
|
||||
}
|
||||
|
||||
// Clear slow time timeout if active
|
||||
if (this._slowTimeTimeout) {
|
||||
clearTimeout(this._slowTimeTimeout);
|
||||
this._slowTimeTimeout = null;
|
||||
}
|
||||
this._slowTimeActive = false;
|
||||
|
||||
this._stopBackgroundMusic();
|
||||
|
||||
if (this._gameContainer) {
|
||||
@ -729,6 +740,13 @@ class RiverRun extends Module {
|
||||
_missWord(wordElement) {
|
||||
soundSystem.play('enemy_defeat');
|
||||
wordElement.classList.add('missed');
|
||||
|
||||
// Add shaking animation to the wrong word
|
||||
wordElement.style.animation = 'wordShake 0.4s ease-in-out, wordMissed 0.6s ease-out forwards';
|
||||
|
||||
// Shake the entire screen
|
||||
this._shakeScreen();
|
||||
|
||||
this._loseLife();
|
||||
|
||||
setTimeout(() => {
|
||||
@ -736,6 +754,17 @@ class RiverRun extends Module {
|
||||
}, 600);
|
||||
}
|
||||
|
||||
_shakeScreen() {
|
||||
const riverGame = document.getElementById('river-game');
|
||||
if (!riverGame) return;
|
||||
|
||||
riverGame.classList.add('screen-shake');
|
||||
|
||||
setTimeout(() => {
|
||||
riverGame.classList.remove('screen-shake');
|
||||
}, 500);
|
||||
}
|
||||
|
||||
_handlePowerUpCollision(powerUp, index) {
|
||||
this._activatePowerUp(powerUp.type);
|
||||
powerUp.element.remove();
|
||||
@ -745,9 +774,23 @@ class RiverRun extends Module {
|
||||
_activatePowerUp(type) {
|
||||
switch (type) {
|
||||
case 'slowTime':
|
||||
this._speed *= 0.5;
|
||||
setTimeout(() => {
|
||||
this._speed *= 2;
|
||||
// Clear any existing slowTime effect
|
||||
if (this._slowTimeTimeout) {
|
||||
clearTimeout(this._slowTimeTimeout);
|
||||
}
|
||||
|
||||
// Activate slow time effect
|
||||
this._slowTimeActive = true;
|
||||
soundSystem.play('powerup');
|
||||
|
||||
// Visual feedback - add a slow-time indicator to HUD
|
||||
this._showSlowTimeIndicator();
|
||||
|
||||
// Reset after 3 seconds
|
||||
this._slowTimeTimeout = setTimeout(() => {
|
||||
this._slowTimeActive = false;
|
||||
this._slowTimeTimeout = null;
|
||||
this._hideSlowTimeIndicator();
|
||||
}, 3000);
|
||||
break;
|
||||
}
|
||||
@ -760,7 +803,10 @@ class RiverRun extends Module {
|
||||
// Progressive speed increase: starts at initialSpeed, increases gradually
|
||||
// Speed increases by 0.1 every 5 seconds (0.02 per second)
|
||||
const speedIncrease = secondsElapsed * 0.02;
|
||||
this._speed = this._config.initialSpeed + speedIncrease;
|
||||
const baseSpeed = this._config.initialSpeed + speedIncrease;
|
||||
|
||||
// Apply slow-time multiplier if power-up is active
|
||||
this._speed = this._slowTimeActive ? baseSpeed * 0.5 : baseSpeed;
|
||||
|
||||
// Update level every 30 seconds
|
||||
const newLevel = Math.floor(timeElapsed / 30000) + 1;
|
||||
@ -771,6 +817,37 @@ class RiverRun extends Module {
|
||||
}
|
||||
}
|
||||
|
||||
_showSlowTimeIndicator() {
|
||||
const hud = document.querySelector('.river-run-hud .hud-right');
|
||||
if (!hud) return;
|
||||
|
||||
// Remove any existing indicator
|
||||
const existing = document.getElementById('slowtime-indicator');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const indicator = document.createElement('div');
|
||||
indicator.id = 'slowtime-indicator';
|
||||
indicator.innerHTML = '⏱️ SLOW TIME';
|
||||
indicator.style.cssText = `
|
||||
background: linear-gradient(45deg, #FF6B35, #F7931E);
|
||||
color: white;
|
||||
padding: 5px 15px;
|
||||
border-radius: 15px;
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
animation: slowTimePulse 0.5s ease-in-out infinite alternate;
|
||||
box-shadow: 0 0 15px rgba(255,107,53,0.8);
|
||||
`;
|
||||
hud.appendChild(indicator);
|
||||
}
|
||||
|
||||
_hideSlowTimeIndicator() {
|
||||
const indicator = document.getElementById('slowtime-indicator');
|
||||
if (indicator) {
|
||||
indicator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
_showPointsPopup(wordElement, points) {
|
||||
const rect = wordElement.getBoundingClientRect();
|
||||
const riverCanvas = document.getElementById('river-canvas');
|
||||
@ -1109,6 +1186,22 @@ class RiverRun extends Module {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.river-run-wrapper.screen-shake {
|
||||
animation: screenShake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes screenShake {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
10%, 30%, 50%, 70%, 90% {
|
||||
transform: translate(-10px, 5px);
|
||||
}
|
||||
20%, 40%, 60%, 80% {
|
||||
transform: translate(10px, -5px);
|
||||
}
|
||||
}
|
||||
|
||||
.river-run-hud {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
@ -1279,6 +1372,18 @@ class RiverRun extends Module {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wordShake {
|
||||
0%, 100% {
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
10%, 30%, 50%, 70%, 90% {
|
||||
transform: translateX(-10px) scale(1.05);
|
||||
}
|
||||
20%, 40%, 60%, 80% {
|
||||
transform: translateX(10px) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.power-up {
|
||||
position: absolute;
|
||||
width: 35px;
|
||||
@ -1300,6 +1405,17 @@ class RiverRun extends Module {
|
||||
to { transform: translateY(-8px) scale(1.05); }
|
||||
}
|
||||
|
||||
@keyframes slowTimePulse {
|
||||
from {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 15px rgba(255,107,53,0.8);
|
||||
}
|
||||
to {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 25px rgba(255,107,53,1);
|
||||
}
|
||||
}
|
||||
|
||||
.game-over-modal {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
@ -737,6 +737,91 @@ class WizardSpellCaster extends Module {
|
||||
animation: spellBlast 0.8s ease-out forwards, fireGlow 0.8s ease-out;
|
||||
}
|
||||
|
||||
.fireball-projectile {
|
||||
position: fixed;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 30% 30%, #fff, #ffd700, #ff6b7a, #ff4757);
|
||||
box-shadow: 0 0 40px #ff4757, 0 0 80px #ff6b7a, inset 0 0 20px #fff;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.fireball-trail {
|
||||
position: fixed;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, #ff6b7a, transparent);
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
animation: fadeTrail 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeTrail {
|
||||
0% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(2);
|
||||
}
|
||||
}
|
||||
|
||||
.fireball-spark {
|
||||
position: fixed;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ffd700;
|
||||
box-shadow: 0 0 10px #ff4757;
|
||||
pointer-events: none;
|
||||
z-index: 998;
|
||||
}
|
||||
|
||||
.lightning-bolt {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.lightning-segment {
|
||||
position: absolute;
|
||||
background: linear-gradient(90deg, transparent, #fff, #ffd700, #fff, transparent);
|
||||
box-shadow: 0 0 20px #ffd700, 0 0 40px #ffed4e;
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.lightning-spark {
|
||||
position: fixed;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 15px #ffd700, 0 0 30px #ffed4e;
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.lightning-flash {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
pointer-events: none;
|
||||
z-index: 998;
|
||||
animation: lightningFlash 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes lightningFlash {
|
||||
0%, 100% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.lightning-effect {
|
||||
background: radial-gradient(circle, #ffd700, #ffed4e, #fff200, transparent);
|
||||
filter: drop-shadow(0 0 25px #ffd700);
|
||||
@ -791,6 +876,81 @@ class WizardSpellCaster extends Module {
|
||||
}
|
||||
}
|
||||
|
||||
.meteor-projectile {
|
||||
position: fixed;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 25% 25%, #fff, #a29bfe, #6c5ce7, #5f3dc4);
|
||||
box-shadow: 0 0 50px #6c5ce7, 0 0 100px #a29bfe, inset 0 0 30px rgba(255, 255, 255, 0.5);
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.meteor-trail {
|
||||
position: fixed;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, #a29bfe, #6c5ce7, transparent);
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
animation: fadeTrail 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
.meteor-debris {
|
||||
position: fixed;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: linear-gradient(135deg, #a29bfe, #6c5ce7);
|
||||
box-shadow: 0 0 15px #6c5ce7;
|
||||
pointer-events: none;
|
||||
z-index: 998;
|
||||
}
|
||||
|
||||
.meteor-spark {
|
||||
position: fixed;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #a29bfe;
|
||||
box-shadow: 0 0 20px #6c5ce7, 0 0 40px #a29bfe;
|
||||
pointer-events: none;
|
||||
z-index: 997;
|
||||
}
|
||||
|
||||
.meteor-shockwave {
|
||||
position: fixed;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid #a29bfe;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 20px #6c5ce7, inset 0 0 20px #6c5ce7;
|
||||
pointer-events: none;
|
||||
z-index: 996;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: meteorShockwave 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes meteorShockwave {
|
||||
0% {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
opacity: 1;
|
||||
border-width: 5px;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
border-width: 3px;
|
||||
}
|
||||
100% {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
opacity: 0;
|
||||
border-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.mini-enemy {
|
||||
position: absolute;
|
||||
width: 60px;
|
||||
@ -1221,6 +1381,10 @@ class WizardSpellCaster extends Module {
|
||||
|
||||
_renderSpellCards() {
|
||||
const container = document.getElementById('spell-selection');
|
||||
if (!container) {
|
||||
console.warn('Spell selection container not found, skipping render');
|
||||
return;
|
||||
}
|
||||
container.innerHTML = this._currentSpells.map((spell, index) => `
|
||||
<div class="spell-card" data-spell-index="${index}">
|
||||
<div class="spell-type">${spell.icon} ${spell.name}</div>
|
||||
@ -1418,23 +1582,572 @@ class WizardSpellCaster extends Module {
|
||||
|
||||
_showSpellEffect(type) {
|
||||
const enemyChar = document.querySelector('.enemy-character');
|
||||
if (!enemyChar) {
|
||||
console.warn('Enemy character not found, skipping spell effect');
|
||||
return;
|
||||
}
|
||||
const rect = enemyChar.getBoundingClientRect();
|
||||
|
||||
// Main spell effect
|
||||
const effect = document.createElement('div');
|
||||
effect.className = `spell-effect ${type}-effect`;
|
||||
effect.style.position = 'fixed';
|
||||
effect.style.left = rect.left + rect.width/2 - 50 + 'px';
|
||||
effect.style.top = rect.top + rect.height/2 - 50 + 'px';
|
||||
document.body.appendChild(effect);
|
||||
// Special animations for different spell types
|
||||
if (type === 'short' || type === 'fire') {
|
||||
this._launchFireball(rect);
|
||||
} else if (type === 'medium' || type === 'lightning') {
|
||||
this._launchLightning(rect);
|
||||
} else if (type === 'long' || type === 'meteor') {
|
||||
this._launchMeteor(rect);
|
||||
} else {
|
||||
// Main spell effect for other types
|
||||
const effect = document.createElement('div');
|
||||
effect.className = `spell-effect ${type}-effect`;
|
||||
effect.style.position = 'fixed';
|
||||
effect.style.left = rect.left + rect.width/2 - 50 + 'px';
|
||||
effect.style.top = rect.top + rect.height/2 - 50 + 'px';
|
||||
document.body.appendChild(effect);
|
||||
|
||||
setTimeout(() => {
|
||||
effect.remove();
|
||||
}, 800);
|
||||
}
|
||||
|
||||
// Enhanced effects based on spell type
|
||||
this._createSpellParticles(type, rect);
|
||||
this._triggerSpellAnimation(type, enemyChar);
|
||||
}
|
||||
|
||||
_launchFireball(targetRect) {
|
||||
const wizardChar = document.querySelector('.wizard-character');
|
||||
const wizardRect = wizardChar.getBoundingClientRect();
|
||||
|
||||
// Create fireball
|
||||
const fireball = document.createElement('div');
|
||||
fireball.className = 'fireball-projectile';
|
||||
|
||||
const startX = wizardRect.left + wizardRect.width / 2 - 30;
|
||||
const startY = wizardRect.top + wizardRect.height / 2 - 30;
|
||||
const endX = targetRect.left + targetRect.width / 2 - 30;
|
||||
const endY = targetRect.top + targetRect.height / 2 - 30;
|
||||
|
||||
fireball.style.left = startX + 'px';
|
||||
fireball.style.top = startY + 'px';
|
||||
|
||||
document.body.appendChild(fireball);
|
||||
|
||||
// Animation parameters
|
||||
const duration = 500; // milliseconds
|
||||
const startTime = Date.now();
|
||||
const trailInterval = 30; // Create trail every 30ms
|
||||
let lastTrailTime = 0;
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Easing function (ease-in-out)
|
||||
const eased = progress < 0.5
|
||||
? 2 * progress * progress
|
||||
: -1 + (4 - 2 * progress) * progress;
|
||||
|
||||
// Calculate current position
|
||||
const currentX = startX + (endX - startX) * eased;
|
||||
const currentY = startY + (endY - startY) * eased;
|
||||
|
||||
fireball.style.left = currentX + 'px';
|
||||
fireball.style.top = currentY + 'px';
|
||||
|
||||
// Create trail effect
|
||||
if (elapsed - lastTrailTime > trailInterval) {
|
||||
this._createFireballTrail(currentX + 30, currentY + 30);
|
||||
this._createFireballSparks(currentX + 30, currentY + 30);
|
||||
lastTrailTime = elapsed;
|
||||
}
|
||||
|
||||
// Continue animation or finish
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
// Impact effect
|
||||
fireball.remove();
|
||||
this._createFireballImpact(endX + 30, endY + 30);
|
||||
}
|
||||
};
|
||||
|
||||
animate();
|
||||
}
|
||||
|
||||
_createFireballTrail(x, y) {
|
||||
const trail = document.createElement('div');
|
||||
trail.className = 'fireball-trail';
|
||||
trail.style.left = (x - 20) + 'px';
|
||||
trail.style.top = (y - 20) + 'px';
|
||||
document.body.appendChild(trail);
|
||||
|
||||
setTimeout(() => trail.remove(), 500);
|
||||
}
|
||||
|
||||
_createFireballSparks(x, y) {
|
||||
const sparkCount = 3;
|
||||
for (let i = 0; i < sparkCount; i++) {
|
||||
const spark = document.createElement('div');
|
||||
spark.className = 'fireball-spark';
|
||||
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const distance = 20 + Math.random() * 30;
|
||||
const sparkX = x + Math.cos(angle) * distance;
|
||||
const sparkY = y + Math.sin(angle) * distance;
|
||||
|
||||
spark.style.left = sparkX + 'px';
|
||||
spark.style.top = sparkY + 'px';
|
||||
|
||||
document.body.appendChild(spark);
|
||||
|
||||
// Animate spark
|
||||
const duration = 300 + Math.random() * 200;
|
||||
const startTime = Date.now();
|
||||
const startSparkX = sparkX;
|
||||
const startSparkY = sparkY;
|
||||
const velocityX = (Math.random() - 0.5) * 100;
|
||||
const velocityY = Math.random() * 50 - 100;
|
||||
|
||||
const animateSpark = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = elapsed / duration;
|
||||
|
||||
if (progress < 1) {
|
||||
const newX = startSparkX + velocityX * progress;
|
||||
const newY = startSparkY + velocityY * progress + (progress * progress * 100);
|
||||
spark.style.left = newX + 'px';
|
||||
spark.style.top = newY + 'px';
|
||||
spark.style.opacity = 1 - progress;
|
||||
requestAnimationFrame(animateSpark);
|
||||
} else {
|
||||
spark.remove();
|
||||
}
|
||||
};
|
||||
|
||||
animateSpark();
|
||||
}
|
||||
}
|
||||
|
||||
_createFireballImpact(x, y) {
|
||||
// Main explosion
|
||||
const explosion = document.createElement('div');
|
||||
explosion.className = 'spell-effect fire-effect';
|
||||
explosion.style.position = 'fixed';
|
||||
explosion.style.left = (x - 50) + 'px';
|
||||
explosion.style.top = (y - 50) + 'px';
|
||||
document.body.appendChild(explosion);
|
||||
|
||||
setTimeout(() => explosion.remove(), 800);
|
||||
|
||||
// Impact sparks
|
||||
const impactSparkCount = 12;
|
||||
for (let i = 0; i < impactSparkCount; i++) {
|
||||
const angle = (i / impactSparkCount) * Math.PI * 2;
|
||||
const spark = document.createElement('div');
|
||||
spark.className = 'fireball-spark';
|
||||
spark.style.left = x + 'px';
|
||||
spark.style.top = y + 'px';
|
||||
spark.style.width = '12px';
|
||||
spark.style.height = '12px';
|
||||
|
||||
document.body.appendChild(spark);
|
||||
|
||||
const distance = 60 + Math.random() * 40;
|
||||
const duration = 400;
|
||||
const startTime = Date.now();
|
||||
|
||||
const animateImpactSpark = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = elapsed / duration;
|
||||
|
||||
if (progress < 1) {
|
||||
const currentDistance = distance * progress;
|
||||
const newX = x + Math.cos(angle) * currentDistance;
|
||||
const newY = y + Math.sin(angle) * currentDistance;
|
||||
spark.style.left = newX + 'px';
|
||||
spark.style.top = newY + 'px';
|
||||
spark.style.opacity = 1 - progress;
|
||||
spark.style.transform = `scale(${1 - progress * 0.5})`;
|
||||
requestAnimationFrame(animateImpactSpark);
|
||||
} else {
|
||||
spark.remove();
|
||||
}
|
||||
};
|
||||
|
||||
animateImpactSpark();
|
||||
}
|
||||
}
|
||||
|
||||
_launchLightning(targetRect) {
|
||||
const wizardChar = document.querySelector('.wizard-character');
|
||||
const wizardRect = wizardChar.getBoundingClientRect();
|
||||
|
||||
const startX = wizardRect.left + wizardRect.width / 2;
|
||||
const startY = wizardRect.top + wizardRect.height / 2;
|
||||
const endX = targetRect.left + targetRect.width / 2;
|
||||
const endY = targetRect.top + targetRect.height / 2;
|
||||
|
||||
// Screen flash
|
||||
const flash = document.createElement('div');
|
||||
flash.className = 'lightning-flash';
|
||||
document.body.appendChild(flash);
|
||||
setTimeout(() => flash.remove(), 300);
|
||||
|
||||
// Create lightning bolt with jagged segments
|
||||
const boltContainer = document.createElement('div');
|
||||
boltContainer.className = 'lightning-bolt';
|
||||
document.body.appendChild(boltContainer);
|
||||
|
||||
const segments = 8;
|
||||
const points = [{ x: startX, y: startY }];
|
||||
|
||||
// Generate jagged lightning path
|
||||
for (let i = 1; i < segments; i++) {
|
||||
const progress = i / segments;
|
||||
const baseX = startX + (endX - startX) * progress;
|
||||
const baseY = startY + (endY - startY) * progress;
|
||||
|
||||
// Add random offset for jagged effect
|
||||
const offsetX = (Math.random() - 0.5) * 60;
|
||||
const offsetY = (Math.random() - 0.5) * 60;
|
||||
|
||||
points.push({
|
||||
x: baseX + offsetX,
|
||||
y: baseY + offsetY
|
||||
});
|
||||
}
|
||||
|
||||
points.push({ x: endX, y: endY });
|
||||
|
||||
// Draw lightning segments
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const segment = document.createElement('div');
|
||||
segment.className = 'lightning-segment';
|
||||
|
||||
const dx = points[i + 1].x - points[i].x;
|
||||
const dy = points[i + 1].y - points[i].y;
|
||||
const length = Math.sqrt(dx * dx + dy * dy);
|
||||
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
|
||||
|
||||
segment.style.left = points[i].x + 'px';
|
||||
segment.style.top = points[i].y + 'px';
|
||||
segment.style.width = length + 'px';
|
||||
segment.style.height = (3 + Math.random() * 3) + 'px';
|
||||
segment.style.transform = `rotate(${angle}deg)`;
|
||||
|
||||
boltContainer.appendChild(segment);
|
||||
}
|
||||
|
||||
// Create electric sparks along the path
|
||||
for (let i = 0; i < 15; i++) {
|
||||
setTimeout(() => {
|
||||
const pointIndex = Math.floor(Math.random() * points.length);
|
||||
const point = points[pointIndex];
|
||||
this._createLightningSpark(point.x, point.y);
|
||||
}, i * 30);
|
||||
}
|
||||
|
||||
// Remove lightning bolt after animation
|
||||
setTimeout(() => {
|
||||
effect.remove();
|
||||
}, 800);
|
||||
boltContainer.remove();
|
||||
}, 400);
|
||||
|
||||
// Impact effect
|
||||
setTimeout(() => {
|
||||
this._createLightningImpact(endX, endY);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
_createLightningSpark(x, y) {
|
||||
const spark = document.createElement('div');
|
||||
spark.className = 'lightning-spark';
|
||||
spark.style.left = x + 'px';
|
||||
spark.style.top = y + 'px';
|
||||
document.body.appendChild(spark);
|
||||
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const distance = 30 + Math.random() * 40;
|
||||
const duration = 300;
|
||||
const startTime = Date.now();
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = elapsed / duration;
|
||||
|
||||
if (progress < 1) {
|
||||
const currentDist = distance * progress;
|
||||
const newX = x + Math.cos(angle) * currentDist;
|
||||
const newY = y + Math.sin(angle) * currentDist;
|
||||
spark.style.left = newX + 'px';
|
||||
spark.style.top = newY + 'px';
|
||||
spark.style.opacity = 1 - progress;
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
spark.remove();
|
||||
}
|
||||
};
|
||||
|
||||
animate();
|
||||
}
|
||||
|
||||
_createLightningImpact(x, y) {
|
||||
// Main impact explosion
|
||||
const explosion = document.createElement('div');
|
||||
explosion.className = 'spell-effect lightning-effect';
|
||||
explosion.style.position = 'fixed';
|
||||
explosion.style.left = (x - 50) + 'px';
|
||||
explosion.style.top = (y - 50) + 'px';
|
||||
document.body.appendChild(explosion);
|
||||
|
||||
setTimeout(() => explosion.remove(), 800);
|
||||
|
||||
// Electric burst
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const spark = document.createElement('div');
|
||||
spark.className = 'lightning-spark';
|
||||
const angle = (i / 20) * Math.PI * 2;
|
||||
const distance = 40 + Math.random() * 60;
|
||||
|
||||
spark.style.left = x + 'px';
|
||||
spark.style.top = y + 'px';
|
||||
document.body.appendChild(spark);
|
||||
|
||||
const duration = 400 + Math.random() * 200;
|
||||
const startTime = Date.now();
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = elapsed / duration;
|
||||
|
||||
if (progress < 1) {
|
||||
const currentDist = distance * progress;
|
||||
const newX = x + Math.cos(angle) * currentDist;
|
||||
const newY = y + Math.sin(angle) * currentDist;
|
||||
spark.style.left = newX + 'px';
|
||||
spark.style.top = newY + 'px';
|
||||
spark.style.opacity = 1 - progress;
|
||||
spark.style.transform = `scale(${1 - progress * 0.7})`;
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
spark.remove();
|
||||
}
|
||||
};
|
||||
|
||||
animate();
|
||||
}
|
||||
}
|
||||
|
||||
_launchMeteor(targetRect) {
|
||||
// Create meteor starting from top of screen
|
||||
const meteor = document.createElement('div');
|
||||
meteor.className = 'meteor-projectile';
|
||||
|
||||
// Random horizontal start position (from above the screen)
|
||||
const startX = targetRect.left + targetRect.width / 2 + (Math.random() - 0.5) * 300;
|
||||
const startY = -100; // Start above viewport
|
||||
const endX = targetRect.left + targetRect.width / 2 - 40;
|
||||
const endY = targetRect.top + targetRect.height / 2 - 40;
|
||||
|
||||
meteor.style.left = startX + 'px';
|
||||
meteor.style.top = startY + 'px';
|
||||
|
||||
document.body.appendChild(meteor);
|
||||
|
||||
// Animation parameters
|
||||
const duration = 800; // milliseconds
|
||||
const startTime = Date.now();
|
||||
const trailInterval = 25;
|
||||
let lastTrailTime = 0;
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Accelerating fall (quadratic easing)
|
||||
const eased = progress * progress;
|
||||
|
||||
// Calculate current position
|
||||
const currentX = startX + (endX - startX) * progress;
|
||||
const currentY = startY + (endY - startY) * eased;
|
||||
|
||||
meteor.style.left = currentX + 'px';
|
||||
meteor.style.top = currentY + 'px';
|
||||
|
||||
// Rotate meteor as it falls
|
||||
meteor.style.transform = `rotate(${progress * 360}deg) scale(${1 + progress * 0.5})`;
|
||||
|
||||
// Create trail effect
|
||||
if (elapsed - lastTrailTime > trailInterval) {
|
||||
this._createMeteorTrail(currentX + 40, currentY + 40);
|
||||
this._createMeteorDebris(currentX + 40, currentY + 40);
|
||||
lastTrailTime = elapsed;
|
||||
}
|
||||
|
||||
// Continue animation or finish
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
// Impact effect
|
||||
meteor.remove();
|
||||
this._createMeteorImpact(endX + 40, endY + 40);
|
||||
}
|
||||
};
|
||||
|
||||
animate();
|
||||
}
|
||||
|
||||
_createMeteorTrail(x, y) {
|
||||
const trail = document.createElement('div');
|
||||
trail.className = 'meteor-trail';
|
||||
trail.style.left = (x - 30) + 'px';
|
||||
trail.style.top = (y - 30) + 'px';
|
||||
document.body.appendChild(trail);
|
||||
|
||||
setTimeout(() => trail.remove(), 600);
|
||||
}
|
||||
|
||||
_createMeteorDebris(x, y) {
|
||||
const debrisCount = 2;
|
||||
for (let i = 0; i < debrisCount; i++) {
|
||||
const debris = document.createElement('div');
|
||||
debris.className = 'meteor-debris';
|
||||
|
||||
// Debris flies off to the sides
|
||||
const angle = Math.PI / 2 + (Math.random() - 0.5) * Math.PI;
|
||||
const distance = 30 + Math.random() * 40;
|
||||
|
||||
debris.style.left = x + 'px';
|
||||
debris.style.top = y + 'px';
|
||||
|
||||
document.body.appendChild(debris);
|
||||
|
||||
const duration = 400 + Math.random() * 200;
|
||||
const startTime = Date.now();
|
||||
|
||||
const animateDebris = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = elapsed / duration;
|
||||
|
||||
if (progress < 1) {
|
||||
const currentDist = distance * progress;
|
||||
const newX = x + Math.cos(angle) * currentDist;
|
||||
const newY = y + Math.sin(angle) * currentDist + (progress * progress * 80);
|
||||
debris.style.left = newX + 'px';
|
||||
debris.style.top = newY + 'px';
|
||||
debris.style.opacity = 1 - progress;
|
||||
debris.style.transform = `rotate(${progress * 720}deg) scale(${1 - progress})`;
|
||||
requestAnimationFrame(animateDebris);
|
||||
} else {
|
||||
debris.remove();
|
||||
}
|
||||
};
|
||||
|
||||
animateDebris();
|
||||
}
|
||||
}
|
||||
|
||||
_createMeteorImpact(x, y) {
|
||||
// Screen shake on impact
|
||||
document.body.classList.add('screen-shake');
|
||||
setTimeout(() => document.body.classList.remove('screen-shake'), 500);
|
||||
|
||||
// Main explosion crater effect
|
||||
const explosion = document.createElement('div');
|
||||
explosion.className = 'spell-effect meteor-effect';
|
||||
explosion.style.position = 'fixed';
|
||||
explosion.style.left = (x - 50) + 'px';
|
||||
explosion.style.top = (y - 50) + 'px';
|
||||
document.body.appendChild(explosion);
|
||||
|
||||
setTimeout(() => explosion.remove(), 800);
|
||||
|
||||
// Shockwave rings
|
||||
for (let i = 0; i < 3; i++) {
|
||||
setTimeout(() => {
|
||||
const shockwave = document.createElement('div');
|
||||
shockwave.className = 'meteor-shockwave';
|
||||
shockwave.style.left = x + 'px';
|
||||
shockwave.style.top = y + 'px';
|
||||
document.body.appendChild(shockwave);
|
||||
|
||||
setTimeout(() => shockwave.remove(), 800);
|
||||
}, i * 100);
|
||||
}
|
||||
|
||||
// Impact debris explosion
|
||||
const debrisCount = 20;
|
||||
for (let i = 0; i < debrisCount; i++) {
|
||||
const debris = document.createElement('div');
|
||||
debris.className = 'meteor-debris';
|
||||
const angle = (i / debrisCount) * Math.PI * 2;
|
||||
const distance = 50 + Math.random() * 80;
|
||||
|
||||
debris.style.left = x + 'px';
|
||||
debris.style.top = y + 'px';
|
||||
debris.style.width = (6 + Math.random() * 8) + 'px';
|
||||
debris.style.height = (6 + Math.random() * 8) + 'px';
|
||||
|
||||
document.body.appendChild(debris);
|
||||
|
||||
const duration = 600 + Math.random() * 400;
|
||||
const startTime = Date.now();
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = elapsed / duration;
|
||||
|
||||
if (progress < 1) {
|
||||
const currentDist = distance * progress;
|
||||
const newX = x + Math.cos(angle) * currentDist;
|
||||
const newY = y + Math.sin(angle) * currentDist - (50 * progress) + (progress * progress * 100);
|
||||
debris.style.left = newX + 'px';
|
||||
debris.style.top = newY + 'px';
|
||||
debris.style.opacity = 1 - progress;
|
||||
debris.style.transform = `rotate(${progress * 1080}deg) scale(${1 - progress * 0.5})`;
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
debris.remove();
|
||||
}
|
||||
};
|
||||
|
||||
animate();
|
||||
}
|
||||
|
||||
// Purple impact sparks
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const spark = document.createElement('div');
|
||||
spark.className = 'meteor-spark';
|
||||
const angle = (i / 12) * Math.PI * 2;
|
||||
const distance = 60 + Math.random() * 50;
|
||||
|
||||
spark.style.left = x + 'px';
|
||||
spark.style.top = y + 'px';
|
||||
|
||||
document.body.appendChild(spark);
|
||||
|
||||
const duration = 500;
|
||||
const startTime = Date.now();
|
||||
|
||||
const animate = () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = elapsed / duration;
|
||||
|
||||
if (progress < 1) {
|
||||
const currentDist = distance * (1 - Math.pow(1 - progress, 3));
|
||||
const newX = x + Math.cos(angle) * currentDist;
|
||||
const newY = y + Math.sin(angle) * currentDist;
|
||||
spark.style.left = newX + 'px';
|
||||
spark.style.top = newY + 'px';
|
||||
spark.style.opacity = 1 - progress;
|
||||
spark.style.transform = `scale(${1 - progress * 0.8})`;
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
spark.remove();
|
||||
}
|
||||
};
|
||||
|
||||
animate();
|
||||
}
|
||||
}
|
||||
|
||||
_createSpellParticles(type, enemyRect) {
|
||||
@ -1599,7 +2312,10 @@ class WizardSpellCaster extends Module {
|
||||
|
||||
_wizardTakesDamage() {
|
||||
this._playerHP = Math.max(0, this._playerHP - 10);
|
||||
document.getElementById('player-health').style.width = this._playerHP + '%';
|
||||
const playerHealthEl = document.getElementById('player-health');
|
||||
if (playerHealthEl) {
|
||||
playerHealthEl.style.width = this._playerHP + '%';
|
||||
}
|
||||
|
||||
document.body.classList.add('screen-shake');
|
||||
setTimeout(() => {
|
||||
@ -1612,6 +2328,10 @@ class WizardSpellCaster extends Module {
|
||||
damageEl.style.color = '#ff4757';
|
||||
|
||||
const wizardChar = document.querySelector('.wizard-character');
|
||||
if (!wizardChar) {
|
||||
console.warn('Wizard character not found, skipping damage display');
|
||||
return;
|
||||
}
|
||||
const rect = wizardChar.getBoundingClientRect();
|
||||
|
||||
damageEl.style.position = 'fixed';
|
||||
@ -1741,7 +2461,10 @@ class WizardSpellCaster extends Module {
|
||||
Math.floor(Math.random() * (this._config.enemyDamage.max - this._config.enemyDamage.min + 1));
|
||||
|
||||
this._playerHP = Math.max(0, this._playerHP - damage);
|
||||
document.getElementById('player-health').style.width = this._playerHP + '%';
|
||||
const playerHealthEl = document.getElementById('player-health');
|
||||
if (playerHealthEl) {
|
||||
playerHealthEl.style.width = this._playerHP + '%';
|
||||
}
|
||||
|
||||
document.body.classList.add('screen-shake');
|
||||
setTimeout(() => {
|
||||
@ -1754,6 +2477,10 @@ class WizardSpellCaster extends Module {
|
||||
damageEl.style.color = '#ff4757';
|
||||
|
||||
const wizardChar = document.querySelector('.wizard-character');
|
||||
if (!wizardChar) {
|
||||
console.warn('Wizard character not found, skipping damage display');
|
||||
return;
|
||||
}
|
||||
const rect = wizardChar.getBoundingClientRect();
|
||||
|
||||
damageEl.style.position = 'fixed';
|
||||
@ -1896,9 +2623,18 @@ class WizardSpellCaster extends Module {
|
||||
this._selectedWords = [];
|
||||
|
||||
// Update UI
|
||||
document.getElementById('current-score').textContent = this._score;
|
||||
document.getElementById('player-health').style.width = '100%';
|
||||
document.getElementById('enemy-health').style.width = '100%';
|
||||
const currentScoreEl = document.getElementById('current-score');
|
||||
if (currentScoreEl) {
|
||||
currentScoreEl.textContent = this._score;
|
||||
}
|
||||
const playerHealthEl = document.getElementById('player-health');
|
||||
if (playerHealthEl) {
|
||||
playerHealthEl.style.width = '100%';
|
||||
}
|
||||
const enemyHealthEl = document.getElementById('enemy-health');
|
||||
if (enemyHealthEl) {
|
||||
enemyHealthEl.style.width = '100%';
|
||||
}
|
||||
|
||||
// Restart enemy attacks
|
||||
this._startEnemyAttackSystem();
|
||||
|
||||
@ -34,6 +34,15 @@ class WordDiscovery extends Module {
|
||||
this._correctAnswer = null;
|
||||
this._currentQuestion = null;
|
||||
|
||||
// Session progress tracking (5 discovery / 3 practice cycle)
|
||||
this._sessionDiscoveryCount = 0; // Words discovered in current cycle
|
||||
this._sessionPracticeCount = 0; // Practice questions answered in current cycle
|
||||
this._totalDiscoveryCount = 0; // Total discoveries in session
|
||||
this._totalPracticeCount = 0; // Total practice questions in session
|
||||
this._cycleDiscoveryMax = 5; // Discover 5 words
|
||||
this._cyclePracticeMax = 3; // Then 3 practice questions
|
||||
this._totalWordsAvailable = 0; // Total vocab words (e.g., 76)
|
||||
|
||||
Object.seal(this);
|
||||
}
|
||||
|
||||
@ -134,12 +143,13 @@ class WordDiscovery extends Module {
|
||||
example: data.example
|
||||
}));
|
||||
|
||||
this._discoveredWords = [];
|
||||
this._currentWordIndex = 0;
|
||||
this._currentPhase = 'discovery';
|
||||
this._totalWordsAvailable = this._practiceWords.length;
|
||||
|
||||
// Restore session or start fresh
|
||||
this._restoreSession();
|
||||
|
||||
await this._preloadAssets();
|
||||
this._renderDiscoveryPhase();
|
||||
this._renderCurrentPhase();
|
||||
|
||||
// Emit game ready event
|
||||
this._eventBus.emit('game:ready', {
|
||||
@ -240,6 +250,129 @@ class WordDiscovery extends Module {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current session state to sessionStorage
|
||||
* @private
|
||||
*/
|
||||
_saveSession() {
|
||||
const sessionData = {
|
||||
currentPhase: this._currentPhase,
|
||||
sessionDiscoveryCount: this._sessionDiscoveryCount,
|
||||
sessionPracticeCount: this._sessionPracticeCount,
|
||||
totalDiscoveryCount: this._totalDiscoveryCount,
|
||||
totalPracticeCount: this._totalPracticeCount,
|
||||
currentWordIndex: this._currentWordIndex,
|
||||
discoveredWords: this._discoveredWords.map(w => w.word),
|
||||
practiceCorrect: this._practiceCorrect,
|
||||
practiceTotal: this._practiceTotal
|
||||
};
|
||||
|
||||
sessionStorage.setItem('wordDiscovery_session', JSON.stringify(sessionData));
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore session state from sessionStorage or start fresh
|
||||
* @private
|
||||
*/
|
||||
_restoreSession() {
|
||||
const savedData = sessionStorage.getItem('wordDiscovery_session');
|
||||
|
||||
if (savedData) {
|
||||
try {
|
||||
const session = JSON.parse(savedData);
|
||||
this._currentPhase = session.currentPhase || 'discovery';
|
||||
this._sessionDiscoveryCount = session.sessionDiscoveryCount || 0;
|
||||
this._sessionPracticeCount = session.sessionPracticeCount || 0;
|
||||
this._totalDiscoveryCount = session.totalDiscoveryCount || 0;
|
||||
this._totalPracticeCount = session.totalPracticeCount || 0;
|
||||
this._currentWordIndex = session.currentWordIndex || 0;
|
||||
this._practiceCorrect = session.practiceCorrect || 0;
|
||||
this._practiceTotal = session.practiceTotal || 0;
|
||||
|
||||
// Restore discovered words
|
||||
if (session.discoveredWords && Array.isArray(session.discoveredWords)) {
|
||||
this._discoveredWords = this._practiceWords.filter(w =>
|
||||
session.discoveredWords.includes(w.word)
|
||||
);
|
||||
}
|
||||
|
||||
console.log('📖 Session restored:', session);
|
||||
} catch (error) {
|
||||
console.warn('Failed to restore session:', error);
|
||||
this._startFreshSession();
|
||||
}
|
||||
} else {
|
||||
this._startFreshSession();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a fresh session (reset all counters)
|
||||
* @private
|
||||
*/
|
||||
_startFreshSession() {
|
||||
this._discoveredWords = [];
|
||||
this._currentWordIndex = 0;
|
||||
this._currentPhase = 'discovery';
|
||||
this._sessionDiscoveryCount = 0;
|
||||
this._sessionPracticeCount = 0;
|
||||
this._totalDiscoveryCount = 0;
|
||||
this._totalPracticeCount = 0;
|
||||
this._practiceCorrect = 0;
|
||||
this._practiceTotal = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total progress including both discovery and practice
|
||||
* Total = totalWordsAvailable + (totalWordsAvailable * 3/5)
|
||||
* @private
|
||||
*/
|
||||
_calculateTotalProgress() {
|
||||
const maxDiscovery = this._totalWordsAvailable;
|
||||
const maxPractice = Math.floor(this._totalWordsAvailable * 3 / 5);
|
||||
const totalMax = maxDiscovery + maxPractice;
|
||||
const currentProgress = this._totalDiscoveryCount + this._totalPracticeCount;
|
||||
|
||||
return {
|
||||
current: currentProgress,
|
||||
max: totalMax,
|
||||
percentage: totalMax > 0 ? (currentProgress / totalMax) * 100 : 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if we should switch phases based on cycle counts
|
||||
* @private
|
||||
*/
|
||||
_shouldSwitchPhase() {
|
||||
if (this._currentPhase === 'discovery') {
|
||||
return this._sessionDiscoveryCount >= this._cycleDiscoveryMax;
|
||||
} else {
|
||||
return this._sessionPracticeCount >= this._cyclePracticeMax;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the appropriate phase based on current state
|
||||
* @private
|
||||
*/
|
||||
_renderCurrentPhase() {
|
||||
// Check if we've completed everything
|
||||
const maxDiscovery = this._totalWordsAvailable;
|
||||
const maxPractice = Math.floor(this._totalWordsAvailable * 3 / 5);
|
||||
|
||||
if (this._totalDiscoveryCount >= maxDiscovery && this._totalPracticeCount >= maxPractice) {
|
||||
this._showCompletionScreen();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._currentPhase === 'discovery') {
|
||||
this._renderDiscoveryPhase();
|
||||
} else {
|
||||
this._renderPracticePhase();
|
||||
}
|
||||
}
|
||||
|
||||
async _preloadAssets() {
|
||||
for (const word of this._practiceWords) {
|
||||
if (word.audio) {
|
||||
@ -271,20 +404,41 @@ class WordDiscovery extends Module {
|
||||
}
|
||||
|
||||
_renderDiscoveryPhase() {
|
||||
const word = this._practiceWords[this._currentWordIndex];
|
||||
if (!word) {
|
||||
this._startPracticePhase();
|
||||
// Check if we've discovered all words
|
||||
if (this._totalDiscoveryCount >= this._totalWordsAvailable) {
|
||||
this._currentPhase = 'practice';
|
||||
this._renderPracticePhase();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we need to switch to practice (5 discoveries done in this cycle)
|
||||
if (this._shouldSwitchPhase()) {
|
||||
this._currentPhase = 'practice';
|
||||
this._sessionPracticeCount = 0; // Reset practice counter for new cycle
|
||||
this._saveSession();
|
||||
this._renderPracticePhase();
|
||||
return;
|
||||
}
|
||||
|
||||
const word = this._practiceWords[this._currentWordIndex];
|
||||
if (!word) {
|
||||
// No more words, finish discovery
|
||||
this._currentPhase = 'practice';
|
||||
this._renderPracticePhase();
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = this._calculateTotalProgress();
|
||||
|
||||
this._gameContainer.innerHTML = `
|
||||
<div class="word-discovery-container">
|
||||
<div class="discovery-header">
|
||||
<h2>Word Discovery</h2>
|
||||
<h2>Word Discovery (${this._sessionDiscoveryCount}/${this._cycleDiscoveryMax} in cycle)</h2>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${(this._currentWordIndex / this._practiceWords.length) * 100}%"></div>
|
||||
<div class="progress-fill" style="width: ${progress.percentage}%"></div>
|
||||
</div>
|
||||
<p>Progress: ${this._currentWordIndex + 1} / ${this._practiceWords.length}</p>
|
||||
<p>Total Progress: ${progress.current} / ${progress.max}</p>
|
||||
<p class="cycle-info">After ${this._cycleDiscoveryMax} discoveries, ${this._cyclePracticeMax} practice questions</p>
|
||||
</div>
|
||||
|
||||
<div class="word-card discovery-card">
|
||||
@ -311,12 +465,6 @@ class WordDiscovery extends Module {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="discovery-controls">
|
||||
<button class="practice-btn" onclick="window.wordDiscovery._startPracticePhase()">
|
||||
Start Practice
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@ -332,10 +480,13 @@ class WordDiscovery extends Module {
|
||||
const currentWord = this._practiceWords[this._currentWordIndex];
|
||||
if (currentWord && !this._discoveredWords.find(w => w.word === currentWord.word)) {
|
||||
this._discoveredWords.push(currentWord);
|
||||
this._sessionDiscoveryCount++;
|
||||
this._totalDiscoveryCount++;
|
||||
}
|
||||
|
||||
this._currentWordIndex++;
|
||||
this._renderDiscoveryPhase();
|
||||
this._saveSession();
|
||||
this._renderCurrentPhase();
|
||||
}
|
||||
|
||||
_playAudio(word) {
|
||||
@ -433,35 +584,49 @@ class WordDiscovery extends Module {
|
||||
});
|
||||
}
|
||||
|
||||
_startPracticePhase() {
|
||||
if (this._discoveredWords.length === 0) {
|
||||
this._discoveredWords = [...this._practiceWords];
|
||||
_renderPracticePhase() {
|
||||
// Check if we've completed all practice (max = totalWords * 3/5)
|
||||
const maxPractice = Math.floor(this._totalWordsAvailable * 3 / 5);
|
||||
if (this._totalPracticeCount >= maxPractice) {
|
||||
// All practice done, switch back to discovery or finish
|
||||
if (this._totalDiscoveryCount < this._totalWordsAvailable) {
|
||||
this._currentPhase = 'discovery';
|
||||
this._sessionDiscoveryCount = 0;
|
||||
this._saveSession();
|
||||
this._renderDiscoveryPhase();
|
||||
} else {
|
||||
this._showCompletionScreen();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._currentPhase = 'practice';
|
||||
this._currentPracticeLevel = 0;
|
||||
this._practiceCorrect = 0;
|
||||
this._practiceTotal = 0;
|
||||
// Check if we need to switch back to discovery (3 practice done in this cycle)
|
||||
if (this._shouldSwitchPhase()) {
|
||||
this._currentPhase = 'discovery';
|
||||
this._sessionDiscoveryCount = 0; // Reset discovery counter for new cycle
|
||||
this._saveSession();
|
||||
this._renderDiscoveryPhase();
|
||||
return;
|
||||
}
|
||||
|
||||
this._renderPracticeLevel();
|
||||
}
|
||||
if (this._discoveredWords.length === 0) {
|
||||
// No words to practice yet
|
||||
this._currentPhase = 'discovery';
|
||||
this._renderDiscoveryPhase();
|
||||
return;
|
||||
}
|
||||
|
||||
_renderPracticeLevel() {
|
||||
const levels = ['Easy', 'Medium', 'Hard', 'Expert'];
|
||||
const levelConfig = {
|
||||
0: { options: 4, type: 'translation' },
|
||||
1: { options: 3, type: 'mixed' },
|
||||
2: { options: 4, type: 'definition' },
|
||||
3: { options: 4, type: 'context' }
|
||||
};
|
||||
|
||||
const config = levelConfig[this._currentPracticeLevel];
|
||||
const levelName = levels[this._currentPracticeLevel];
|
||||
const progress = this._calculateTotalProgress();
|
||||
const config = { options: 4, type: 'translation' }; // Simple translation questions
|
||||
|
||||
this._gameContainer.innerHTML = `
|
||||
<div class="word-discovery-container">
|
||||
<div class="practice-header">
|
||||
<h2>Practice Phase - ${levelName}</h2>
|
||||
<h2>Practice Phase (${this._sessionPracticeCount}/${this._cyclePracticeMax} in cycle)</h2>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: ${progress.percentage}%"></div>
|
||||
</div>
|
||||
<p>Total Progress: ${progress.current} / ${progress.max}</p>
|
||||
<div class="practice-stats">
|
||||
<span>Correct: ${this._practiceCorrect}</span>
|
||||
<span>Total: ${this._practiceTotal}</span>
|
||||
@ -472,16 +637,6 @@ class WordDiscovery extends Module {
|
||||
<div class="practice-question" id="practice-question">
|
||||
Loading question...
|
||||
</div>
|
||||
|
||||
<div class="practice-controls">
|
||||
<button class="back-btn" onclick="window.wordDiscovery._backToDiscovery()">
|
||||
Back to Discovery
|
||||
</button>
|
||||
<button class="next-level-btn" onclick="window.wordDiscovery._nextLevel()"
|
||||
style="display: ${this._currentPracticeLevel < 3 ? 'inline-block' : 'none'}">
|
||||
Next Level
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@ -493,8 +648,7 @@ class WordDiscovery extends Module {
|
||||
const levelConfig = {
|
||||
0: { options: 4, type: 'translation' },
|
||||
1: { options: 3, type: 'mixed' },
|
||||
2: { options: 4, type: 'definition' },
|
||||
3: { options: 4, type: 'context' }
|
||||
2: { options: 4, type: 'definition' }
|
||||
};
|
||||
config = levelConfig[this._currentPracticeLevel];
|
||||
}
|
||||
@ -530,9 +684,6 @@ class WordDiscovery extends Module {
|
||||
case 'definition':
|
||||
questionHTML = this._renderDefinitionQuestion(correctWord);
|
||||
break;
|
||||
case 'context':
|
||||
questionHTML = this._renderContextQuestion(correctWord);
|
||||
break;
|
||||
case 'mixed':
|
||||
const types = ['translation', 'definition'];
|
||||
const randomType = types[Math.floor(Math.random() * types.length)];
|
||||
@ -541,6 +692,11 @@ class WordDiscovery extends Module {
|
||||
}
|
||||
|
||||
questionContainer.innerHTML = questionHTML;
|
||||
|
||||
// Auto-play TTS for the target language word in practice mode
|
||||
setTimeout(() => {
|
||||
this._playWordSound(correctWord.word);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
_renderTranslationQuestion(correctWord) {
|
||||
@ -586,29 +742,11 @@ class WordDiscovery extends Module {
|
||||
`;
|
||||
}
|
||||
|
||||
_renderContextQuestion(correctWord) {
|
||||
return `
|
||||
<div class="question-content">
|
||||
<h3>Complete the sentence:</h3>
|
||||
<div class="question-context">
|
||||
${correctWord.example ? correctWord.example.replace(correctWord.word, '_____') : `The _____ is very important.`}
|
||||
</div>
|
||||
<div class="options-grid">
|
||||
${this._practiceOptions.map(option => `
|
||||
<button class="option-btn" onclick="window.wordDiscovery._selectAnswer('${option.word}')">
|
||||
<div class="option-word-with-pronunciation">
|
||||
<span>${option.word}</span>
|
||||
${option.pronunciation ? `<span class="option-pronunciation">[${option.pronunciation}]</span>` : ''}
|
||||
</div>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_selectAnswer(selectedWord) {
|
||||
this._practiceTotal++;
|
||||
this._sessionPracticeCount++;
|
||||
this._totalPracticeCount++;
|
||||
|
||||
const isCorrect = selectedWord === this._correctAnswer.word;
|
||||
|
||||
if (isCorrect) {
|
||||
@ -619,8 +757,10 @@ class WordDiscovery extends Module {
|
||||
|
||||
this._showResult(isCorrect, isCorrect ? 'Correct!' : `Wrong! The answer was: ${this._correctAnswer.word}`);
|
||||
|
||||
this._saveSession();
|
||||
|
||||
setTimeout(() => {
|
||||
this._generateQuestion();
|
||||
this._renderCurrentPhase();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
@ -652,17 +792,44 @@ class WordDiscovery extends Module {
|
||||
}
|
||||
}
|
||||
|
||||
_nextLevel() {
|
||||
if (this._currentPracticeLevel < 3) {
|
||||
this._currentPracticeLevel++;
|
||||
this._renderPracticeLevel();
|
||||
}
|
||||
_showCompletionScreen() {
|
||||
const progress = this._calculateTotalProgress();
|
||||
const accuracy = this._practiceTotal > 0 ? Math.round((this._practiceCorrect / this._practiceTotal) * 100) : 0;
|
||||
|
||||
this._gameContainer.innerHTML = `
|
||||
<div class="word-discovery-container">
|
||||
<div class="completion-screen">
|
||||
<h2>🎉 Congratulations!</h2>
|
||||
<p>You've completed all vocabulary!</p>
|
||||
<div class="completion-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Words Discovered:</span>
|
||||
<span class="stat-value">${this._totalDiscoveryCount}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Practice Questions:</span>
|
||||
<span class="stat-value">${this._totalPracticeCount}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Overall Accuracy:</span>
|
||||
<span class="stat-value">${accuracy}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="completion-controls">
|
||||
<button class="restart-btn" onclick="window.wordDiscovery._restartSession()">
|
||||
Start Over
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
_backToDiscovery() {
|
||||
this._currentPhase = 'discovery';
|
||||
this._currentWordIndex = 0;
|
||||
this._renderDiscoveryPhase();
|
||||
_restartSession() {
|
||||
sessionStorage.removeItem('wordDiscovery_session');
|
||||
this._startFreshSession();
|
||||
this._saveSession();
|
||||
this._renderCurrentPhase();
|
||||
}
|
||||
|
||||
_injectCSS() {
|
||||
@ -689,6 +856,13 @@ class WordDiscovery extends Module {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.cycle-info {
|
||||
font-size: 0.9em;
|
||||
color: #7f8c8d;
|
||||
font-style: italic;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
@ -964,6 +1138,71 @@ class WordDiscovery extends Module {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.completion-screen {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 16px;
|
||||
color: white;
|
||||
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.completion-screen h2 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 20px;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.completion-stats {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
margin: 30px 0;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stat-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 1.2em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.restart-btn {
|
||||
padding: 15px 40px;
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
font-size: 1.2em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 20px;
|
||||
box-shadow: 0 5px 15px rgba(245, 87, 108, 0.4);
|
||||
}
|
||||
|
||||
.restart-btn:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 20px rgba(245, 87, 108, 0.6);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.word-discovery-container {
|
||||
padding: 10px;
|
||||
|
||||
@ -657,7 +657,7 @@ class WordStorm extends Module {
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._generateAnswerOptions();
|
||||
// Answer buttons will be populated when first word spawns
|
||||
}
|
||||
|
||||
_setupEventListeners() {
|
||||
@ -718,6 +718,9 @@ class WordStorm extends Module {
|
||||
const word = this._vocabulary[this._currentWordIndex % this._vocabulary.length];
|
||||
this._currentWordIndex++;
|
||||
|
||||
// Generate options for THIS specific word
|
||||
const options = this._generateOptionsForWord(word);
|
||||
|
||||
const gameArea = document.getElementById('game-area');
|
||||
const wordElement = document.createElement('div');
|
||||
wordElement.className = 'falling-word';
|
||||
@ -734,27 +737,22 @@ class WordStorm extends Module {
|
||||
return;
|
||||
}
|
||||
|
||||
const gameArea = document.getElementById('game-area');
|
||||
if (!gameArea) {
|
||||
const answerPanel = document.getElementById('answer-panel');
|
||||
if (!answerPanel) {
|
||||
clearInterval(positionCheck);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get positions using getBoundingClientRect for accuracy
|
||||
const wordRect = wordElement.getBoundingClientRect();
|
||||
const gameAreaRect = gameArea.getBoundingClientRect();
|
||||
const answerPanelRect = answerPanel.getBoundingClientRect();
|
||||
|
||||
// Calculate word's position relative to game area
|
||||
const wordTop = wordRect.top;
|
||||
const wordHeight = wordRect.height;
|
||||
const gameAreaBottom = gameAreaRect.bottom;
|
||||
// Calculate word's bottom edge position
|
||||
const wordBottom = wordRect.bottom;
|
||||
const panelTop = answerPanelRect.top;
|
||||
|
||||
// Destroy when word's bottom edge nears the bottom of the game area
|
||||
// Use larger margin to ensure word stays visible until destruction
|
||||
const wordBottom = wordTop + wordHeight;
|
||||
const threshold = gameAreaBottom - 100; // 100px margin before bottom
|
||||
|
||||
if (wordBottom >= threshold) {
|
||||
// Destroy when word's bottom edge touches the answer panel
|
||||
if (wordBottom >= panelTop) {
|
||||
clearInterval(positionCheck);
|
||||
if (wordElement.parentNode) {
|
||||
this._missWord(wordElement);
|
||||
@ -763,15 +761,18 @@ class WordStorm extends Module {
|
||||
|
||||
}, 50); // Check every 50ms for smooth detection
|
||||
|
||||
this._fallingWords.push({
|
||||
const fallingWordData = {
|
||||
element: wordElement,
|
||||
word: word,
|
||||
options: options, // Store options with this word
|
||||
startTime: Date.now(),
|
||||
positionCheck: positionCheck
|
||||
});
|
||||
};
|
||||
|
||||
// Generate new answer options when word spawns
|
||||
this._generateAnswerOptions();
|
||||
this._fallingWords.push(fallingWordData);
|
||||
|
||||
// Update answer panel with the OLDEST word's options (first word still falling)
|
||||
this._updateAnswerPanelForOldestWord();
|
||||
|
||||
// Animate falling
|
||||
this._animateFalling(wordElement);
|
||||
@ -797,16 +798,11 @@ class WordStorm extends Module {
|
||||
}, 50);
|
||||
}
|
||||
|
||||
_generateAnswerOptions() {
|
||||
if (this._vocabulary.length === 0) return;
|
||||
|
||||
_generateOptionsForWord(word) {
|
||||
const buttons = [];
|
||||
const correctWord = this._fallingWords.length > 0 ?
|
||||
this._fallingWords[this._fallingWords.length - 1].word :
|
||||
this._vocabulary[0];
|
||||
|
||||
// Add correct answer
|
||||
buttons.push(correctWord.translation);
|
||||
buttons.push(word.translation);
|
||||
|
||||
// Add 3 random incorrect answers
|
||||
while (buttons.length < 4) {
|
||||
@ -816,13 +812,24 @@ class WordStorm extends Module {
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle buttons
|
||||
this._shuffleArray(buttons);
|
||||
// Shuffle and return
|
||||
return this._shuffleArray(buttons);
|
||||
}
|
||||
|
||||
_updateAnswerPanelForOldestWord() {
|
||||
// Find the oldest word still falling (first in array)
|
||||
const activeFallingWords = this._fallingWords.filter(fw => fw.element.parentNode);
|
||||
|
||||
if (activeFallingWords.length === 0) return;
|
||||
|
||||
// Use the OLDEST word's options
|
||||
const oldestWord = activeFallingWords[0];
|
||||
const options = oldestWord.options;
|
||||
|
||||
// Update answer panel
|
||||
const answerButtons = document.getElementById('answer-buttons');
|
||||
if (answerButtons) {
|
||||
answerButtons.innerHTML = buttons.map(answer =>
|
||||
answerButtons.innerHTML = options.map(answer =>
|
||||
`<button class="word-storm-answer-btn">${answer}</button>`
|
||||
).join('');
|
||||
}
|
||||
@ -892,6 +899,9 @@ class WordStorm extends Module {
|
||||
// Add points popup animation
|
||||
this._showPointsPopup(points, fallingWord.element);
|
||||
|
||||
// Update answer panel to show next word's options
|
||||
this._updateAnswerPanelForOldestWord();
|
||||
|
||||
// Vibration feedback (if supported)
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate([50, 30, 50]);
|
||||
@ -1002,9 +1012,30 @@ class WordStorm extends Module {
|
||||
clearInterval(fallingWord.positionCheck);
|
||||
}
|
||||
|
||||
// Remove word
|
||||
// Play explosion sound
|
||||
soundSystem.play('enemy_defeat');
|
||||
|
||||
// EXPLOSION ANIMATION when word reaches bottom
|
||||
if (wordElement.parentNode) {
|
||||
wordElement.remove();
|
||||
wordElement.classList.add('exploding');
|
||||
|
||||
// Add screen shake effect for life loss
|
||||
const gameArea = document.getElementById('game-area');
|
||||
if (gameArea) {
|
||||
gameArea.style.animation = 'none';
|
||||
gameArea.offsetHeight; // Force reflow
|
||||
gameArea.style.animation = 'screenShake 0.5s ease-in-out';
|
||||
setTimeout(() => {
|
||||
gameArea.style.animation = '';
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Remove after explosion animation completes
|
||||
setTimeout(() => {
|
||||
if (wordElement.parentNode) {
|
||||
wordElement.remove();
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
|
||||
// Remove from tracking
|
||||
@ -1016,6 +1047,14 @@ class WordStorm extends Module {
|
||||
|
||||
this._updateHUD();
|
||||
|
||||
// Update answer panel to show next word's options
|
||||
this._updateAnswerPanelForOldestWord();
|
||||
|
||||
// Stronger vibration for life loss
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate([200, 100, 200, 100, 200]);
|
||||
}
|
||||
|
||||
if (this._lives <= 0) {
|
||||
this._gameOver();
|
||||
}
|
||||
@ -1180,7 +1219,6 @@ class WordStorm extends Module {
|
||||
|
||||
// Update HUD and restart
|
||||
this._updateHUD();
|
||||
this._generateAnswerOptions();
|
||||
this._startSpawning();
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user