diff --git a/src/content/SBS-level-1.js b/src/content/SBS-level-1.js
index 3793ab0..6c8cad8 100644
--- a/src/content/SBS-level-1.js
+++ b/src/content/SBS-level-1.js
@@ -240,7 +240,7 @@ window.ContentModules.SBSLevel1 = {
story: {
title: "To Be: Introduction - 动词Be的介绍",
- totalSentences: 50,
+ totalSentences: 13,
chapters: [
{
title: "Chapter 1: Vocabulary Preview - 第一章:词汇预览",
diff --git a/src/gameHelpers/MarioEducational/PhysicsEngine.js b/src/gameHelpers/MarioEducational/PhysicsEngine.js
index deb819e..926e047 100644
--- a/src/gameHelpers/MarioEducational/PhysicsEngine.js
+++ b/src/gameHelpers/MarioEducational/PhysicsEngine.js
@@ -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;
+ }
}
});
diff --git a/src/gameHelpers/MarioEducational/Renderer.js b/src/gameHelpers/MarioEducational/Renderer.js
index ff9200c..a680e6e 100644
--- a/src/gameHelpers/MarioEducational/Renderer.js
+++ b/src/gameHelpers/MarioEducational/Renderer.js
@@ -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);
}
});
}
diff --git a/src/gameHelpers/MarioEducational/enemies/PiranhaPlant.js b/src/gameHelpers/MarioEducational/enemies/PiranhaPlant.js
index d3c8a77..79264b9 100644
--- a/src/gameHelpers/MarioEducational/enemies/PiranhaPlant.js
+++ b/src/gameHelpers/MarioEducational/enemies/PiranhaPlant.js
@@ -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));
diff --git a/src/games/GrammarDiscovery.js b/src/games/GrammarDiscovery.js
index ff2d267..0bde951 100644
--- a/src/games/GrammarDiscovery.js
+++ b/src/games/GrammarDiscovery.js
@@ -788,7 +788,7 @@ class GrammarDiscovery extends Module {
${example.pronunciation || example.prononciation || ''}
${example.explanation || example.breakdown || ''}
-
@@ -877,7 +877,7 @@ class GrammarDiscovery extends Module {
${example.pronunciation || example.prononciation || ''}
${example.explanation || example.breakdown || ''}
-
+
🔊 Pronunciation
@@ -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} 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--) {
diff --git a/src/games/MarioEducational.js b/src/games/MarioEducational.js
index ea5ba1b..727fa17 100644
--- a/src/games/MarioEducational.js
+++ b/src/games/MarioEducational.js
@@ -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();
}
/**
diff --git a/src/games/RiverRun.js b/src/games/RiverRun.js
index 15f3315..60e0457 100644
--- a/src/games/RiverRun.js
+++ b/src/games/RiverRun.js
@@ -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%;
diff --git a/src/games/WizardSpellCaster.js b/src/games/WizardSpellCaster.js
index da4c334..c136f3d 100644
--- a/src/games/WizardSpellCaster.js
+++ b/src/games/WizardSpellCaster.js
@@ -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) => `
${spell.icon} ${spell.name}
@@ -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();
diff --git a/src/games/WordDiscovery.js b/src/games/WordDiscovery.js
index f15a6e4..6745bb0 100644
--- a/src/games/WordDiscovery.js
+++ b/src/games/WordDiscovery.js
@@ -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 = `
@@ -311,12 +465,6 @@ class WordDiscovery extends Module {
-
-
-
- Start Practice
-
-
`;
@@ -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 = `
`;
- 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 =>
`${answer}`
).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();
}