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:
StillHammer 2025-10-18 14:09:13 +08:00
parent 4714a4a1c6
commit e4d7e838d5
10 changed files with 1538 additions and 165 deletions

View File

@ -240,7 +240,7 @@ window.ContentModules.SBSLevel1 = {
story: {
title: "To Be: Introduction - 动词Be的介绍",
totalSentences: 50,
totalSentences: 13,
chapters: [
{
title: "Chapter 1: Vocabulary Preview - 第一章:词汇预览",

View File

@ -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;
}
}
});

View File

@ -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);
}
});
}

View File

@ -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));

View File

@ -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--) {

View File

@ -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();
}
/**

View File

@ -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%;

View File

@ -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();

View File

@ -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;

View File

@ -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();
}