Add TTS support and improve content compatibility system

Major improvements:
- Add TTSHelper utility for text-to-speech functionality
- Enhance content compatibility scoring across all games
- Improve sentence extraction from multiple content sources
- Update all game modules to support diverse content formats
- Refine MarioEducational physics and rendering
- Polish UI styles and remove unused CSS

Games updated: AdventureReader, FillTheBlank, FlashcardLearning,
GrammarDiscovery, MarioEducational, QuizGame, RiverRun, WhackAMole,
WhackAMoleHard, WizardSpellCaster, WordDiscovery, WordStorm

🤖 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 02:49:48 +08:00
parent 325b97060c
commit 4714a4a1c6
20 changed files with 2203 additions and 570 deletions

View File

@ -159,11 +159,248 @@
] ]
} }
}, },
"grammar": {
"articles": {
"title": "Articles (a, an, the)",
"explanation": "Articles are used before nouns. 'A' and 'an' are indefinite articles (any one), 'the' is the definite article (a specific one).",
"examples": [
{
"chinese": "I'm wearing a blue shirt",
"translation": "我穿着一件蓝色的衬衫",
"explanation": "We use 'a' before singular countable nouns"
},
{
"chinese": "I live in the center of town",
"translation": "我住在城镇中心",
"explanation": "We use 'the' when talking about a specific location"
}
]
},
"present-continuous": {
"title": "Present Continuous Tense (am/is/are + -ing)",
"explanation": "Used to describe actions happening right now or temporary situations. Form: subject + am/is/are + verb-ing",
"examples": [
{
"chinese": "She is wearing a dress",
"translation": "她正穿着一件连衣裙",
"explanation": "Present continuous shows what someone is wearing now"
},
{
"chinese": "What are you wearing?",
"translation": "你穿的是什么?",
"explanation": "Question form: Question word + am/is/are + subject + verb-ing"
}
]
},
"adjectives-emotions": {
"title": "Adjectives for Feelings and Emotions",
"explanation": "Adjectives describe how someone feels. After 'feel' or 'be', we use adjectives (not adverbs).",
"examples": [
{
"chinese": "I feel happy today",
"translation": "我今天感觉很开心",
"explanation": "After 'feel', use adjective 'happy' not adverb 'happily'"
},
{
"chinese": "Are you hungry?",
"translation": "你饿吗?",
"explanation": "We use 'are' with adjectives like hungry, tired, cold"
}
]
},
"there-is-are": {
"title": "There is / There are",
"explanation": "Use 'there is' for singular or uncountable nouns, 'there are' for plural countable nouns.",
"examples": [
{
"chinese": "There is a lot of noise",
"translation": "有很多噪音",
"explanation": "'Noise' is uncountable, so we use 'is' not 'are'"
},
{
"chinese": "There's a bus stop right outside",
"translation": "外面就有一个公交车站",
"explanation": "Use 'there's' (there is) for a single bus stop"
}
]
}
},
"fillInBlanks": [
{
"sentence": "I live in a two-bedroom ___",
"options": ["apartment", "building", "elevator", "closet"],
"correctAnswer": "apartment",
"explanation": "We use 'apartment' to describe a living space with bedrooms",
"grammarFocus": "housing-vocabulary"
},
{
"sentence": "The apartment is in the ___ of town",
"options": ["center", "noise", "sidewalk", "building"],
"correctAnswer": "center",
"explanation": "'Center' means the middle or main part of town",
"grammarFocus": "location"
},
{
"sentence": "I'm wearing a blue ___",
"options": ["shirt", "pants", "shoes", "hat"],
"correctAnswer": "shirt",
"explanation": "A shirt is a piece of clothing for the upper body",
"grammarFocus": "clothing-vocabulary"
},
{
"sentence": "She is wearing a beautiful ___",
"options": ["dress", "tie", "belt", "gloves"],
"correctAnswer": "dress",
"explanation": "A dress is a one-piece garment typically worn by women",
"grammarFocus": "clothing-vocabulary"
},
{
"sentence": "My ___ hurts from typing all day",
"options": ["fingers", "eyes", "ears", "nose"],
"correctAnswer": "fingers",
"explanation": "Fingers are used for typing on a keyboard",
"grammarFocus": "body-parts"
},
{
"sentence": "I feel very ___ today",
"options": ["happy", "shirt", "computer", "building"],
"correctAnswer": "happy",
"explanation": "Happy is an emotion/feeling adjective",
"grammarFocus": "emotions"
},
{
"sentence": "Are you ___? You haven't eaten all day",
"options": ["hungry", "tired", "cold", "hot"],
"correctAnswer": "hungry",
"explanation": "Hungry describes the feeling of needing food",
"grammarFocus": "emotions"
},
{
"sentence": "I need to check my ___",
"options": ["email", "password", "website", "laptop"],
"correctAnswer": "email",
"explanation": "We 'check email' to read messages",
"grammarFocus": "technology"
},
{
"sentence": "Do you have ___ access?",
"options": ["internet", "computer", "phone", "tablet"],
"correctAnswer": "internet",
"explanation": "We say 'internet access' to mean connection to the web",
"grammarFocus": "technology"
},
{
"sentence": "There's a lot of ___ in the city",
"options": ["noise", "building", "elevator", "machine"],
"correctAnswer": "noise",
"explanation": "Noise refers to unwanted or loud sounds",
"grammarFocus": "housing-vocabulary"
},
{
"sentence": "The bus ___ is right outside",
"options": ["stop", "building", "town", "sidewalk"],
"correctAnswer": "stop",
"explanation": "'Bus stop' is the place where buses pick up passengers",
"grammarFocus": "location"
},
{
"sentence": "I'm wearing ___ because it's cold",
"options": ["gloves", "shorts", "sandals", "sunglasses"],
"correctAnswer": "gloves",
"explanation": "Gloves keep your hands warm in cold weather",
"grammarFocus": "clothing-vocabulary"
},
{
"sentence": "She looks ___ about the test",
"options": ["worried", "exciting", "convenience", "building"],
"correctAnswer": "worried",
"explanation": "Worried describes feeling anxious or concerned",
"grammarFocus": "emotions"
},
{
"sentence": "I need a new ___ for my job interview",
"options": ["suit", "jeans", "shorts", "sneakers"],
"correctAnswer": "suit",
"explanation": "A suit is formal clothing appropriate for interviews",
"grammarFocus": "clothing-vocabulary"
},
{
"sentence": "My ___ are tired from walking all day",
"options": ["feet", "hands", "eyes", "ears"],
"correctAnswer": "feet",
"explanation": "Feet are used for walking",
"grammarFocus": "body-parts"
}
],
"corrections": [
{
"correct": "I'm wearing a blue shirt",
"incorrect": "I'm wearing blue shirt",
"explanation": "We need the article 'a' before singular countable nouns",
"grammarFocus": "articles"
},
{
"correct": "There is a lot of noise",
"incorrect": "There are a lot of noise",
"grammarFocus": "subject-verb-agreement",
"explanation": "'Noise' is uncountable, so we use 'is' not 'are'"
},
{
"correct": "I feel happy today",
"incorrect": "I feel happily today",
"explanation": "After 'feel', we use an adjective (happy) not an adverb (happily)",
"grammarFocus": "adjectives-adverbs"
},
{
"correct": "She is wearing a dress",
"incorrect": "She wearing a dress",
"explanation": "We need the verb 'is' in present continuous tense",
"grammarFocus": "present-continuous"
},
{
"correct": "I need to check my email",
"incorrect": "I need check my email",
"explanation": "After 'need', we use 'to' + infinitive verb",
"grammarFocus": "infinitives"
},
{
"correct": "Do you have internet access?",
"incorrect": "Are you have internet access?",
"explanation": "We use 'do' not 'are' for questions with the verb 'have'",
"grammarFocus": "questions"
},
{
"correct": "My fingers hurt",
"incorrect": "My fingers hurts",
"explanation": "Plural subjects take plural verbs (hurt, not hurts)",
"grammarFocus": "subject-verb-agreement"
},
{
"correct": "The apartment is convenient",
"incorrect": "The apartment is convenience",
"explanation": "We need the adjective 'convenient', not the noun 'convenience'",
"grammarFocus": "adjectives-nouns"
},
{
"correct": "I live in the center of town",
"incorrect": "I live in center of town",
"explanation": "We need 'the' before 'center' when talking about a specific location",
"grammarFocus": "articles"
},
{
"correct": "Are you hungry?",
"incorrect": "Do you hungry?",
"explanation": "We use 'are' with adjectives, not 'do'",
"grammarFocus": "questions"
}
],
"statistics": { "statistics": {
"vocabulary_count": 67, "vocabulary_count": 67,
"phrases_count": 10, "phrases_count": 10,
"dialogs_count": 2, "dialogs_count": 2,
"exercises_count": 2, "exercises_count": 2,
"fillInBlanks_count": 15,
"corrections_count": 10,
"estimated_completion_time": 25 "estimated_completion_time": 25
} }
} }

View File

@ -43,32 +43,6 @@
<main id="app-main" class="app-main"> <main id="app-main" class="app-main">
<!-- Content will be rendered here by modules --> <!-- Content will be rendered here by modules -->
</main> </main>
<!-- Debug panel (only shown in debug mode) -->
<div id="debug-panel" class="debug-panel" style="display: none;">
<div class="debug-header">
<h3>Debug Panel</h3>
<button id="debug-toggle" class="debug-toggle">×</button>
</div>
<div class="debug-content">
<div id="debug-status"></div>
<div id="debug-events"></div>
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #ccc;">
<button id="run-integration-tests" onclick="runDRSTests()"
style="background: #28a745; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; width: 100%; margin-bottom: 8px;">
🧪 Run Integration Tests
</button>
<button id="run-uiux-tests" onclick="runUIUXTests()"
style="background: #6f42c1; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; width: 100%; margin-bottom: 8px;">
🎨 Run UI/UX Tests
</button>
<button id="run-e2e-tests" onclick="runE2ETests()"
style="background: #fd7e14; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; width: 100%;">
🎬 Run E2E Scenarios
</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
@ -151,14 +125,6 @@
if (appContainer) appContainer.style.display = 'block'; if (appContainer) appContainer.style.display = 'block';
// Smart Preview Orchestrator is automatically initialized by Application.js // Smart Preview Orchestrator is automatically initialized by Application.js
// Show debug panel if enabled
const status = app.getStatus();
if (status.config.enableDebug) {
const debugPanel = document.getElementById('debug-panel');
if (debugPanel) debugPanel.style.display = 'block';
updateDebugInfo();
}
}, 'Bootstrap'); }, 'Bootstrap');
// Handle navigation events // Handle navigation events
@ -411,9 +377,11 @@
<h3>${game.metadata.name}</h3> <h3>${game.metadata.name}</h3>
<p class="game-description">${game.metadata.description}</p> <p class="game-description">${game.metadata.description}</p>
<div class="game-meta"> <div class="game-meta">
<span class="difficulty-badge difficulty-${game.metadata.difficulty}"> ${typeof game.metadata.difficulty === 'string' ? `
${game.metadata.difficulty} <span class="difficulty-badge difficulty-${game.metadata.difficulty}">
</span> ${game.metadata.difficulty}
</span>
` : ''}
<span class="compatibility-score ${game.compatibility.score >= 0.7 ? 'high' : game.compatibility.score >= 0.3 ? 'medium' : 'low'}"> <span class="compatibility-score ${game.compatibility.score >= 0.7 ? 'high' : game.compatibility.score >= 0.3 ? 'medium' : 'low'}">
${Math.round(game.compatibility.score * 100)}% compatible ${Math.round(game.compatibility.score * 100)}% compatible
</span> </span>
@ -585,9 +553,6 @@
// Set up keyboard shortcuts after app is ready // Set up keyboard shortcuts after app is ready
setupKeyboardShortcuts(); setupKeyboardShortcuts();
// Set up debug panel after app is ready
setupDebugPanel();
} }
// Tooltip functions - Make them global // Tooltip functions - Make them global
@ -644,32 +609,6 @@
} }
} }
function updateDebugInfo() {
const debugStatus = document.getElementById('debug-status');
if (debugStatus && app) {
const status = app.getStatus();
debugStatus.innerHTML = `
<h4>System Status</h4>
<ul>
<li>Running: ${status.isRunning}</li>
<li>Uptime: ${Math.round(status.uptime / 1000)}s</li>
<li>Loaded Modules: ${status.modules.loaded.length}</li>
</ul>
`;
}
}
function setupDebugPanel() {
const debugToggle = document.getElementById('debug-toggle');
const debugPanel = document.getElementById('debug-panel');
if (debugToggle && debugPanel) {
debugToggle.addEventListener('click', () => {
debugPanel.style.display = 'none';
});
}
}
function setupKeyboardShortcuts() { function setupKeyboardShortcuts() {
document.addEventListener('keydown', (event) => { document.addEventListener('keydown', (event) => {
try { try {
@ -729,9 +668,24 @@
} }
}; };
window.navigateToChapters = function(bookId) { window.navigateToChapters = function(chapterIdOrBookId) {
try { try {
const { router } = app.getCore(); const { router } = app.getCore();
// If a chapterId is passed (e.g., "sbs-7-8"), extract the bookId from it
// If a bookId is passed (e.g., "sbs"), use it directly
let bookId = chapterIdOrBookId;
// Check if window.currentBookId is already set (most reliable)
if (window.currentBookId) {
bookId = window.currentBookId;
} else if (chapterIdOrBookId && chapterIdOrBookId.includes('-')) {
// Extract bookId from chapterId (e.g., "sbs-7-8" -> "sbs")
const parts = chapterIdOrBookId.split('-');
bookId = parts[0];
}
console.log(`📚 Navigating to chapters for book: ${bookId}`);
router.navigate(`/chapters/${bookId || ''}`); router.navigate(`/chapters/${bookId || ''}`);
} catch (error) { } catch (error) {
console.error('Navigation error:', error); console.error('Navigation error:', error);

View File

@ -791,7 +791,7 @@ class VocabularyModule extends DRSExerciseInterface {
} }
} }
_speakWord(text, options = {}) { async _speakWord(text, options = {}) {
// Check if browser supports Speech Synthesis // Check if browser supports Speech Synthesis
if ('speechSynthesis' in window) { if ('speechSynthesis' in window) {
try { try {
@ -807,8 +807,8 @@ class VocabularyModule extends DRSExerciseInterface {
utterance.pitch = options.pitch || 1; utterance.pitch = options.pitch || 1;
utterance.volume = options.volume || 1; utterance.volume = options.volume || 1;
// Try to find a suitable voice for the language // Wait for voices to be loaded before selecting one
const voices = window.speechSynthesis.getVoices(); const voices = await this._getVoices();
if (voices.length > 0) { if (voices.length > 0) {
// Find voice matching the chapter language // Find voice matching the chapter language
const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN" const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
@ -819,6 +819,8 @@ class VocabularyModule extends DRSExerciseInterface {
if (matchingVoice) { if (matchingVoice) {
utterance.voice = matchingVoice; utterance.voice = matchingVoice;
console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang); console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang);
} else {
console.warn(`🔊 No voice found for language: ${chapterLanguage}, available:`, voices.map(v => v.lang));
} }
} }
@ -851,6 +853,40 @@ class VocabularyModule extends DRSExerciseInterface {
} }
} }
/**
* Get available speech synthesis voices, waiting for them to load if necessary
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
* @private
*/
_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);
});
}
_updateTTSButton(isPlaying) { _updateTTSButton(isPlaying) {
// Update main TTS button // Update main TTS button
const ttsBtn = document.getElementById('tts-btn'); const ttsBtn = document.getElementById('tts-btn');

View File

@ -122,7 +122,7 @@ export class PhysicsEngine {
static checkCollisions(mario, gameState, callbacks) { static checkCollisions(mario, gameState, callbacks) {
const { const {
platforms, questionBlocks, enemies, walls, catapults, platforms, questionBlocks, enemies, walls, catapults,
piranhaPlants, boulders piranhaPlants, boulders, flyingEyes
} = gameState; } = gameState;
const { const {
@ -165,41 +165,43 @@ export class PhysicsEngine {
} }
}); });
// Question block collisions // Question block collisions (non-blocking - just trigger on contact)
questionBlocks.forEach(block => { questionBlocks.forEach(block => {
if (!block.hit && this.isColliding(mario, block)) { if (!block.hit && this.isColliding(mario, block)) {
// Check if Mario hit from below // Trigger question block on any contact (pass-through)
if (mario.velocityY < 0 && mario.y < block.y + block.height) { if (onQuestionBlock) onQuestionBlock(block);
if (onQuestionBlock) onQuestionBlock(block);
}
// Solid collision (treat as platform)
else if (mario.velocityY > 0) {
mario.y = block.y - mario.height;
mario.velocityY = 0;
mario.onGround = true;
}
} }
}); });
// Wall collisions // Wall collisions
walls.forEach(wall => { walls.forEach(wall => {
if (this.isColliding(mario, wall)) { if (this.isColliding(mario, wall)) {
// Side collision // Determine collision direction based on previous position
if (mario.velocityX > 0) { const overlapLeft = (mario.x + mario.width) - wall.x;
mario.x = wall.x - mario.width; const overlapRight = (wall.x + wall.width) - mario.x;
} else if (mario.velocityX < 0) { const overlapTop = (mario.y + mario.height) - wall.y;
mario.x = wall.x + wall.width; const overlapBottom = (wall.y + wall.height) - mario.y;
}
mario.velocityX = 0;
// Top/bottom collision // Find the smallest overlap to determine collision side
if (mario.velocityY > 0) { const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom);
if (minOverlap === overlapTop && mario.velocityY > 0) {
// Landing on top of wall
mario.y = wall.y - mario.height; mario.y = wall.y - mario.height;
mario.velocityY = 0; mario.velocityY = 0;
mario.onGround = true; mario.onGround = true;
} else if (mario.velocityY < 0) { } else if (minOverlap === overlapBottom && mario.velocityY < 0) {
// Hit wall from below
mario.y = wall.y + wall.height; mario.y = wall.y + wall.height;
mario.velocityY = 0; mario.velocityY = 0;
} else if (minOverlap === overlapLeft && mario.velocityX > 0) {
// Hit wall from left side
mario.x = wall.x - mario.width;
mario.velocityX = 0;
} else if (minOverlap === overlapRight && mario.velocityX < 0) {
// Hit wall from right side
mario.x = wall.x + wall.width;
mario.velocityX = 0;
} }
} }
}); });
@ -256,6 +258,36 @@ export class PhysicsEngine {
mario.onGround = true; mario.onGround = true;
} }
}); });
// Flying Eye collisions
if (flyingEyes) {
flyingEyes.forEach((eye, index) => {
// Eye position is CENTER, convert to top-left corner for collision
const eyeLeft = eye.x - eye.width / 2;
const eyeTop = eye.y - eye.height / 2;
const eyeRect = {
x: eyeLeft,
y: eyeTop,
width: eye.width,
height: eye.height
};
if (this.isColliding(mario, eyeRect)) {
// Check if Mario jumped on eye from above
if (mario.velocityY > 0 && mario.y < eyeTop + eye.height / 2) {
// Eye defeated
mario.velocityY = -8; // Bounce
flyingEyes.splice(index, 1); // Remove eye
if (onAddParticles) onAddParticles(eye.x, eye.y, '#DC143C');
console.log(`👁️ Mario defeated flying eye`);
} else {
// Mario hit by eye
console.log(`👁️ Mario hit by flying eye - restarting level`);
if (onMarioDeath) onMarioDeath();
}
}
});
}
} }
/** /**

View File

@ -47,8 +47,8 @@ export class Renderer {
// Level 6 boss elements // Level 6 boss elements
if (gameState.boss) this.renderBoss(ctx, gameState.boss); if (gameState.boss) this.renderBoss(ctx, gameState.boss);
// Castle // Castle structure (emoji-based)
if (gameState.castle) this.renderCastle(ctx, gameState.castle); if (gameState.castleStructure) this.renderCastleStructure(ctx, gameState.castleStructure);
// Finish line // Finish line
if (gameState.finishLine) this.renderFinishLine(ctx, gameState.finishLine, gameState.currentLevel); if (gameState.finishLine) this.renderFinishLine(ctx, gameState.finishLine, gameState.currentLevel);
@ -447,52 +447,30 @@ export class Renderer {
} }
/** /**
* Render castle * Render castle structure with emojis
*/ */
renderCastle(ctx, castle) { renderCastleStructure(ctx, castleStructure) {
if (!castle) return; if (!castleStructure) return;
// Main castle body // Draw castle emoji
ctx.fillStyle = '#888'; ctx.font = `${castleStructure.size}px Arial`;
ctx.fillRect(castle.x, castle.y, castle.width, castle.height); ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
castleStructure.emoji,
castleStructure.x,
castleStructure.y
);
// Towers // Draw princess emoji (if exists)
const towerWidth = 40; if (castleStructure.princess) {
const towerHeight = 80; ctx.font = `${castleStructure.princess.size}px Arial`;
ctx.fillText(
// Left tower castleStructure.princess.emoji,
ctx.fillRect(castle.x - 20, castle.y - 30, towerWidth, towerHeight); castleStructure.princess.x,
castleStructure.princess.y
// Right tower );
ctx.fillRect(castle.x + castle.width - 20, castle.y - 30, towerWidth, towerHeight); }
// Tower tops (triangular)
ctx.fillStyle = '#666';
ctx.beginPath();
ctx.moveTo(castle.x - 20, castle.y - 30);
ctx.lineTo(castle.x + 20, castle.y - 60);
ctx.lineTo(castle.x + 20, castle.y - 30);
ctx.fill();
ctx.beginPath();
ctx.moveTo(castle.x + castle.width - 20, castle.y - 30);
ctx.lineTo(castle.x + castle.width + 20, castle.y - 60);
ctx.lineTo(castle.x + castle.width + 20, castle.y - 30);
ctx.fill();
// Door
ctx.fillStyle = '#654321';
ctx.fillRect(castle.x + castle.width / 2 - 20, castle.y + castle.height - 50, 40, 50);
// Windows
ctx.fillStyle = '#FFFF00';
ctx.fillRect(castle.x + 20, castle.y + 20, 15, 20);
ctx.fillRect(castle.x + castle.width - 35, castle.y + 20, 15, 20);
// Borders
ctx.strokeStyle = '#000';
ctx.lineWidth = 2;
ctx.strokeRect(castle.x, castle.y, castle.width, castle.height);
} }
/** /**
@ -599,25 +577,150 @@ export class Renderer {
* Render debug hitboxes (for development) * Render debug hitboxes (for development)
*/ */
renderDebugHitboxes(ctx, gameState) { renderDebugHitboxes(ctx, gameState) {
ctx.strokeStyle = '#FF00FF'; ctx.lineWidth = 2;
ctx.lineWidth = 1;
// Mario hitbox // Mario hitbox (GREEN)
ctx.strokeStyle = '#00FF00';
ctx.strokeRect(gameState.mario.x, gameState.mario.y, gameState.mario.width, gameState.mario.height); ctx.strokeRect(gameState.mario.x, gameState.mario.y, gameState.mario.width, gameState.mario.height);
// Enemy hitboxes // Mario position info
ctx.fillStyle = '#00FF00';
ctx.font = 'bold 10px monospace';
ctx.fillText(`M: ${Math.floor(gameState.mario.x)},${Math.floor(gameState.mario.y)}`,
gameState.mario.x, gameState.mario.y - 5);
// Enemy hitboxes (RED)
if (gameState.enemies) { if (gameState.enemies) {
gameState.enemies.forEach(enemy => { ctx.strokeStyle = '#FF0000';
gameState.enemies.forEach((enemy, idx) => {
ctx.strokeRect(enemy.x, enemy.y, enemy.width, enemy.height); ctx.strokeRect(enemy.x, enemy.y, enemy.width, enemy.height);
ctx.fillStyle = '#FF0000';
ctx.fillText(`E${idx}: ${enemy.type || 'goomba'}`, enemy.x, enemy.y - 5);
}); });
} }
// Platform hitboxes // Platform hitboxes (CYAN)
if (gameState.platforms) { if (gameState.platforms) {
gameState.platforms.forEach(platform => { ctx.strokeStyle = '#00FFFF';
gameState.platforms.forEach((platform, idx) => {
ctx.strokeRect(platform.x, platform.y, platform.width, platform.height); ctx.strokeRect(platform.x, platform.y, platform.width, platform.height);
// Only show info for floating platforms (not ground)
if (platform.type !== 'ground') {
ctx.fillStyle = '#00FFFF';
ctx.fillText(`P${idx}: ${platform.type}`, platform.x, platform.y - 5);
}
}); });
} }
// Question block hitboxes (YELLOW)
if (gameState.questionBlocks) {
ctx.strokeStyle = '#FFFF00';
gameState.questionBlocks.forEach((block, idx) => {
ctx.strokeRect(block.x, block.y, block.width, block.height);
ctx.fillStyle = '#FFFF00';
ctx.fillText(`Q${idx}: ${block.hit ? 'HIT' : 'ACTIVE'}`, block.x, block.y - 5);
});
}
// Wall hitboxes (ORANGE)
if (gameState.walls) {
ctx.strokeStyle = '#FF8800';
gameState.walls.forEach((wall, idx) => {
ctx.strokeRect(wall.x, wall.y, wall.width, wall.height);
ctx.fillStyle = '#FF8800';
ctx.fillText(`W${idx}: ${Math.floor(wall.height)}h`, wall.x, wall.y - 5);
});
}
// Boss hitboxes (MAGENTA)
if (gameState.boss) {
ctx.strokeStyle = '#FF00FF';
ctx.strokeRect(gameState.boss.x, gameState.boss.y, gameState.boss.width, gameState.boss.height);
// Boss knee hitboxes (smaller boxes)
if (gameState.boss.leftKnee) {
ctx.strokeStyle = '#FF00FF';
ctx.strokeRect(
gameState.boss.leftKnee.x,
gameState.boss.leftKnee.y,
gameState.boss.leftKnee.width,
gameState.boss.leftKnee.height
);
ctx.fillStyle = '#FF00FF';
ctx.fillText('LEFT KNEE', gameState.boss.leftKnee.x, gameState.boss.leftKnee.y - 5);
}
if (gameState.boss.rightKnee) {
ctx.strokeStyle = '#FF00FF';
ctx.strokeRect(
gameState.boss.rightKnee.x,
gameState.boss.rightKnee.y,
gameState.boss.rightKnee.width,
gameState.boss.rightKnee.height
);
ctx.fillStyle = '#FF00FF';
ctx.fillText('RIGHT KNEE', gameState.boss.rightKnee.x, gameState.boss.rightKnee.y - 5);
}
ctx.fillStyle = '#FF00FF';
ctx.fillText(`BOSS HP: ${gameState.boss.health}/${gameState.boss.maxHealth}`,
gameState.boss.x, gameState.boss.y - 5);
}
// Flying eyes hitboxes (PINK)
if (gameState.flyingEyes) {
ctx.strokeStyle = '#FF69B4';
gameState.flyingEyes.forEach((eye, idx) => {
// Eye position is CENTER - draw rectangle hitbox as it's used in collision
const eyeLeft = eye.x - eye.width / 2;
const eyeTop = eye.y - eye.height / 2;
// Draw rectangle hitbox (actual collision box)
ctx.strokeRect(eyeLeft, eyeTop, eye.width, eye.height);
// Draw center point
ctx.fillStyle = '#FF69B4';
ctx.fillRect(eye.x - 2, eye.y - 2, 4, 4);
ctx.fillText(`EYE${idx}: ${eye.isChasing ? 'CHASE' : 'IDLE'}`, eyeLeft, eyeTop - 5);
});
}
// Projectiles hitboxes (RED)
if (gameState.projectiles) {
ctx.strokeStyle = '#FF4444';
gameState.projectiles.forEach((proj, idx) => {
ctx.beginPath();
ctx.arc(proj.x, proj.y, proj.radius, 0, Math.PI * 2);
ctx.stroke();
});
}
// Boulders hitboxes (GRAY)
if (gameState.boulders) {
ctx.strokeStyle = '#888888';
gameState.boulders.forEach((boulder, idx) => {
ctx.beginPath();
ctx.arc(boulder.x, boulder.y, boulder.radius, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = '#888888';
ctx.fillText(`B${idx}: ${boulder.hasLanded ? 'LANDED' : 'FLYING'}`,
boulder.x - 20, boulder.y - boulder.radius - 5);
});
}
// Debug info overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(10, 50, 200, 80);
ctx.fillStyle = '#00FF00';
ctx.font = 'bold 12px monospace';
ctx.fillText('DEBUG MODE (D to toggle)', 15, 65);
ctx.fillText(`Mario: ${Math.floor(gameState.mario.x)}, ${Math.floor(gameState.mario.y)}`, 15, 80);
ctx.fillText(`Velocity: ${gameState.mario.velocityX.toFixed(1)}, ${gameState.mario.velocityY.toFixed(1)}`, 15, 95);
ctx.fillText(`Ground: ${gameState.mario.onGround ? 'YES' : 'NO'}`, 15, 110);
ctx.fillText(`Facing: ${gameState.mario.facing}`, 15, 125);
} }
} }

View File

@ -68,8 +68,10 @@ export class FlyingEye {
const currentTime = Date.now(); const currentTime = Date.now();
eyes.forEach(eye => { eyes.forEach(eye => {
// Calculate distance from eye center to Mario center
const marioCenter = { x: mario.x + mario.width / 2, y: mario.y + mario.height / 2 };
const distanceToMario = Math.sqrt( const distanceToMario = Math.sqrt(
Math.pow(eye.x - mario.x, 2) + Math.pow(eye.y - mario.y, 2) Math.pow(eye.x - marioCenter.x, 2) + Math.pow(eye.y - marioCenter.y, 2)
); );
// Blinking animation // Blinking animation
@ -100,9 +102,9 @@ export class FlyingEye {
eye.dashDuration = 30; // 30 frames of dash eye.dashDuration = 30; // 30 frames of dash
eye.lastDashTime = currentTime; eye.lastDashTime = currentTime;
// Set dash velocity towards Mario // Set dash velocity towards Mario center
const dx = mario.x - eye.x; const dx = marioCenter.x - eye.x;
const dy = mario.y - eye.y; const dy = marioCenter.y - eye.y;
const distance = Math.sqrt(dx * dx + dy * dy); const distance = Math.sqrt(dx * dx + dy * dy);
eye.velocityX = (dx / distance) * eye.dashSpeed; eye.velocityX = (dx / distance) * eye.dashSpeed;
eye.velocityY = (dy / distance) * eye.dashSpeed; eye.velocityY = (dy / distance) * eye.dashSpeed;
@ -116,9 +118,9 @@ export class FlyingEye {
eye.x += eye.velocityX; eye.x += eye.velocityX;
eye.y += eye.velocityY; eye.y += eye.velocityY;
} else if (eye.isChasing) { } else if (eye.isChasing) {
// Chase Mario // Chase Mario center
const dx = mario.x - eye.x; const dx = marioCenter.x - eye.x;
const dy = mario.y - eye.y; const dy = marioCenter.y - eye.y;
const distance = Math.sqrt(dx * dx + dy * dy); const distance = Math.sqrt(dx * dx + dy * dy);
eye.velocityX = (dx / distance) * eye.chaseSpeed; eye.velocityX = (dx / distance) * eye.chaseSpeed;
@ -139,6 +141,7 @@ export class FlyingEye {
} }
// Keep eyes within bounds (with some margin) // Keep eyes within bounds (with some margin)
// Allow eyes to fly anywhere but not too close to edges
if (eye.x < 50) { if (eye.x < 50) {
eye.x = 50; eye.x = 50;
eye.velocityX = Math.abs(eye.velocityX); eye.velocityX = Math.abs(eye.velocityX);
@ -147,8 +150,9 @@ export class FlyingEye {
eye.y = 50; eye.y = 50;
eye.velocityY = Math.abs(eye.velocityY); eye.velocityY = Math.abs(eye.velocityY);
} }
if (eye.y > 400) { // Allow eyes to go much lower (near ground level) - 600px is just above ground (640)
eye.y = 400; if (eye.y > 600) {
eye.y = 600;
eye.velocityY = -Math.abs(eye.velocityY); eye.velocityY = -Math.abs(eye.velocityY);
} }
}); });
@ -162,11 +166,15 @@ export class FlyingEye {
*/ */
static checkCollision(mario, eyes) { static checkCollision(mario, eyes) {
for (const eye of eyes) { for (const eye of eyes) {
// Simple rectangle collision // Eye position is CENTER, convert to top-left corner for collision
if (mario.x < eye.x + eye.width && const eyeLeft = eye.x - eye.width / 2;
mario.x + mario.width > eye.x && const eyeTop = eye.y - eye.height / 2;
mario.y < eye.y + eye.height &&
mario.y + mario.height > eye.y) { // Rectangle collision with centered eye position
if (mario.x < eyeLeft + eye.width &&
mario.x + mario.width > eyeLeft &&
mario.y < eyeTop + eye.height &&
mario.y + mario.height > eyeTop) {
return eye; return eye;
} }
} }

View File

@ -72,14 +72,47 @@ class AdventureReader extends Module {
*/ */
static getCompatibilityScore(content) { static getCompatibilityScore(content) {
const vocab = content?.vocabulary || {}; const vocab = content?.vocabulary || {};
const sentences = content?.sentences || [];
const stories = content?.story?.chapters || content?.texts || [];
const dialogues = content?.dialogues || []; const dialogues = content?.dialogues || [];
const stories = content?.story?.chapters || content?.texts || [];
const vocabCount = Object.keys(vocab).length; const vocabCount = Object.keys(vocab).length;
const sentenceCount = sentences.length;
const storyCount = stories.length;
const dialogueCount = dialogues.length; const dialogueCount = dialogues.length;
const storyCount = stories.length;
// Count sentences from ALL possible sources (matching _extractSentences logic)
let sentenceCount = 0;
// From story chapters
if (content?.story?.chapters) {
content.story.chapters.forEach(chapter => {
if (chapter.sentences) {
sentenceCount += chapter.sentences.filter(s => s.original && s.translation).length;
}
});
}
// From direct sentences array
if (content?.sentences) {
sentenceCount += content.sentences.length;
}
// From phrases (array or object format)
if (content?.phrases) {
if (Array.isArray(content.phrases)) {
sentenceCount += content.phrases.filter(p => p.chinese && p.english).length;
} else if (typeof content.phrases === 'object') {
sentenceCount += Object.keys(content.phrases).length;
}
}
// From lessons
if (content?.lessons) {
content.lessons.forEach(lesson => {
if (lesson.sentences) {
sentenceCount += lesson.sentences.filter(s => s.chinese && s.english).length;
}
});
}
const totalContent = vocabCount + sentenceCount + storyCount + dialogueCount; const totalContent = vocabCount + sentenceCount + storyCount + dialogueCount;
@ -93,18 +126,26 @@ class AdventureReader extends Module {
}; };
} }
// Calculate weighted score based on content diversity // Calculate weighted score based on content diversity and quantity
let score = 0; let score = 0;
if (vocabCount > 0) score += Math.min(vocabCount / 10, 0.3);
if (sentenceCount > 0) score += Math.min(sentenceCount / 10, 0.3); // Vocabulary: 0.3 points max (reach 100% at 8+ items)
if (storyCount > 0) score += Math.min(storyCount / 5, 0.2); if (vocabCount > 0) score += Math.min(vocabCount / 8, 1) * 0.3;
if (dialogueCount > 0) score += Math.min(dialogueCount / 3, 0.2);
// Sentences: 0.4 points max (reach 100% at 8+ items) - most important for gameplay
if (sentenceCount > 0) score += Math.min(sentenceCount / 8, 1) * 0.4;
// Stories: 0.15 points max (reach 100% at 3+ items)
if (storyCount > 0) score += Math.min(storyCount / 3, 1) * 0.15;
// Dialogues: 0.15 points max (reach 100% at 3+ items)
if (dialogueCount > 0) score += Math.min(dialogueCount / 3, 1) * 0.15;
return { return {
score: Math.min(score, 1), score: Math.min(score, 1),
reason: `Adventure content: ${vocabCount} vocab, ${sentenceCount} sentences, ${storyCount} stories, ${dialogueCount} dialogues`, reason: `Adventure content: ${vocabCount} vocab, ${sentenceCount} sentences, ${storyCount} stories, ${dialogueCount} dialogues`,
requirements: ['vocabulary', 'sentences', 'stories', 'dialogues'], requirements: ['vocabulary', 'sentences', 'stories', 'dialogues'],
optimalContent: { vocab: 10, sentences: 10, stories: 5, dialogues: 3 }, optimalContent: { vocab: 8, sentences: 8, stories: 3, dialogues: 3 },
details: `Rich adventure content with ${totalContent} total elements` details: `Rich adventure content with ${totalContent} total elements`
}; };
} }
@ -135,12 +176,16 @@ class AdventureReader extends Module {
// Initialize game interface // Initialize game interface
this._createGameInterface(); this._createGameInterface();
this._initializePlayer();
this._setupEventListeners(); // Wait for DOM to render before initializing player
this._updateContentInfo(); requestAnimationFrame(() => {
this._generateGameObjects(); this._initializePlayer();
this._generateDecorations(); this._setupEventListeners();
this._startGameLoop(); this._updateContentInfo();
this._generateGameObjects();
this._generateDecorations();
this._startGameLoop();
});
// Start the game // Start the game
this._gameStartTime = Date.now(); this._gameStartTime = Date.now();
@ -246,6 +291,8 @@ class AdventureReader extends Module {
_extractSentences() { _extractSentences() {
let sentences = []; let sentences = [];
console.log('AdventureReader: Extracting sentences from content', this._content);
// Support for Dragon's Pearl structure // Support for Dragon's Pearl structure
if (this._content.story?.chapters) { if (this._content.story?.chapters) {
this._content.story.chapters.forEach(chapter => { this._content.story.chapters.forEach(chapter => {
@ -268,13 +315,61 @@ class AdventureReader extends Module {
if (this._content.sentences) { if (this._content.sentences) {
this._content.sentences.forEach(sentence => { this._content.sentences.forEach(sentence => {
sentences.push({ sentences.push({
original_language: sentence.english || sentence.original_language, original_language: sentence.english || sentence.original_language || sentence.target_language,
user_language: sentence.chinese || sentence.french || sentence.user_language || sentence.translation, user_language: sentence.chinese || sentence.french || sentence.user_language || sentence.translation,
pronunciation: sentence.pronunciation || sentence.prononciation pronunciation: sentence.pronunciation || sentence.prononciation
}); });
}); });
} }
// Support for LEDU format with phrases/lessons
if (this._content.phrases) {
// Check if phrases is an array or object
if (Array.isArray(this._content.phrases)) {
this._content.phrases.forEach(phrase => {
if (phrase.chinese && phrase.english) {
sentences.push({
original_language: phrase.chinese,
user_language: phrase.english,
pronunciation: phrase.pinyin
});
}
});
} else if (typeof this._content.phrases === 'object') {
// Handle object format (key-value pairs)
Object.entries(this._content.phrases).forEach(([phraseText, phraseData]) => {
const translation = typeof phraseData === 'object' ? phraseData.user_language : phraseData;
const pronunciation = typeof phraseData === 'object' ? phraseData.pronunciation : undefined;
if (phraseText && translation) {
sentences.push({
original_language: phraseText,
user_language: translation,
pronunciation: pronunciation
});
}
});
}
}
// Support for lessons with sentences
if (this._content.lessons) {
this._content.lessons.forEach(lesson => {
if (lesson.sentences) {
lesson.sentences.forEach(sentence => {
if (sentence.chinese && sentence.english) {
sentences.push({
original_language: sentence.chinese,
user_language: sentence.english,
pronunciation: sentence.pinyin
});
}
});
}
});
}
console.log('AdventureReader: Extracted sentences:', sentences.length);
return sentences.filter(s => s.original_language && s.user_language); return sentences.filter(s => s.original_language && s.user_language);
} }
@ -364,14 +459,14 @@ class AdventureReader extends Module {
} }
.stat-icon { .stat-icon {
font-size: 1.2rem; font-size: 0.72rem; /* 1.2 / 1.66 */
} }
.progress-info { .progress-info {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
padding: 8px 15px; padding: 8px 15px;
border-radius: 15px; border-radius: 15px;
font-size: 0.9rem; font-size: 0.54rem; /* 0.9 / 1.66 */
} }
.game-map { .game-map {
@ -384,7 +479,7 @@ class AdventureReader extends Module {
.player { .player {
position: absolute; position: absolute;
font-size: 2.5rem; font-size: 1.51rem; /* 2.5 / 1.66 */
z-index: 50; z-index: 50;
transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.3)); filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.3));
@ -393,7 +488,7 @@ class AdventureReader extends Module {
.pot, .enemy { .pot, .enemy {
position: absolute; position: absolute;
font-size: 2rem; font-size: 1.2rem; /* 2 / 1.66 */
cursor: pointer; cursor: pointer;
z-index: 30; z-index: 30;
transition: all 0.3s ease; transition: all 0.3s ease;
@ -443,12 +538,12 @@ class AdventureReader extends Module {
} }
.instructions { .instructions {
font-size: 0.9rem; font-size: 0.54rem; /* 0.9 / 1.66 */
opacity: 0.9; opacity: 0.9;
} }
.content-summary { .content-summary {
font-size: 0.85rem; font-size: 0.51rem; /* 0.85 / 1.66 */
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
padding: 8px 12px; padding: 8px 12px;
border-radius: 8px; border-radius: 8px;
@ -459,7 +554,7 @@ class AdventureReader extends Module {
border: none; border: none;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.54rem; /* 0.9 / 1.66 */
font-weight: 500; font-weight: 500;
transition: all 0.3s ease; transition: all 0.3s ease;
} }
@ -531,7 +626,7 @@ class AdventureReader extends Module {
.modal-header h3 { .modal-header h3 {
margin: 0; margin: 0;
font-size: 1.3rem; font-size: 0.78rem; /* 1.3 / 1.66 */
} }
.modal-body { .modal-body {
@ -560,7 +655,7 @@ class AdventureReader extends Module {
} }
.original-text { .original-text {
font-size: 1.4rem; font-size: 0.84rem; /* 1.4 / 1.66 */
font-weight: 600; font-weight: 600;
color: #1f2937; color: #1f2937;
margin-bottom: 15px; margin-bottom: 15px;
@ -568,14 +663,14 @@ class AdventureReader extends Module {
} }
.translation-text { .translation-text {
font-size: 1.1rem; font-size: 0.66rem; /* 1.1 / 1.66 */
color: #6b7280; color: #6b7280;
margin-bottom: 10px; margin-bottom: 10px;
line-height: 1.3; line-height: 1.3;
} }
.pronunciation-text { .pronunciation-text {
font-size: 1rem; font-size: 0.60rem; /* 1.0 / 1.66 */
color: #7c3aed; color: #7c3aed;
font-style: italic; font-style: italic;
} }
@ -601,19 +696,19 @@ class AdventureReader extends Module {
} }
.vocab-word { .vocab-word {
font-size: 2rem; font-size: 1.2rem; /* 2.0 / 1.66 */
font-weight: bold; font-weight: bold;
margin-bottom: 10px; margin-bottom: 10px;
} }
.vocab-translation { .vocab-translation {
font-size: 1.3rem; font-size: 0.78rem; /* 1.3 / 1.66 */
margin-bottom: 10px; margin-bottom: 10px;
opacity: 0.9; opacity: 0.9;
} }
.vocab-pronunciation { .vocab-pronunciation {
font-size: 1rem; font-size: 0.60rem; /* 1.0 / 1.66 */
opacity: 0.8; opacity: 0.8;
font-style: italic; font-style: italic;
} }
@ -1042,9 +1137,6 @@ class AdventureReader extends Module {
<!-- Sentence content --> <!-- Sentence content -->
</div> </div>
</div> </div>
<div class="modal-footer">
<button class="control-btn primary" id="continue-btn">Continue Adventure </button>
</div>
</div> </div>
</div> </div>
@ -1084,7 +1176,6 @@ class AdventureReader extends Module {
_setupEventListeners() { _setupEventListeners() {
// Control buttons // Control buttons
document.getElementById('restart-btn').addEventListener('click', () => this._restart()); document.getElementById('restart-btn').addEventListener('click', () => this._restart());
document.getElementById('continue-btn').addEventListener('click', () => this._closeModal());
// Exit button // Exit button
const exitButton = document.getElementById('exit-adventure'); const exitButton = document.getElementById('exit-adventure');
@ -1199,7 +1290,7 @@ class AdventureReader extends Module {
y: position.y, y: position.y,
defeated: false, defeated: false,
moveDirection: Math.random() * Math.PI * 2, moveDirection: Math.random() * Math.PI * 2,
speed: 0.6 + Math.random() * 0.6, speed: 1.2 + Math.random() * 1.2, // 2x faster (was 0.6 + 0.6)
pattern: pattern, pattern: pattern,
patrolStartX: position.x, patrolStartX: position.x,
patrolStartY: position.y, patrolStartY: position.y,
@ -1209,7 +1300,8 @@ class AdventureReader extends Module {
circleAngle: Math.random() * Math.PI * 2, circleAngle: Math.random() * Math.PI * 2,
changeDirectionTimer: 0, changeDirectionTimer: 0,
dashCooldown: 0, dashCooldown: 0,
isDashing: false isDashing: false,
dashDuration: 0
}; };
} }
@ -1257,7 +1349,7 @@ class AdventureReader extends Module {
const position = this._getDecorationPosition(mapWidth, mapHeight, 60); const position = this._getDecorationPosition(mapWidth, mapHeight, 60);
tree.style.left = position.x + 'px'; tree.style.left = position.x + 'px';
tree.style.top = position.y + 'px'; tree.style.top = position.y + 'px';
tree.style.fontSize = (25 + Math.random() * 15) + 'px'; tree.style.fontSize = ((25 + Math.random() * 15) / 1.66) + 'px'; // Reduced by 1.66
gameMap.appendChild(tree); gameMap.appendChild(tree);
} }
@ -1273,7 +1365,7 @@ class AdventureReader extends Module {
const position = this._getDecorationPosition(mapWidth, mapHeight, 30); const position = this._getDecorationPosition(mapWidth, mapHeight, 30);
grass.style.left = position.x + 'px'; grass.style.left = position.x + 'px';
grass.style.top = position.y + 'px'; grass.style.top = position.y + 'px';
grass.style.fontSize = (15 + Math.random() * 8) + 'px'; grass.style.fontSize = ((15 + Math.random() * 8) / 1.66) + 'px'; // Reduced by 1.66
gameMap.appendChild(grass); gameMap.appendChild(grass);
} }
@ -1288,7 +1380,7 @@ class AdventureReader extends Module {
const position = this._getDecorationPosition(mapWidth, mapHeight, 40); const position = this._getDecorationPosition(mapWidth, mapHeight, 40);
rock.style.left = position.x + 'px'; rock.style.left = position.x + 'px';
rock.style.top = position.y + 'px'; rock.style.top = position.y + 'px';
rock.style.fontSize = (20 + Math.random() * 10) + 'px'; rock.style.fontSize = ((20 + Math.random() * 10) / 1.66) + 'px'; // Reduced by 1.66
gameMap.appendChild(rock); gameMap.appendChild(rock);
} }
@ -1334,6 +1426,8 @@ class AdventureReader extends Module {
_startGameLoop() { _startGameLoop() {
const animate = () => { const animate = () => {
if (this._isDestroyed) return; // Stop animation if game is destroyed
if (!this._isGamePaused) { if (!this._isGamePaused) {
this._moveEnemies(); this._moveEnemies();
} }
@ -1344,6 +1438,8 @@ class AdventureReader extends Module {
_moveEnemies() { _moveEnemies() {
const gameMap = document.getElementById('game-map'); const gameMap = document.getElementById('game-map');
if (!gameMap) return; // Exit if game map doesn't exist
const mapRect = gameMap.getBoundingClientRect(); const mapRect = gameMap.getBoundingClientRect();
const mapWidth = mapRect.width; const mapWidth = mapRect.width;
const mapHeight = mapRect.height; const mapHeight = mapRect.height;
@ -1366,6 +1462,15 @@ class AdventureReader extends Module {
enemy.element.style.left = enemy.x + 'px'; enemy.element.style.left = enemy.x + 'px';
enemy.element.style.top = enemy.y + 'px'; enemy.element.style.top = enemy.y + 'px';
// Add red shadow effect during dash
if (enemy.isDashing) {
enemy.element.style.filter = 'drop-shadow(0 0 10px rgba(255, 0, 0, 0.8)) drop-shadow(0 0 20px rgba(255, 0, 0, 0.5))';
enemy.element.style.transform = 'scale(1.1)'; // Slightly larger during dash
} else {
enemy.element.style.filter = '';
enemy.element.style.transform = '';
}
this._checkPlayerEnemyCollision(enemy); this._checkPlayerEnemyCollision(enemy);
}); });
} }
@ -1401,10 +1506,43 @@ class AdventureReader extends Module {
this._player.y - enemy.y, this._player.y - enemy.y,
this._player.x - enemy.x this._player.x - enemy.x
); );
enemy.moveDirection = angleToPlayer + (Math.random() - 0.5) * 0.3; const distanceToPlayer = Math.sqrt(
Math.pow(this._player.x - enemy.x, 2) + Math.pow(this._player.y - enemy.y, 2)
);
enemy.x += Math.cos(enemy.moveDirection) * (enemy.speed * 0.8); // Decrease dash cooldown
enemy.y += Math.sin(enemy.moveDirection) * (enemy.speed * 0.8); if (enemy.dashCooldown > 0) {
enemy.dashCooldown--;
}
// Trigger dash if close enough and cooldown is ready
if (!enemy.isDashing && enemy.dashCooldown <= 0 && distanceToPlayer < 300 && distanceToPlayer > 80) {
enemy.isDashing = true;
enemy.dashDuration = 30; // 30 frames of dash
enemy.dashCooldown = 120; // 120 frames cooldown (~2 seconds)
// Choose perpendicular direction (90° or -90° randomly)
const perpendicularOffset = Math.random() < 0.5 ? Math.PI / 2 : -Math.PI / 2;
enemy.dashAngle = angleToPlayer + perpendicularOffset;
}
// Handle dashing (perpendicular to player direction - evasive maneuver)
if (enemy.isDashing) {
// Use stored dash angle (perpendicular to player at dash start)
enemy.moveDirection = enemy.dashAngle;
enemy.x += Math.cos(enemy.dashAngle) * (enemy.speed * 3.5); // 3.5x speed during dash
enemy.y += Math.sin(enemy.dashAngle) * (enemy.speed * 3.5);
enemy.dashDuration--;
if (enemy.dashDuration <= 0) {
enemy.isDashing = false;
}
} else {
// Normal chase movement
enemy.moveDirection = angleToPlayer + (Math.random() - 0.5) * 0.3;
enemy.x += Math.cos(enemy.moveDirection) * (enemy.speed * 0.8);
enemy.y += Math.sin(enemy.moveDirection) * (enemy.speed * 0.8);
}
break; break;
case 'wander': case 'wander':
@ -1629,11 +1767,25 @@ class AdventureReader extends Module {
modal.style.display = 'flex'; modal.style.display = 'flex';
modal.classList.add('show'); modal.classList.add('show');
// Calculate reading time based on text length and TTS
const textLength = sentence.original_language.length;
// Average reading speed: ~5 chars/second at 0.8 rate
// Add base delay of 800ms (600ms initial + 200ms buffer)
const ttsDelay = 600; // Initial delay before TTS starts
const readingTime = (textLength / 5) * 1000; // Characters to milliseconds
const bufferTime = 500; // Extra buffer after TTS ends
const totalTime = ttsDelay + readingTime + bufferTime;
if (this._config.autoPlayTTS && this._config.ttsEnabled) { if (this._config.autoPlayTTS && this._config.ttsEnabled) {
setTimeout(() => { setTimeout(() => {
this._speakText(sentence.original_language, { rate: 0.8 }); this._speakText(sentence.original_language, { rate: 0.8 });
}, 600); }, ttsDelay);
} }
// Auto-close modal after TTS completes
setTimeout(() => {
this._closeModal();
}, totalTime);
} }
_closeModal() { _closeModal() {
@ -1642,6 +1794,9 @@ class AdventureReader extends Module {
setTimeout(() => { setTimeout(() => {
modal.style.display = 'none'; modal.style.display = 'none';
this._isGamePaused = false; this._isGamePaused = false;
// Grant 1 second invulnerability after closing reading modal
this._grantPostReadingInvulnerability();
}, 300); }, 300);
this._checkGameComplete(); this._checkGameComplete();
@ -1705,7 +1860,8 @@ class AdventureReader extends Module {
} }
_checkPlayerEnemyCollision(enemy) { _checkPlayerEnemyCollision(enemy) {
if (this._isPlayerInvulnerable || enemy.defeated) return; // Skip collision check during pause (reading), invulnerability, or defeated enemy
if (this._isGamePaused || this._isPlayerInvulnerable || enemy.defeated) return;
const distance = Math.sqrt( const distance = Math.sqrt(
Math.pow(this._player.x - enemy.x, 2) + Math.pow(this._player.y - enemy.y, 2) Math.pow(this._player.x - enemy.x, 2) + Math.pow(this._player.y - enemy.y, 2)
@ -1729,6 +1885,7 @@ class AdventureReader extends Module {
this._isPlayerInvulnerable = true; this._isPlayerInvulnerable = true;
const playerElement = document.getElementById('player'); const playerElement = document.getElementById('player');
// Blinking animation (visual only)
let blinkCount = 0; let blinkCount = 0;
const blinkInterval = setInterval(() => { const blinkInterval = setInterval(() => {
playerElement.style.opacity = playerElement.style.opacity === '0.3' ? '1' : '0.3'; playerElement.style.opacity = playerElement.style.opacity === '0.3' ? '1' : '0.3';
@ -1738,10 +1895,14 @@ class AdventureReader extends Module {
clearInterval(blinkInterval); clearInterval(blinkInterval);
playerElement.style.opacity = '1'; playerElement.style.opacity = '1';
playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))'; playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))';
this._isPlayerInvulnerable = false;
} }
}, 250); }, 250);
// Actual invulnerability duration (independent of blink animation)
this._invulnerabilityTimeout = setTimeout(() => {
this._isPlayerInvulnerable = false;
}, 2000); // 2 seconds of actual invulnerability
this._showDamagePopup(); this._showDamagePopup();
} }
@ -1763,6 +1924,23 @@ class AdventureReader extends Module {
this._showInvulnerabilityPopup(); this._showInvulnerabilityPopup();
} }
_grantPostReadingInvulnerability() {
this._isPlayerInvulnerable = true;
const playerElement = document.getElementById('player');
if (this._invulnerabilityTimeout) {
clearTimeout(this._invulnerabilityTimeout);
}
// Brief blue glow to indicate post-reading protection
playerElement.style.filter = 'drop-shadow(0 0 10px rgba(100, 150, 255, 0.8)) brightness(1.2)';
this._invulnerabilityTimeout = setTimeout(() => {
playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))';
this._isPlayerInvulnerable = false;
}, 1000); // 1 second protection
}
_refreshAttackInvulnerability() { _refreshAttackInvulnerability() {
if (this._invulnerabilityTimeout) { if (this._invulnerabilityTimeout) {
clearTimeout(this._invulnerabilityTimeout); clearTimeout(this._invulnerabilityTimeout);

View File

@ -521,12 +521,12 @@ class FillTheBlank extends Module {
const points = 10 * blanks.length; const points = 10 * blanks.length;
this._score += points; this._score += points;
this._showFeedback(`🎉 Perfect! +${points} points`, 'success'); this._showFeedback(`🎉 Perfect! +${points} points`, 'success');
this._speakWord(blanks[0].answer); // Pronounce first blank word
setTimeout(() => { // Read the complete sentence
this._speakSentence(this._currentExercise.original, () => {
this._currentIndex++; this._currentIndex++;
this._loadNextExercise(); this._loadNextExercise();
}, 1500); });
} else { } else {
this._errors++; this._errors++;
if (correctCount > 0) { if (correctCount > 0) {
@ -574,42 +574,106 @@ class FillTheBlank extends Module {
input.classList.add('revealed'); input.classList.add('revealed');
}); });
this._showFeedback('📖 Answers revealed! Next exercise...', 'info'); this._showFeedback('📖 Answers revealed!', 'info');
setTimeout(() => { // Read the complete sentence before moving on
this._speakSentence(this._currentExercise.original, () => {
this._currentIndex++; this._currentIndex++;
this._loadNextExercise(); this._loadNextExercise();
}, 2000); });
} }
_speakWord(word) { async _speakSentence(sentence, callback) {
if (!window.speechSynthesis || !word) return; if (!window.speechSynthesis || !sentence) {
if (callback) setTimeout(callback, 1500);
return;
}
try { try {
window.speechSynthesis.cancel(); window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(word); // For predefined exercises, replace underscores with actual answers
utterance.lang = 'en-US'; let textToSpeak = sentence;
utterance.rate = 0.9; if (this._currentExercise && this._currentExercise.type === 'predefined') {
// Replace each _______ with the correct answer
this._currentExercise.blanks.forEach(blank => {
textToSpeak = textToSpeak.replace('_______', blank.answer);
});
}
const utterance = new SpeechSynthesisUtterance(textToSpeak);
const targetLanguage = this._content?.language || 'en-US';
utterance.lang = targetLanguage;
utterance.rate = 0.8;
utterance.pitch = 1.0; utterance.pitch = 1.0;
utterance.volume = 1.0; utterance.volume = 1.0;
const voices = window.speechSynthesis.getVoices(); const voices = await this._getVoices();
const langPrefix = targetLanguage.split('-')[0];
const preferredVoice = voices.find(v => const preferredVoice = voices.find(v =>
v.lang.startsWith('en') && v.lang.startsWith(langPrefix) && v.default
(v.name.includes('Google') || v.name.includes('Neural') || v.name.includes('Microsoft')) ) || voices.find(v => v.lang.startsWith(langPrefix));
);
if (preferredVoice) { if (preferredVoice) {
utterance.voice = preferredVoice; utterance.voice = preferredVoice;
console.log(`🔊 Using voice: ${preferredVoice.name} (${preferredVoice.lang})`);
} else {
console.warn(`🔊 No voice found for: ${targetLanguage}, available:`, voices.map(v => v.lang));
} }
// Call callback when speech ends
utterance.onend = () => {
if (callback) {
setTimeout(callback, 500); // Small delay after speech
}
};
utterance.onerror = () => {
console.warn('Speech synthesis error');
if (callback) setTimeout(callback, 1500);
};
window.speechSynthesis.speak(utterance); window.speechSynthesis.speak(utterance);
} catch (error) { } catch (error) {
console.warn('Speech synthesis failed:', error); console.warn('Speech synthesis failed:', error);
if (callback) setTimeout(callback, 1500);
} }
} }
/**
* Get available speech synthesis voices, waiting for them to load if necessary
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
* @private
*/
_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);
});
}
_showFeedback(message, type = 'info') { _showFeedback(message, type = 'info') {
const feedbackArea = document.getElementById('feedback-area'); const feedbackArea = document.getElementById('feedback-area');
if (feedbackArea) { if (feedbackArea) {

View File

@ -1727,7 +1727,7 @@ class FlashcardLearning extends Module {
} }
// Audio System // Audio System
_playAudio(text) { async _playAudio(text) {
if ('speechSynthesis' in window) { if ('speechSynthesis' in window) {
// Cancel any ongoing speech // Cancel any ongoing speech
speechSynthesis.cancel(); speechSynthesis.cancel();
@ -1742,7 +1742,7 @@ class FlashcardLearning extends Module {
utterance.volume = 1.0; utterance.volume = 1.0;
// Try to find a suitable voice for the language // Try to find a suitable voice for the language
const voices = speechSynthesis.getVoices(); const voices = await this._getVoices();
if (voices.length > 0) { if (voices.length > 0) {
// Find voice matching the chapter language // Find voice matching the chapter language
const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN" const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
@ -1753,6 +1753,8 @@ class FlashcardLearning extends Module {
if (matchingVoice) { if (matchingVoice) {
utterance.voice = matchingVoice; utterance.voice = matchingVoice;
console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang); console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang);
} else {
console.warn(`🔊 No voice found for: ${chapterLanguage}, available:`, voices.map(v => v.lang));
} }
} }
@ -1760,6 +1762,40 @@ class FlashcardLearning extends Module {
} }
} }
/**
* Get available speech synthesis voices, waiting for them to load if necessary
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
* @private
*/
_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);
});
}
_highlightPronunciation() { _highlightPronunciation() {
// Highlight pronunciation when TTS is played // Highlight pronunciation when TTS is played
const pronunciation = document.getElementById('pronunciation-display'); const pronunciation = document.getElementById('pronunciation-display');

View File

@ -333,19 +333,39 @@ class GrammarDiscovery extends Module {
if (Array.isArray(content.vocabulary)) { if (Array.isArray(content.vocabulary)) {
vocabulary = content.vocabulary.slice(0, 10); // Limit to first 10 vocabulary = content.vocabulary.slice(0, 10); // Limit to first 10
} else if (content.vocabulary && typeof content.vocabulary === 'object') { } else if (content.vocabulary && typeof content.vocabulary === 'object') {
vocabulary = Object.entries(content.vocabulary).slice(0, 10).map(([word, data]) => ({ vocabulary = Object.entries(content.vocabulary).slice(0, 10).map(([word, data]) => {
chinese: word, // Extract translation properly from different formats
english: data.english || data.translation || data, let translation;
pronunciation: data.pronunciation || data.prononciation || '', if (typeof data === 'string') {
text: word translation = data;
})); } else if (typeof data === 'object') {
translation = data.english || data.translation || data.user_language;
// If still an object, extract the user_language property
if (typeof translation === 'object' && translation.user_language) {
translation = translation.user_language;
}
}
return {
chinese: word,
english: translation || word,
pronunciation: (typeof data === 'object' ? (data.pronunciation || data.prononciation) : '') || '',
text: word
};
});
} }
vocabulary.forEach(item => { vocabulary.forEach(item => {
if (typeof item === 'object') { if (typeof item === 'object') {
// Extract translation - handle both string and object formats
let translation = item.english || item.translation;
if (typeof translation === 'object') {
translation = translation.english || translation.translation || JSON.stringify(translation);
}
examples.push({ examples.push({
chinese: item.chinese || item.text || item.word, chinese: item.chinese || item.text || item.word,
english: item.english || item.translation, english: translation || `Translation for: ${item.chinese || item.text || item.word}`,
pronunciation: item.pronunciation || item.prononciation || '', pronunciation: item.pronunciation || item.prononciation || '',
explanation: `Practice word: ${item.chinese || item.text || item.word}` explanation: `Practice word: ${item.chinese || item.text || item.word}`
}); });
@ -370,19 +390,41 @@ class GrammarDiscovery extends Module {
if (Array.isArray(content.vocabulary)) { if (Array.isArray(content.vocabulary)) {
vocabulary = content.vocabulary.slice(0, 5); vocabulary = content.vocabulary.slice(0, 5);
} else if (content.vocabulary && typeof content.vocabulary === 'object') { } else if (content.vocabulary && typeof content.vocabulary === 'object') {
vocabulary = Object.entries(content.vocabulary).slice(0, 5).map(([word, data]) => ({ vocabulary = Object.entries(content.vocabulary).slice(0, 5).map(([word, data]) => {
chinese: word, // Extract translation properly from different formats
english: data.english || data.translation || data, let translation;
text: word if (typeof data === 'string') {
})); translation = data;
} else if (typeof data === 'object') {
translation = data.english || data.translation || data.user_language;
// If still an object, extract the user_language property
if (typeof translation === 'object' && translation.user_language) {
translation = translation.user_language;
}
}
return {
chinese: word,
english: translation || word,
pronunciation: (typeof data === 'object' ? (data.pronunciation || data.prononciation) : '') || '',
text: word
};
});
} }
vocabulary.forEach(item => { vocabulary.forEach(item => {
if (typeof item === 'object') { if (typeof item === 'object') {
const word = item.chinese || item.text || item.word; const word = item.chinese || item.text || item.word;
// Extract translation - handle both string and object formats
let translation = item.english || item.translation;
if (typeof translation === 'object') {
translation = translation.english || translation.translation || JSON.stringify(translation);
}
examples.push({ examples.push({
chinese: word, chinese: word,
english: item.english || item.translation || `Formation example: ${word}`, english: translation || `Formation example: ${word}`,
pronunciation: item.pronunciation || item.prononciation || '', pronunciation: item.pronunciation || item.prononciation || '',
explanation: `Analyze the structure and formation of: ${word}` explanation: `Analyze the structure and formation of: ${word}`
}); });

View File

@ -69,6 +69,7 @@ class MarioEducational extends Module {
this._particles = []; this._particles = [];
this._walls = []; this._walls = [];
this._castleStructure = null; this._castleStructure = null;
this._finishLine = null;
// Advanced level elements // Advanced level elements
this._piranhaPlants = []; this._piranhaPlants = [];
@ -115,6 +116,9 @@ class MarioEducational extends Module {
// UI elements (need to be declared before seal) // UI elements (need to be declared before seal)
this._uiOverlay = null; this._uiOverlay = null;
// Debug mode (toggle with 'D' key)
this._debugMode = false;
Object.seal(this); Object.seal(this);
} }
@ -140,63 +144,60 @@ class MarioEducational extends Module {
*/ */
static getCompatibilityScore(content) { static getCompatibilityScore(content) {
const sentences = content?.sentences || []; const sentences = content?.sentences || [];
const vocab = content?.vocabulary || {}; const phrases = content?.phrases || {};
const dialogs = content?.dialogs || {};
const texts = content?.texts || []; const texts = content?.texts || [];
const story = content?.story || ''; const story = content?.story || '';
const vocabCount = Object.keys(vocab).length; let totalSentences = sentences.length;
const sentenceCount = sentences.length;
// Count sentences from texts and story // Count phrases (SBS format)
let extraSentenceCount = 0; totalSentences += Object.keys(phrases).length;
if (story && typeof story === 'string') { // Count dialog lines (SBS format)
extraSentenceCount += story.split(/[.!?]+/).filter(s => s.trim().length > 0).length; Object.values(dialogs).forEach(dialog => {
} if (dialog.lines && Array.isArray(dialog.lines)) {
totalSentences += dialog.lines.length;
}
});
// Count sentences from texts
if (Array.isArray(texts)) { if (Array.isArray(texts)) {
texts.forEach(text => { texts.forEach(text => {
if (typeof text === 'string') { if (typeof text === 'string') {
extraSentenceCount += text.split(/[.!?]+/).filter(s => s.trim().length > 0).length; totalSentences += text.split(/[.!?]+/).filter(s => s.trim().length > 10).length;
} else if (text.content) { } else if (text.content) {
extraSentenceCount += text.content.split(/[.!?]+/).filter(s => s.trim().length > 0).length; totalSentences += text.content.split(/[.!?]+/).filter(s => s.trim().length > 10).length;
} }
}); });
} }
const totalSentences = sentenceCount + extraSentenceCount; // Count sentences from story
if (story && typeof story === 'string') {
totalSentences += story.split(/[.!?]+/).filter(s => s.trim().length > 10).length;
}
if (totalSentences < 3 && vocabCount < 10) { // Mario Educational requires at least 5 sentences
if (totalSentences < 5) {
return { return {
score: 0, score: 0,
reason: `Insufficient content (${totalSentences} sentences, ${vocabCount} vocabulary words)`, reason: `Insufficient sentences (${totalSentences}/5 minimum)`,
requirements: ['sentences', 'vocabulary', 'texts', 'story'], requirements: ['sentences', 'phrases', 'dialogs', 'texts', 'story'],
minSentences: 3, minSentences: 5,
minVocabulary: 10, details: 'Mario Educational needs at least 5 complete sentences from any source'
details: 'Mario Educational needs at least 3 sentences OR 10+ vocabulary words'
}; };
} }
if (vocabCount < 5) { // Good score for 10+ sentences, perfect at 30+
return { const score = Math.min(totalSentences / 30, 1);
score: 0.3,
reason: `Limited vocabulary (${vocabCount}/5 minimum)`,
requirements: ['sentences', 'vocabulary'],
minWords: 5,
details: 'Game can work with sentences but vocabulary enhances learning'
};
}
// Perfect score at 30+ sentences, good score for 10+
const score = Math.min((totalSentences + vocabCount) / 50, 1);
return { return {
score, score,
reason: `${totalSentences} sentences and ${vocabCount} vocabulary words available`, reason: `${totalSentences} sentences available`,
requirements: ['sentences', 'vocabulary', 'texts', 'story'], requirements: ['sentences', 'phrases', 'dialogs', 'texts', 'story'],
minSentences: 3, minSentences: 5,
optimalSentences: 30, optimalSentences: 30,
details: `Can create ${Math.min(totalSentences + vocabCount, 50)} question blocks from all content sources` details: `Can create ${Math.min(totalSentences, 50)} question blocks from sentence sources`
}; };
} }
@ -279,14 +280,15 @@ class MarioEducational extends Module {
// Private methods // Private methods
_extractSentences() { _extractSentences() {
const sentences = this._content.sentences || []; const sentences = this._content.sentences || [];
const vocab = this._content.vocabulary || {}; const phrases = this._content.phrases || {};
const dialogs = this._content.dialogs || {};
const texts = this._content.texts || []; const texts = this._content.texts || [];
const story = this._content.story || ''; const story = this._content.story || '';
// Combine sentences and vocabulary for questions // Combine all sentence sources
this._sentences = []; this._sentences = [];
// Add actual sentences - handle both formats // Add sentences from 'sentences' array
sentences.forEach(sentence => { sentences.forEach(sentence => {
// Format 1: Modern format with english/user_language // Format 1: Modern format with english/user_language
if (sentence.english && sentence.user_language) { if (sentence.english && sentence.user_language) {
@ -308,6 +310,34 @@ class MarioEducational extends Module {
} }
}); });
// Add phrases from 'phrases' object (SBS format)
Object.entries(phrases).forEach(([english, data]) => {
if (data.user_language) {
this._sentences.push({
type: 'phrase',
english: english,
translation: data.user_language,
context: data.context || 'phrase'
});
}
});
// Add dialog lines from 'dialogs' object (SBS format)
Object.values(dialogs).forEach(dialog => {
if (dialog.lines && Array.isArray(dialog.lines)) {
dialog.lines.forEach(line => {
if (line.text && line.user_language) {
this._sentences.push({
type: 'dialog',
english: line.text,
translation: line.user_language,
context: dialog.title || 'dialog'
});
}
});
}
});
// Extract sentences from story text // Extract sentences from story text
if (story && typeof story === 'string') { if (story && typeof story === 'string') {
const storySentences = sentenceGenerator.splitTextIntoSentences(story); const storySentences = sentenceGenerator.splitTextIntoSentences(story);
@ -348,25 +378,10 @@ class MarioEducational extends Module {
}); });
} }
// Add vocabulary as contextual sentences
Object.entries(vocab).forEach(([word, data]) => {
if (data.user_language) {
const generatedSentence = sentenceGenerator.generateSentence(word, data);
this._sentences.push({
type: 'vocabulary',
english: generatedSentence.english,
translation: generatedSentence.translation,
context: data.type || 'vocabulary',
difficulty: generatedSentence.difficulty,
wordType: generatedSentence.wordType
});
}
});
// Shuffle sentences for variety // Shuffle sentences for variety
this._sentences = this._shuffleArray(this._sentences); this._sentences = this._shuffleArray(this._sentences);
console.log(`📝 Extracted ${this._sentences.length} sentences/vocabulary for questions`); console.log(`📝 Extracted ${this._sentences.length} sentences for questions`);
} }
@ -388,6 +403,12 @@ class MarioEducational extends Module {
this._handleKeyDown = (e) => { this._handleKeyDown = (e) => {
this._keys[e.code] = true; this._keys[e.code] = true;
// Toggle debug mode with 'D' key
if (e.code === 'KeyD') {
this._debugMode = !this._debugMode;
console.log(`🐛 Debug mode: ${this._debugMode ? 'ON' : 'OFF'}`);
}
// Prevent default for game controls // Prevent default for game controls
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'Space'].includes(e.code)) { if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'Space'].includes(e.code)) {
e.preventDefault(); e.preventDefault();
@ -456,6 +477,17 @@ class MarioEducational extends Module {
// Generate floating platforms with intelligent placement // Generate floating platforms with intelligent placement
this._generateIntelligentPlatforms(level, difficulty); this._generateIntelligentPlatforms(level, difficulty);
// Level 3+ gets advanced features BEFORE enemies (so enemy spawning can avoid them)
if (index >= 2 && index <= 4) {
this._generateHoles(level, difficulty);
this._generateStairs(level, difficulty);
}
// Level 6 boss level: only stairs (no holes)
if (index === 5) {
this._generateBossStairs(level, difficulty);
}
// Generate question blocks on reachable platforms // Generate question blocks on reachable platforms
const questionCount = 3 + difficulty; const questionCount = 3 + difficulty;
const availablePlatforms = level.platforms.filter(p => p.y < this._config.canvasHeight - 100); const availablePlatforms = level.platforms.filter(p => p.y < this._config.canvasHeight - 100);
@ -533,10 +565,10 @@ class MarioEducational extends Module {
return enemyX + 10 >= hole.x && enemyX + 10 <= hole.x + hole.width; // Check center of enemy return enemyX + 10 >= hole.x && enemyX + 10 <= hole.x + hole.width; // Check center of enemy
}); });
// Check if enemy has solid ground/platform/stair support below // Enemy is spawned on a platform, so it has support by definition
const hasSolidSupport = this._hasSolidSupportBelow(enemyX, enemyY + 20, level); // Check below enemy position // No need to check hasSolidSupport - the platform we selected IS the support
if (!wouldOverlapWall && !wouldOverlapHole && hasSolidSupport) { if (!wouldOverlapWall && !wouldOverlapHole) {
// Level 2+ gets exactly ONE helmet enemy per level // 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 && i === 0; // Only first enemy can be helmet
@ -555,8 +587,10 @@ class MarioEducational extends Module {
hasHelmet: isHelmetEnemy hasHelmet: isHelmetEnemy
}); });
enemyPlaced = true; enemyPlaced = true;
console.log(`✅ Enemy ${i} placed at x=${enemyX.toFixed(0)}, y=${enemyY.toFixed(0)} on platform`);
} else { } else {
console.log(`🚫 Enemy ${i} attempt ${attempts} would overlap wall/hole/unsupported ground, retrying...`); const reason = wouldOverlapWall ? 'wall' : 'hole';
console.log(`🚫 Enemy ${i} attempt ${attempts} failed: ${reason}`);
} }
attempts++; attempts++;
} }
@ -566,17 +600,13 @@ class MarioEducational extends Module {
} }
} }
// Level 3+ gets advanced features (except level 6 boss level) // Generate piranha plants AFTER enemies (visual decoration)
if (index >= 2 && index <= 4) { if (index >= 2 && index <= 4) {
this._generateHoles(level, difficulty);
this._generateStairs(level, difficulty);
this._generatePiranhaPlants(level, difficulty); this._generatePiranhaPlants(level, difficulty);
} }
// Level 6 boss level: only stairs (no holes, limited piranha plants) // Level 6 boss level: limited piranha plants
if (index === 5) { if (index === 5) {
this._generateBossStairs(level, difficulty);
// Reduced piranha plants for boss level
if (difficulty > 3) { if (difficulty > 3) {
this._generatePiranhaPlants(level, Math.min(difficulty - 2, 2)); this._generatePiranhaPlants(level, Math.min(difficulty - 2, 2));
} }
@ -592,10 +622,10 @@ class MarioEducational extends Module {
this._generateFlyingEyes(level, difficulty); this._generateFlyingEyes(level, difficulty);
} }
// Level 6 gets colossal boss // Level 6 gets colossal boss (DISABLED)
if (index === 5) { // if (index === 5) {
this._generateColossalBoss(level, difficulty); // this._generateColossalBoss(level, difficulty);
} // }
return level; return level;
} }
@ -1527,6 +1557,13 @@ class MarioEducational extends Module {
this._walls = [...(level.walls || [])]; this._walls = [...(level.walls || [])];
this._castleStructure = level.castleStructure || null; this._castleStructure = level.castleStructure || null;
// Create finish line at level end
this._finishLine = {
x: level.endX,
y: this._config.canvasHeight - 150,
height: 150
};
// Advanced level elements // Advanced level elements
this._piranhaPlants = [...(level.piranhaPlants || [])]; this._piranhaPlants = [...(level.piranhaPlants || [])];
this._projectiles = []; // Reset projectiles each level this._projectiles = []; // Reset projectiles each level
@ -1649,7 +1686,8 @@ class MarioEducational extends Module {
walls: this._walls, walls: this._walls,
catapults: this._catapults, catapults: this._catapults,
piranhaPlants: this._piranhaPlants, piranhaPlants: this._piranhaPlants,
boulders: this._boulders boulders: this._boulders,
flyingEyes: this._flyingEyes
}; };
const callbacks = { const callbacks = {
@ -1749,7 +1787,7 @@ class MarioEducational extends Module {
this._playTTSAndAutoClose(sentence.english, overlay, progressFill); this._playTTSAndAutoClose(sentence.english, overlay, progressFill);
} }
_playTTSAndAutoClose(text, overlay, progressBar) { async _playTTSAndAutoClose(text, overlay, progressBar) {
// Calculate duration based on text length (words per minute estimation) // Calculate duration based on text length (words per minute estimation)
const words = text.split(' ').length; const words = text.split(' ').length;
const wordsPerMinute = 150; // Average speaking speed const wordsPerMinute = 150; // Average speaking speed
@ -1765,15 +1803,23 @@ class MarioEducational extends Module {
utterance.pitch = 1.0; utterance.pitch = 1.0;
utterance.volume = 0.8; utterance.volume = 0.8;
// Try to use a nice English voice // Get target language from content
const voices = speechSynthesis.getVoices(); const targetLanguage = this._content?.language || 'en-US';
const englishVoice = voices.find(voice => utterance.lang = targetLanguage;
voice.lang.startsWith('en') && (voice.name.includes('Female') || voice.name.includes('Google'))
) || voices.find(voice => voice.lang.startsWith('en'));
if (englishVoice) { // Wait for voices to be loaded before selecting one
utterance.voice = englishVoice; const voices = await this._getVoices();
console.log(`🎤 Using voice: ${englishVoice.name}`); 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})`);
} else {
console.warn(`🔊 No voice found for language: ${targetLanguage}, available:`, voices.map(v => v.lang));
} }
speechSynthesis.speak(utterance); speechSynthesis.speak(utterance);
@ -2252,6 +2298,44 @@ class MarioEducational extends Module {
console.log(`💀 Mario died! Score penalty: -${penalty}. New score: ${this._score}`); console.log(`💀 Mario died! Score penalty: -${penalty}. New score: ${this._score}`);
} }
_shuffleArray(array) {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
_getRandomSentence() {
if (this._sentences.length === 0) {
return null;
}
// Get a sentence that hasn't been used yet, or recycle if all used
const availableSentences = this._sentences.filter(
s => !this._usedSentences.includes(s)
);
if (availableSentences.length === 0) {
// All sentences used, reset the used list
this._usedSentences = [];
return this._sentences[Math.floor(Math.random() * this._sentences.length)];
}
const randomSentence = availableSentences[Math.floor(Math.random() * availableSentences.length)];
this._usedSentences.push(randomSentence);
return randomSentence;
}
_updateFlyingEyes() {
FlyingEye.update(this._flyingEyes, this._mario, () => this._restartLevel());
}
_updateBoss() {
Boss.update(this._boss, this._bossTurrets, this._bossMinions, this._mario, this._projectiles, (sound) => soundSystem.play(sound));
}
_render() { _render() {
// Build game state object for renderer // Build game state object for renderer
const gameState = { const gameState = {
@ -2268,19 +2352,53 @@ class MarioEducational extends Module {
stones: this._stones, stones: this._stones,
flyingEyes: this._flyingEyes, flyingEyes: this._flyingEyes,
boss: this._boss, boss: this._boss,
castle: this._castle, castleStructure: this._castleStructure,
finishLine: this._finishLine, finishLine: this._finishLine,
particles: this._particles, particles: this._particles,
currentLevel: this._currentLevel, currentLevel: this._currentLevel,
lives: this._lives, lives: this._lives,
score: this._score, score: this._score,
debugMode: false // Can be toggled debugMode: this._debugMode // Toggle with 'D' key during gameplay
}; };
// Delegate rendering to helper // Delegate rendering to helper
renderer.render(this._ctx, gameState, this._config); renderer.render(this._ctx, gameState, this._config);
} }
/**
* Get available speech synthesis voices, waiting for them to load if necessary
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
* @private
*/
_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);
});
}
} }
export default MarioEducational; export default MarioEducational;

View File

@ -713,7 +713,7 @@ class QuizGame extends Module {
data-pronunciation="${pronunciation}" data-pronunciation="${pronunciation}"
title="Click to hear pronunciation"> title="Click to hear pronunciation">
${optionText} ${optionText}
${pronunciation ? `<div class="option-pronunciation">[${pronunciation}]</div>` : ''} ${pronunciation ? `<div class="option-pronunciation" style="display: none;">[${pronunciation}]</div>` : ''}
</div> </div>
`; `;
}).join(''); }).join('');
@ -784,16 +784,6 @@ class QuizGame extends Module {
return; return;
} }
// Play TTS when clicking on an option
const word = optionElement.dataset.word;
const pronunciation = optionElement.dataset.pronunciation;
if (word) {
this._playAudio(word);
if (pronunciation) {
this._highlightPronunciation(optionElement);
}
}
this._isAnswering = true; this._isAnswering = true;
const question = this._questions[this._currentQuestion]; const question = this._questions[this._currentQuestion];
const selectedAnswer = optionElement.dataset.value; const selectedAnswer = optionElement.dataset.value;
@ -816,6 +806,40 @@ class QuizGame extends Module {
this._score += 100 + timeBonus; this._score += 100 + timeBonus;
} }
// Play TTS and show pronunciation
if (isCorrect) {
// For correct answer, play the clicked option
const word = optionElement.dataset.word;
const pronunciation = optionElement.dataset.pronunciation;
if (word) {
this._playAudio(word);
if (pronunciation) {
const pronunciationElement = optionElement.querySelector('.option-pronunciation');
if (pronunciationElement) {
pronunciationElement.style.display = 'block';
this._highlightPronunciation(optionElement);
}
}
}
} else {
// For incorrect answer, find and play the correct option's TTS
const correctOption = this._findCorrectOption(question.correctAnswer);
if (correctOption) {
const word = correctOption.dataset.word;
const pronunciation = correctOption.dataset.pronunciation;
if (word) {
this._playAudio(word);
if (pronunciation) {
const pronunciationElement = correctOption.querySelector('.option-pronunciation');
if (pronunciationElement) {
pronunciationElement.style.display = 'block';
this._highlightPronunciation(correctOption);
}
}
}
}
}
// Show feedback // Show feedback
this._showAnswerFeedback(isCorrect, question); this._showAnswerFeedback(isCorrect, question);
this._updateStats(); this._updateStats();
@ -831,6 +855,19 @@ class QuizGame extends Module {
}, this.name); }, this.name);
} }
_findCorrectOption(correctAnswer) {
const optionsContainer = document.getElementById('quiz-options');
if (!optionsContainer) return null;
const options = optionsContainer.querySelectorAll('.quiz-option');
for (const option of options) {
if (option.dataset.value === correctAnswer) {
return option;
}
}
return null;
}
_handleTimeout() { _handleTimeout() {
if (this._timer) { if (this._timer) {
clearInterval(this._timer); clearInterval(this._timer);
@ -991,7 +1028,7 @@ class QuizGame extends Module {
this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name); this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name);
} }
_playAudio(text) { async _playAudio(text) {
if ('speechSynthesis' in window) { if ('speechSynthesis' in window) {
// Cancel any ongoing speech // Cancel any ongoing speech
speechSynthesis.cancel(); speechSynthesis.cancel();
@ -1006,7 +1043,7 @@ class QuizGame extends Module {
utterance.volume = 1.0; utterance.volume = 1.0;
// Try to find a suitable voice for the language // Try to find a suitable voice for the language
const voices = speechSynthesis.getVoices(); const voices = await this._getVoices();
if (voices.length > 0) { if (voices.length > 0) {
// Find voice matching the chapter language // Find voice matching the chapter language
const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN" const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
@ -1017,6 +1054,8 @@ class QuizGame extends Module {
if (matchingVoice) { if (matchingVoice) {
utterance.voice = matchingVoice; utterance.voice = matchingVoice;
console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang); console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang);
} else {
console.warn(`🔊 No voice found for: ${chapterLanguage}, available:`, voices.map(v => v.lang));
} }
} }
@ -1024,6 +1063,40 @@ class QuizGame extends Module {
} }
} }
/**
* Get available speech synthesis voices, waiting for them to load if necessary
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
* @private
*/
_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);
});
}
_highlightPronunciation(optionElement) { _highlightPronunciation(optionElement) {
const pronunciation = optionElement.querySelector('.option-pronunciation'); const pronunciation = optionElement.querySelector('.option-pronunciation');

View File

@ -1,4 +1,5 @@
import Module from '../core/Module.js'; import Module from '../core/Module.js';
import { soundSystem } from '../gameHelpers/MarioEducational/SoundSystem.js';
class RiverRun extends Module { class RiverRun extends Module {
constructor(name, dependencies, config = {}) { constructor(name, dependencies, config = {}) {
@ -55,6 +56,12 @@ class RiverRun extends Module {
this._gameContainer = null; this._gameContainer = null;
this._animationFrame = null; this._animationFrame = null;
// Background music
this._audioContext = null;
this._backgroundMusicNodes = [];
this._isMusicPlaying = false;
this._musicLoopTimeout = null;
Object.seal(this); Object.seal(this);
} }
@ -108,6 +115,8 @@ class RiverRun extends Module {
this._eventBus.on('game:stop', this._handleGameStop.bind(this), this.name); this._eventBus.on('game:stop', this._handleGameStop.bind(this), this.name);
this._eventBus.on('navigation:change', this._handleNavigationChange.bind(this), this.name); this._eventBus.on('navigation:change', this._handleNavigationChange.bind(this), this.name);
soundSystem.initialize();
this._injectCSS(); this._injectCSS();
// Start game immediately // Start game immediately
@ -219,6 +228,8 @@ class RiverRun extends Module {
this._animationFrame = null; this._animationFrame = null;
} }
this._stopBackgroundMusic();
if (this._gameContainer) { if (this._gameContainer) {
this._gameContainer.innerHTML = ''; this._gameContainer.innerHTML = '';
} }
@ -332,6 +343,7 @@ class RiverRun extends Module {
this._isRunning = true; this._isRunning = true;
this._gameStartTime = Date.now(); this._gameStartTime = Date.now();
this._setNextTarget(); this._setNextTarget();
this._startBackgroundMusic();
this._gameLoop(); this._gameLoop();
console.log('River Run started!'); console.log('River Run started!');
@ -343,7 +355,29 @@ class RiverRun extends Module {
const now = Date.now(); const now = Date.now();
if (now - this._lastSpawn > this._config.spawnInterval) { if (now - this._lastSpawn > this._config.spawnInterval) {
this._spawnFloatingWord(); // Spawn multiple words based on speed: sqrt(speed) words per spawn cycle
// The decimal part is treated as probability for an additional word
const speedSqrt = Math.sqrt(this._speed);
const baseWords = Math.floor(speedSqrt);
const probability = speedSqrt - baseWords; // Decimal part (e.g., 2.7 -> 0.7)
// Always spawn at least the base number of words
let wordsToSpawn = Math.max(1, baseWords);
// Add one more word based on probability
if (Math.random() < probability) {
wordsToSpawn++;
}
for (let i = 0; i < wordsToSpawn; i++) {
// Add slight delay between each word spawn for visual variety
setTimeout(() => {
if (this._isRunning) {
this._spawnFloatingWord();
}
}, i * 100); // 100ms delay between each word
}
this._lastSpawn = now; this._lastSpawn = now;
} }
@ -392,16 +426,37 @@ class RiverRun extends Module {
const spacePadding = ' '.repeat(this._level * 2); const spacePadding = ' '.repeat(this._level * 2);
wordElement.textContent = spacePadding + word.french + spacePadding; wordElement.textContent = spacePadding + word.french + spacePadding;
wordElement.style.left = `${Math.random() * 80 + 10}%`; // More random positioning with different strategies
wordElement.style.top = '-60px'; let xPosition;
const strategy = Math.random();
if (strategy < 0.4) {
// Random across full width (with margins)
xPosition = Math.random() * 80 + 10;
} else if (strategy < 0.6) {
// Prefer left side
xPosition = Math.random() * 40 + 10;
} else if (strategy < 0.8) {
// Prefer right side
xPosition = Math.random() * 40 + 50;
} else {
// Prefer center
xPosition = Math.random() * 30 + 35;
}
// Add slight random variation to starting Y position for staggered effect
const yStart = -60 - Math.random() * 40;
wordElement.style.left = `${xPosition}%`;
wordElement.style.top = `${yStart}px`;
wordElement.wordData = word; wordElement.wordData = word;
riverCanvas.appendChild(wordElement); riverCanvas.appendChild(wordElement);
this._floatingWords.push({ this._floatingWords.push({
element: wordElement, element: wordElement,
y: -60, y: yStart,
x: parseFloat(wordElement.style.left), x: xPosition,
wordData: word wordData: word
}); });
@ -421,27 +476,35 @@ class RiverRun extends Module {
const powerUpElement = document.createElement('div'); const powerUpElement = document.createElement('div');
powerUpElement.className = 'power-up'; powerUpElement.className = 'power-up';
powerUpElement.innerHTML = '⚡'; powerUpElement.innerHTML = '⚡';
powerUpElement.style.left = `${Math.random() * 80 + 10}%`;
powerUpElement.style.top = '-40px'; // Random positioning similar to words
const xPosition = Math.random() * 80 + 10;
const yStart = -40 - Math.random() * 30;
powerUpElement.style.left = `${xPosition}%`;
powerUpElement.style.top = `${yStart}px`;
riverCanvas.appendChild(powerUpElement); riverCanvas.appendChild(powerUpElement);
this._powerUps.push({ this._powerUps.push({
element: powerUpElement, element: powerUpElement,
y: -40, y: yStart,
x: parseFloat(powerUpElement.style.left), x: xPosition,
type: 'slowTime' type: 'slowTime'
}); });
} }
_updateFloatingWords() { _updateFloatingWords() {
const riverCanvas = document.getElementById('river-canvas');
const canvasHeight = riverCanvas ? riverCanvas.offsetHeight : window.innerHeight;
this._floatingWords = this._floatingWords.filter(word => { this._floatingWords = this._floatingWords.filter(word => {
word.y += this._speed; word.y += this._speed;
word.element.style.top = `${word.y}px`; word.element.style.top = `${word.y}px`;
if (word.y > window.innerHeight + 60) { // Check if word has gone below the visible game area
if (word.wordData.french === this._currentTarget.french) { if (word.y > canvasHeight - 50) {
this._loseLife(); // Note: No longer losing life when target word escapes
} // Life loss only happens on collision with wrong words
word.element.remove(); word.element.remove();
return false; return false;
} }
@ -453,7 +516,8 @@ class RiverRun extends Module {
powerUp.y += this._speed; powerUp.y += this._speed;
powerUp.element.style.top = `${powerUp.y}px`; powerUp.element.style.top = `${powerUp.y}px`;
if (powerUp.y > window.innerHeight + 40) { // Check if power-up has gone below the visible game area
if (powerUp.y > canvasHeight - 50) {
powerUp.element.remove(); powerUp.element.remove();
return false; return false;
} }
@ -532,45 +596,61 @@ class RiverRun extends Module {
_checkCollisions() { _checkCollisions() {
const playerRect = this._getPlayerRect(); const playerRect = this._getPlayerRect();
this._floatingWords.forEach((word, index) => { // Check word collisions (iterate backwards to safely splice)
for (let i = this._floatingWords.length - 1; i >= 0; i--) {
const word = this._floatingWords[i];
// Skip if word was already collected
if (word.element.dataset.collected === 'true') continue;
const wordRect = this._getElementRect(word.element); const wordRect = this._getElementRect(word.element);
if (this._isColliding(playerRect, wordRect)) { if (this._isColliding(playerRect, wordRect)) {
this._handleWordCollision(word, index); this._handleWordCollision(word, i);
} }
}); }
this._powerUps.forEach((powerUp, index) => { // Check power-up collisions (iterate backwards to safely splice)
for (let i = this._powerUps.length - 1; i >= 0; i--) {
const powerUp = this._powerUps[i];
const powerUpRect = this._getElementRect(powerUp.element); const powerUpRect = this._getElementRect(powerUp.element);
if (this._isColliding(playerRect, powerUpRect)) { if (this._isColliding(playerRect, powerUpRect)) {
this._handlePowerUpCollision(powerUp, index); this._handlePowerUpCollision(powerUp, i);
} }
}); }
} }
_getPlayerRect() { _getPlayerRect() {
const playerElement = document.getElementById('player'); const playerElement = document.getElementById('player');
if (!playerElement) return { x: 0, y: 0, width: 0, height: 0 }; if (!playerElement) return { x: 0, y: 0, width: 0, height: 0 };
const canvas = document.getElementById('river-canvas');
if (!canvas) return { x: 0, y: 0, width: 0, height: 0 };
const rect = playerElement.getBoundingClientRect(); const rect = playerElement.getBoundingClientRect();
const canvas = document.getElementById('river-canvas').getBoundingClientRect(); const canvasRect = canvas.getBoundingClientRect();
return { return {
x: rect.left - canvas.left, x: rect.left - canvasRect.left,
y: rect.top - canvas.top, y: rect.top - canvasRect.top,
width: rect.width, width: rect.width,
height: rect.height height: rect.height
}; };
} }
_getElementRect(element) { _getElementRect(element) {
if (!element) return { x: 0, y: 0, width: 0, height: 0 };
const canvas = document.getElementById('river-canvas');
if (!canvas) return { x: 0, y: 0, width: 0, height: 0 };
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
const canvas = document.getElementById('river-canvas').getBoundingClientRect(); const canvasRect = canvas.getBoundingClientRect();
return { return {
x: rect.left - canvas.left, x: rect.left - canvasRect.left,
y: rect.top - canvas.top, y: rect.top - canvasRect.top,
width: rect.width, width: rect.width,
height: rect.height height: rect.height
}; };
@ -594,22 +674,43 @@ class RiverRun extends Module {
} }
_handleWordCollision(word, index) { _handleWordCollision(word, index) {
if (word.wordData.french === this._currentTarget.french) { // Handle collision for ALL words:
this._collectWord(word.element, true); // - TARGET word: auto-collect (points)
} else { // - WRONG word: lose life
this._missWord(word.element); if (word.wordData && this._currentTarget) {
} if (word.wordData.french === this._currentTarget.french) {
// Correct word - collect it
this._collectWord(word.element, true);
} else {
// Wrong word - lose life
this._missWord(word.element);
}
this._floatingWords.splice(index, 1); // Remove the word from the array to prevent multiple collisions
this._floatingWords.splice(index, 1);
// Also mark as collected to prevent further processing
word.element.dataset.collected = 'true';
}
} }
_collectWord(wordElement, isCorrect) { _collectWord(wordElement, isCorrect) {
wordElement.classList.add('collected'); wordElement.classList.add('collected');
if (isCorrect) { if (isCorrect) {
this._score += 10 + (this._level * 2); soundSystem.play('coin');
// Base points increased with level, multiplied by sqrt of speed
const basePoints = 10 + (this._level * 2);
const speedMultiplier = Math.sqrt(this._speed);
const pointsEarned = Math.round(basePoints * speedMultiplier);
this._score += pointsEarned;
this._wordsCollected++; this._wordsCollected++;
// Show points earned (visual feedback)
this._showPointsPopup(wordElement, pointsEarned);
this._eventBus.emit('game:score-update', { this._eventBus.emit('game:score-update', {
gameId: 'river-run', gameId: 'river-run',
score: this._score, score: this._score,
@ -626,6 +727,7 @@ class RiverRun extends Module {
} }
_missWord(wordElement) { _missWord(wordElement) {
soundSystem.play('enemy_defeat');
wordElement.classList.add('missed'); wordElement.classList.add('missed');
this._loseLife(); this._loseLife();
@ -653,24 +755,103 @@ class RiverRun extends Module {
_updateDifficulty() { _updateDifficulty() {
const timeElapsed = Date.now() - this._gameStartTime; const timeElapsed = Date.now() - this._gameStartTime;
const newLevel = Math.floor(timeElapsed / 30000) + 1; const secondsElapsed = timeElapsed / 1000;
// 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;
// Update level every 30 seconds
const newLevel = Math.floor(timeElapsed / 30000) + 1;
if (newLevel > this._level) { if (newLevel > this._level) {
this._level = newLevel; this._level = newLevel;
this._speed += 0.5; // Decrease spawn interval with each level (spawn words more frequently)
this._config.spawnInterval = Math.max(500, this._config.spawnInterval - 100); this._config.spawnInterval = Math.max(500, 1000 - (this._level - 1) * 100);
} }
} }
_playSuccessSound(word) { _showPointsPopup(wordElement, points) {
const rect = wordElement.getBoundingClientRect();
const riverCanvas = document.getElementById('river-canvas');
if (!riverCanvas) return;
const canvasRect = riverCanvas.getBoundingClientRect();
const popup = document.createElement('div');
popup.className = 'points-popup';
popup.textContent = `+${points}`;
popup.style.left = `${rect.left - canvasRect.left + rect.width / 2}px`;
popup.style.top = `${rect.top - canvasRect.top}px`;
riverCanvas.appendChild(popup);
setTimeout(() => {
popup.remove();
}, 1000);
}
async _playSuccessSound(word) {
if ('speechSynthesis' in window) { if ('speechSynthesis' in window) {
// Cancel any ongoing speech
speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(word.trim()); const utterance = new SpeechSynthesisUtterance(word.trim());
utterance.lang = 'fr-FR';
utterance.rate = 1.0; // Get language from content, fallback to zh-CN (Chinese) for vocabulary
const contentLanguage = this._content?.language || 'zh-CN';
utterance.lang = contentLanguage;
utterance.rate = 0.8;
utterance.pitch = 1.0;
utterance.volume = 1.0;
// Wait for voices to be loaded and select the best one
const voices = await this._getVoices();
if (voices.length > 0) {
const langPrefix = contentLanguage.split('-')[0];
const matchingVoice = voices.find(voice =>
voice.lang === contentLanguage
) || voices.find(voice =>
voice.lang.startsWith(langPrefix)
);
if (matchingVoice) {
utterance.voice = matchingVoice;
console.log(`🔊 RiverRun using voice: ${matchingVoice.name} (${matchingVoice.lang})`);
} else {
console.warn(`🔊 No voice found for: ${contentLanguage}`);
}
}
speechSynthesis.speak(utterance); speechSynthesis.speak(utterance);
} }
} }
_getVoices() {
return new Promise((resolve) => {
let voices = window.speechSynthesis.getVoices();
if (voices.length > 0) {
resolve(voices);
return;
}
const voicesChangedHandler = () => {
voices = window.speechSynthesis.getVoices();
if (voices.length > 0) {
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
resolve(voices);
}
};
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
setTimeout(() => {
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
resolve(window.speechSynthesis.getVoices());
}, 1000);
});
}
_loseLife() { _loseLife() {
this._lives--; this._lives--;
@ -681,6 +862,7 @@ class RiverRun extends Module {
_gameOver() { _gameOver() {
this._isRunning = false; this._isRunning = false;
this._stopBackgroundMusic();
const accuracy = this._wordsCollected > 0 ? Math.round((this._wordsCollected / (this._wordsCollected + (3 - this._lives))) * 100) : 0; const accuracy = this._wordsCollected > 0 ? Math.round((this._wordsCollected / (this._wordsCollected + (3 - this._lives))) * 100) : 0;
@ -734,6 +916,9 @@ class RiverRun extends Module {
} }
_restart() { _restart() {
// Stop any playing music before restarting
this._stopBackgroundMusic();
this._isRunning = false; this._isRunning = false;
this._score = 0; this._score = 0;
this._lives = this._config.initialLives; this._lives = this._config.initialLives;
@ -783,6 +968,123 @@ class RiverRun extends Module {
console.log('River Run restarted'); console.log('River Run restarted');
} }
_startBackgroundMusic() {
if (this._isMusicPlaying) return;
try {
// Create audio context
this._audioContext = new (window.AudioContext || window.webkitAudioContext)();
// Create master gain for volume control (quiet background music)
const masterGain = this._audioContext.createGain();
masterGain.gain.value = 0.15; // Very quiet, 15% volume
masterGain.connect(this._audioContext.destination);
// River-like pentatonic scale (C D E G A) - peaceful and flowing
const frequencies = [261.63, 293.66, 329.63, 392.00, 440.00]; // C4, D4, E4, G4, A4
// Create multiple oscillators for a richer sound
const createNote = (freq, startTime, duration, gainValue) => {
const oscillator = this._audioContext.createOscillator();
const gainNode = this._audioContext.createGain();
oscillator.type = 'sine'; // Soft sine wave
oscillator.frequency.setValueAtTime(freq, this._audioContext.currentTime);
// Envelope: fade in and fade out
gainNode.gain.setValueAtTime(0, startTime);
gainNode.gain.linearRampToValueAtTime(gainValue, startTime + 0.1);
gainNode.gain.linearRampToValueAtTime(gainValue * 0.7, startTime + duration - 0.3);
gainNode.gain.linearRampToValueAtTime(0, startTime + duration);
oscillator.connect(gainNode);
gainNode.connect(masterGain);
oscillator.start(startTime);
oscillator.stop(startTime + duration);
return { oscillator, gainNode };
};
// Create a flowing melodic pattern
const playMelody = () => {
if (!this._isMusicPlaying || !this._audioContext) return;
const now = this._audioContext.currentTime;
const noteDuration = 1.5; // Longer notes for a relaxed feel
// Play a sequence of notes with random variation (like water flowing)
for (let i = 0; i < 4; i++) {
const randomIndex = Math.floor(Math.random() * frequencies.length);
const freq = frequencies[randomIndex];
const startTime = now + (i * noteDuration);
const gainValue = 0.3 + Math.random() * 0.2; // Vary volume slightly
createNote(freq, startTime, noteDuration * 1.2, gainValue);
}
// Schedule next melody and store timeout ID
this._musicLoopTimeout = setTimeout(() => playMelody(), noteDuration * 4 * 1000);
};
// Add subtle low drone (like distant river sound)
const bassDrone = this._audioContext.createOscillator();
const bassGain = this._audioContext.createGain();
bassDrone.type = 'sine';
bassDrone.frequency.value = 65.41; // C2 - very low
bassGain.gain.value = 0.08; // Very subtle
bassDrone.connect(bassGain);
bassGain.connect(masterGain);
bassDrone.start();
this._backgroundMusicNodes.push({ oscillator: bassDrone, gainNode: bassGain });
this._isMusicPlaying = true;
// Start the melody
playMelody();
console.log('🎵 River background music started');
} catch (error) {
console.warn('Failed to start background music:', error);
}
}
_stopBackgroundMusic() {
if (!this._isMusicPlaying) return;
try {
// Clear the melody loop timeout
if (this._musicLoopTimeout) {
clearTimeout(this._musicLoopTimeout);
this._musicLoopTimeout = null;
}
// Stop all oscillators
this._backgroundMusicNodes.forEach(node => {
if (node.oscillator) {
try {
node.oscillator.stop();
} catch (e) {
// Oscillator might already be stopped
}
}
});
// Close audio context
if (this._audioContext) {
this._audioContext.close();
this._audioContext = null;
}
this._backgroundMusicNodes = [];
this._isMusicPlaying = false;
console.log('🎵 River background music stopped');
} catch (error) {
console.warn('Failed to stop background music:', error);
}
}
_shuffleArray(array) { _shuffleArray(array) {
const shuffled = [...array]; const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) { for (let i = shuffled.length - 1; i > 0; i--) {
@ -1055,6 +1357,35 @@ class RiverRun extends Module {
z-index: 30; z-index: 30;
} }
.points-popup {
position: absolute;
color: #FFD700;
font-size: 1.5em;
font-weight: bold;
pointer-events: none;
z-index: 100;
text-shadow:
0 0 10px rgba(255,215,0,0.8),
0 2px 4px rgba(0,0,0,0.5);
animation: pointsFloat 1s ease-out forwards;
transform: translate(-50%, 0);
}
@keyframes pointsFloat {
0% {
opacity: 1;
transform: translate(-50%, 0) scale(1);
}
50% {
opacity: 1;
transform: translate(-50%, -30px) scale(1.2);
}
100% {
opacity: 0;
transform: translate(-50%, -60px) scale(0.8);
}
}
.game-error { .game-error {
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
border: 2px solid #ef4444; border: 2px solid #ef4444;

View File

@ -1140,32 +1140,71 @@ class WhackAMole extends Module {
} }
} }
_speakWord(word) { async _speakWord(word) {
// Use Web Speech API to pronounce the word // Use Web Speech API to pronounce the word
if ('speechSynthesis' in window) { if ('speechSynthesis' in window) {
// Cancel any ongoing speech // Cancel any ongoing speech
speechSynthesis.cancel(); speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(word); const utterance = new SpeechSynthesisUtterance(word);
utterance.lang = 'en-US'; // English pronunciation const targetLanguage = this._content?.language || 'en-US';
utterance.lang = targetLanguage;
utterance.rate = 0.9; // Slightly slower for clarity utterance.rate = 0.9; // Slightly slower for clarity
utterance.pitch = 1.0; utterance.pitch = 1.0;
utterance.volume = 1.0; utterance.volume = 1.0;
// Try to use a good English voice // Try to use a good voice for the target language
const voices = speechSynthesis.getVoices(); const voices = await this._getVoices();
const englishVoice = voices.find(voice => const langPrefix = targetLanguage.split('-')[0];
voice.lang.startsWith('en') && (voice.name.includes('Google') || voice.name.includes('Neural')) const preferredVoice = voices.find(voice =>
) || voices.find(voice => voice.lang.startsWith('en')); voice.lang.startsWith(langPrefix) && (voice.name.includes('Google') || voice.name.includes('Neural') || voice.default)
) || voices.find(voice => voice.lang.startsWith(langPrefix));
if (englishVoice) { if (preferredVoice) {
utterance.voice = englishVoice; utterance.voice = preferredVoice;
console.log(`🔊 Using voice: ${preferredVoice.name} (${preferredVoice.lang})`);
} else {
console.warn(`🔊 No voice found for: ${targetLanguage}, available:`, voices.map(v => v.lang));
} }
speechSynthesis.speak(utterance); speechSynthesis.speak(utterance);
} }
} }
/**
* Get available speech synthesis voices, waiting for them to load if necessary
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
* @private
*/
_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) { _shuffleArray(array) {
const shuffled = [...array]; const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) { for (let i = shuffled.length - 1; i > 0; i--) {

View File

@ -1351,32 +1351,71 @@ class WhackAMoleHard extends Module {
]; ];
} }
_speakWord(word) { async _speakWord(word) {
// Use Web Speech API to pronounce the word // Use Web Speech API to pronounce the word
if ('speechSynthesis' in window) { if ('speechSynthesis' in window) {
// Cancel any ongoing speech // Cancel any ongoing speech
speechSynthesis.cancel(); speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(word); const utterance = new SpeechSynthesisUtterance(word);
utterance.lang = 'en-US'; // English pronunciation const targetLanguage = this._content?.language || 'en-US';
utterance.lang = targetLanguage;
utterance.rate = 0.9; // Slightly slower for clarity utterance.rate = 0.9; // Slightly slower for clarity
utterance.pitch = 1.0; utterance.pitch = 1.0;
utterance.volume = 1.0; utterance.volume = 1.0;
// Try to use a good English voice // Try to use a good voice for the target language
const voices = speechSynthesis.getVoices(); const voices = await this._getVoices();
const englishVoice = voices.find(voice => const langPrefix = targetLanguage.split('-')[0];
voice.lang.startsWith('en') && (voice.name.includes('Google') || voice.name.includes('Neural')) const preferredVoice = voices.find(voice =>
) || voices.find(voice => voice.lang.startsWith('en')); voice.lang.startsWith(langPrefix) && (voice.name.includes('Google') || voice.name.includes('Neural') || voice.default)
) || voices.find(voice => voice.lang.startsWith(langPrefix));
if (englishVoice) { if (preferredVoice) {
utterance.voice = englishVoice; utterance.voice = preferredVoice;
console.log(`🔊 Using voice: ${preferredVoice.name} (${preferredVoice.lang})`);
} else {
console.warn(`🔊 No voice found for: ${targetLanguage}, available:`, voices.map(v => v.lang));
} }
speechSynthesis.speak(utterance); speechSynthesis.speak(utterance);
} }
} }
/**
* Get available speech synthesis voices, waiting for them to load if necessary
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
* @private
*/
_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) { _shuffleArray(array) {
const shuffled = [...array]; const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) { for (let i = shuffled.length - 1; i > 0; i--) {

View File

@ -70,6 +70,9 @@ class WizardSpellCaster extends Module {
const sentences = content?.sentences || []; const sentences = content?.sentences || [];
const storyChapters = content?.story?.chapters || []; const storyChapters = content?.story?.chapters || [];
const dialogues = content?.dialogues || []; const dialogues = content?.dialogues || [];
const texts = content?.texts || [];
const phrases = content?.phrases || {};
const dialogs = content?.dialogs || {};
let totalSentences = sentences.length; let totalSentences = sentences.length;
@ -87,16 +90,45 @@ class WizardSpellCaster extends Module {
} }
}); });
// Count phrases (object format with key-value pairs)
if (typeof phrases === 'object') {
totalSentences += Object.keys(phrases).length;
}
// Count dialog lines (alternative spelling, object format)
if (typeof dialogs === 'object') {
Object.values(dialogs).forEach(dialog => {
if (dialog.lines && Array.isArray(dialog.lines)) {
totalSentences += dialog.lines.length;
}
});
}
// Count extractable sentences from texts (LEDU-style content)
let extractableSentences = 0;
texts.forEach(text => {
if (text.content) {
const sentencesInText = text.content.split(/[。!?\.\!\?]+/).filter(s => {
const trimmed = s.trim();
const wordCount = trimmed.split(/\s+/).length;
return trimmed && wordCount >= 3 && wordCount <= 15;
});
extractableSentences += sentencesInText.length;
}
});
totalSentences += extractableSentences;
// If we have enough sentences, use them // If we have enough sentences, use them
if (totalSentences >= 9) { if (totalSentences >= 9) {
const score = Math.min(totalSentences / 30, 1); const score = Math.min(totalSentences / 30, 1);
return { return {
score, score,
reason: `${totalSentences} sentences available for spell construction`, reason: `${totalSentences} sentences/phrases available for spell construction`,
requirements: ['sentences', 'story', 'dialogues'], requirements: ['sentences', 'story', 'dialogues', 'texts', 'phrases', 'dialogs'],
minSentences: 9, minSentences: 9,
optimalSentences: 30, optimalSentences: 30,
details: `Can create engaging spell combat with ${totalSentences} sentences` details: `Can create engaging spell combat with ${totalSentences} sentences/phrases`
}; };
} }
@ -132,11 +164,11 @@ class WizardSpellCaster extends Module {
return { return {
score: 0, score: 0,
reason: `Insufficient content (${totalSentences} sentences, ${vocabCount} vocabulary words)`, reason: `Insufficient content (${totalSentences} sentences/phrases, ${vocabCount} vocabulary words)`,
requirements: ['sentences', 'story', 'dialogues', 'vocabulary'], requirements: ['sentences', 'story', 'dialogues', 'texts', 'phrases', 'dialogs', 'vocabulary'],
minSentences: 9, minSentences: 9,
minWords: 15, minWords: 15,
details: 'Wizard Spell Caster needs at least 9 sentences or 15 vocabulary words' details: 'Wizard Spell Caster needs at least 9 sentences/phrases or 15 vocabulary words'
}; };
} }
@ -292,6 +324,60 @@ class WizardSpellCaster extends Module {
} }
}); });
} }
// Extract from phrases (key-value object format)
if (this._content.phrases && typeof this._content.phrases === 'object') {
Object.entries(this._content.phrases).forEach(([english, phraseData]) => {
const translation = typeof phraseData === 'string' ? phraseData : phraseData.user_language || phraseData.chinese;
if (english && translation) {
this._processSentence({
original: english,
translation: translation,
words: this._extractWordsFromSentence(english)
});
}
});
}
// Extract from dialogs (alternative spelling of dialogues)
if (this._content.dialogs && typeof this._content.dialogs === 'object') {
Object.values(this._content.dialogs).forEach(dialog => {
if (dialog.lines && Array.isArray(dialog.lines)) {
dialog.lines.forEach(line => {
if (line.text && line.user_language) {
this._processSentence({
original: line.text,
translation: line.user_language,
words: this._extractWordsFromSentence(line.text)
});
}
});
}
});
}
// Fallback: Extract from texts (for LEDU-style content)
if (this._getTotalSpellCount() < 9 && this._content.texts && Array.isArray(this._content.texts)) {
console.log('WizardSpellCaster: Extracting spells from texts as fallback');
this._content.texts.forEach(text => {
if (text.content) {
// Split text into sentences using Chinese punctuation and periods
const sentences = text.content.split(/[。!?\.\!\?]+/).filter(s => s.trim().length > 0);
sentences.forEach(sentence => {
const trimmed = sentence.trim();
// Only use sentences with reasonable length (3-15 words)
const wordCount = trimmed.split(/\s+/).length;
if (trimmed && wordCount >= 3 && wordCount <= 15) {
this._processSentence({
original: trimmed,
translation: trimmed, // In Chinese content, use same for both
words: this._extractWordsFromSentence(trimmed)
});
}
});
}
});
}
} }
_processSentence(sentenceData) { _processSentence(sentenceData) {
@ -371,19 +457,22 @@ class WizardSpellCaster extends Module {
style.textContent = ` style.textContent = `
.wizard-game-wrapper { .wizard-game-wrapper {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
min-height: 100vh; height: 100vh;
color: white; color: white;
font-family: 'Fantasy', serif; font-family: 'Fantasy', serif;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
display: flex;
flex-direction: column;
} }
.wizard-hud { .wizard-hud {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 15px; padding: 8px 15px;
background: rgba(0,0,0,0.3); background: rgba(0,0,0,0.3);
border-bottom: 2px solid #ffd700; border-bottom: 2px solid #ffd700;
flex-shrink: 0;
} }
.wizard-stats { .wizard-stats {
@ -393,10 +482,10 @@ class WizardSpellCaster extends Module {
} }
.health-bar { .health-bar {
width: 150px; width: 120px;
height: 20px; height: 16px;
background: rgba(255,255,255,0.2); background: rgba(255,255,255,0.2);
border-radius: 10px; border-radius: 8px;
overflow: hidden; overflow: hidden;
border: 2px solid #ffd700; border: 2px solid #ffd700;
} }
@ -409,8 +498,9 @@ class WizardSpellCaster extends Module {
.battle-area { .battle-area {
display: flex; display: flex;
height: 60vh; height: 180px;
padding: 20px; padding: 10px 20px;
flex-shrink: 0;
} }
.wizard-side { .wizard-side {
@ -430,29 +520,29 @@ class WizardSpellCaster extends Module {
} }
.wizard-character { .wizard-character {
width: 120px; width: 80px;
height: 120px; height: 80px;
background: linear-gradient(45deg, #6c5ce7, #a29bfe); background: linear-gradient(45deg, #6c5ce7, #a29bfe);
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 48px; font-size: 36px;
margin-bottom: 20px; margin-bottom: 8px;
animation: float 3s ease-in-out infinite; animation: float 3s ease-in-out infinite;
box-shadow: 0 0 30px rgba(108, 92, 231, 0.6); box-shadow: 0 0 30px rgba(108, 92, 231, 0.6);
} }
.enemy-character { .enemy-character {
width: 150px; width: 100px;
height: 150px; height: 100px;
background: linear-gradient(45deg, #ff4757, #ff6b7a); background: linear-gradient(45deg, #ff4757, #ff6b7a);
border-radius: 20px; border-radius: 20px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 64px; font-size: 48px;
margin-bottom: 20px; margin-bottom: 8px;
animation: enemyPulse 2s ease-in-out infinite; animation: enemyPulse 2s ease-in-out infinite;
box-shadow: 0 0 40px rgba(255, 71, 87, 0.6); box-shadow: 0 0 40px rgba(255, 71, 87, 0.6);
} }
@ -471,22 +561,27 @@ class WizardSpellCaster extends Module {
background: rgba(0,0,0,0.4); background: rgba(0,0,0,0.4);
border: 2px solid #ffd700; border: 2px solid #ffd700;
border-radius: 15px; border-radius: 15px;
padding: 20px; padding: 12px 15px;
margin: 20px; margin: 0 15px 10px 15px;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
} }
.spell-selection { .spell-selection {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 15px; gap: 10px;
margin-bottom: 20px; margin-bottom: 10px;
flex-shrink: 0;
} }
.spell-card { .spell-card {
background: linear-gradient(135deg, #2c2c54, #40407a); background: linear-gradient(135deg, #2c2c54, #40407a);
border: 2px solid #ffd700; border: 2px solid #ffd700;
border-radius: 10px; border-radius: 8px;
padding: 15px; padding: 10px;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
text-align: center; text-align: center;
@ -520,29 +615,32 @@ class WizardSpellCaster extends Module {
.sentence-builder { .sentence-builder {
background: rgba(255,255,255,0.1); background: rgba(255,255,255,0.1);
border-radius: 10px; border-radius: 8px;
padding: 15px; padding: 10px;
margin-bottom: 20px; margin-bottom: 10px;
min-height: 80px; min-height: 60px;
border: 2px dashed #ffd700; border: 2px dashed #ffd700;
flex-shrink: 0;
} }
.word-bank { .word-bank {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 8px;
margin-bottom: 20px; margin-bottom: 10px;
flex-shrink: 0;
} }
.word-tile { .word-tile {
background: linear-gradient(135deg, #5f27cd, #8854d0); background: linear-gradient(135deg, #5f27cd, #8854d0);
color: white; color: white;
padding: 8px 15px; padding: 6px 12px;
border-radius: 20px; border-radius: 15px;
cursor: grab; cursor: grab;
user-select: none; user-select: none;
transition: all 0.3s ease; transition: all 0.3s ease;
border: 2px solid transparent; border: 2px solid transparent;
font-size: 0.9em;
} }
.word-tile:hover { .word-tile:hover {
@ -564,14 +662,15 @@ class WizardSpellCaster extends Module {
background: linear-gradient(135deg, #ff6b7a, #ff4757); background: linear-gradient(135deg, #ff6b7a, #ff4757);
border: none; border: none;
color: white; color: white;
padding: 15px 30px; padding: 12px 25px;
border-radius: 25px; border-radius: 20px;
font-size: 18px; font-size: 16px;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; transition: all 0.3s ease;
box-shadow: 0 5px 15px rgba(255, 71, 87, 0.3); box-shadow: 0 5px 15px rgba(255, 71, 87, 0.3);
width: 100%; width: 100%;
flex-shrink: 0;
} }
.cast-button:hover { .cast-button:hover {
@ -1055,8 +1154,8 @@ class WizardSpellCaster extends Module {
</div> </div>
<div class="sentence-builder" id="sentence-builder"> <div class="sentence-builder" id="sentence-builder">
<div style="color: #ffd700; margin-bottom: 10px;">Form your spell incantation:</div> <div style="color: #ffd700; margin-bottom: 5px; font-size: 0.9em;">Form your spell incantation:</div>
<div id="current-sentence" style="font-size: 18px; min-height: 30px;"></div> <div id="current-sentence" style="font-size: 16px; min-height: 24px;"></div>
</div> </div>
<div class="word-bank" id="word-bank"> <div class="word-bank" id="word-bank">

View File

@ -14,7 +14,6 @@ class WordDiscovery extends Module {
container: null, container: null,
difficulty: 'medium', difficulty: 'medium',
practiceCount: 10, practiceCount: 10,
timerDuration: 30,
...config ...config
}; };
@ -294,16 +293,19 @@ class WordDiscovery extends Module {
</div>` : ''} </div>` : ''}
<div class="word-content"> <div class="word-content">
<h3 class="word-text">${word.word}</h3> <h3 class="word-text">
${word.word}
${word.pronunciation ? `<span class="word-pronunciation">[${word.pronunciation}]</span>` : ''}
</h3>
${word.translation ? `<p class="word-translation">${word.translation}</p>` : ''} ${word.translation ? `<p class="word-translation">${word.translation}</p>` : ''}
${word.definition ? `<p class="word-definition">${word.definition}</p>` : ''} ${word.definition ? `<p class="word-definition">${word.definition}</p>` : ''}
${word.example ? `<p class="word-example">"${word.example}"</p>` : ''} ${word.example ? `<p class="word-example">"${word.example}"</p>` : ''}
</div> </div>
<div class="word-controls"> <div class="word-controls">
${word.audio ? `<button class="audio-btn" onclick="window.wordDiscovery._playAudio('${word.word}')"> <button class="audio-btn" onclick="window.wordDiscovery._playWordSound('${word.word}')">
🔊 Listen 🔊 Listen
</button>` : ''} </button>
<button class="next-btn" onclick="window.wordDiscovery._nextWord()"> <button class="next-btn" onclick="window.wordDiscovery._nextWord()">
Next Word Next Word
</button> </button>
@ -319,6 +321,11 @@ class WordDiscovery extends Module {
`; `;
window.wordDiscovery = this; window.wordDiscovery = this;
// Auto-play TTS when word is revealed (with slight delay for better UX)
setTimeout(() => {
this._playWordSound(word.word);
}, 300);
} }
_nextWord() { _nextWord() {
@ -341,6 +348,91 @@ class WordDiscovery extends Module {
} }
} }
_playWordSound(wordText) {
// First, try to play preloaded audio file if available
const audio = this._audioElements.get(wordText);
if (audio) {
audio.currentTime = 0;
audio.play().catch(error => {
console.warn(`Failed to play audio for ${wordText}:`, error);
// Fallback to TTS if audio file fails
this._playTTS(wordText);
});
} else {
// No audio file, use TTS
this._playTTS(wordText);
}
}
async _playTTS(text) {
if ('speechSynthesis' in window) {
// Cancel any ongoing speech
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.rate = 0.9; // Slightly slower for clarity
utterance.pitch = 1.0;
utterance.volume = 1.0;
// Get target language from content
const targetLanguage = this._content?.language || 'en-US';
utterance.lang = targetLanguage;
// Wait for voices to be loaded before selecting one
const voices = await this._getVoices();
const langPrefix = targetLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
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 TTS voice: ${matchingVoice.name} (${matchingVoice.lang})`);
} else {
console.warn(`🔊 No voice found for language: ${targetLanguage}, available:`, voices.map(v => v.lang));
}
window.speechSynthesis.speak(utterance);
} else {
console.warn('Text-to-speech not supported in this browser');
}
}
/**
* Get available speech synthesis voices, waiting for them to load if necessary
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
* @private
*/
_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);
});
}
_startPracticePhase() { _startPracticePhase() {
if (this._discoveredWords.length === 0) { if (this._discoveredWords.length === 0) {
this._discoveredWords = [...this._practiceWords]; this._discoveredWords = [...this._practiceWords];
@ -357,10 +449,10 @@ class WordDiscovery extends Module {
_renderPracticeLevel() { _renderPracticeLevel() {
const levels = ['Easy', 'Medium', 'Hard', 'Expert']; const levels = ['Easy', 'Medium', 'Hard', 'Expert'];
const levelConfig = { const levelConfig = {
0: { time: 45, options: 2, type: 'translation' }, 0: { options: 4, type: 'translation' },
1: { time: 30, options: 3, type: 'mixed' }, 1: { options: 3, type: 'mixed' },
2: { time: 20, options: 4, type: 'definition' }, 2: { options: 4, type: 'definition' },
3: { time: 15, options: 4, type: 'context' } 3: { options: 4, type: 'context' }
}; };
const config = levelConfig[this._currentPracticeLevel]; const config = levelConfig[this._currentPracticeLevel];
@ -375,7 +467,6 @@ class WordDiscovery extends Module {
<span>Total: ${this._practiceTotal}</span> <span>Total: ${this._practiceTotal}</span>
<span>Accuracy: ${this._practiceTotal > 0 ? Math.round((this._practiceCorrect / this._practiceTotal) * 100) : 0}%</span> <span>Accuracy: ${this._practiceTotal > 0 ? Math.round((this._practiceCorrect / this._practiceTotal) * 100) : 0}%</span>
</div> </div>
<div class="timer">Time: <span id="timer-display">${config.time}</span></div>
</div> </div>
<div class="practice-question" id="practice-question"> <div class="practice-question" id="practice-question">
@ -394,41 +485,16 @@ class WordDiscovery extends Module {
</div> </div>
`; `;
this._timeLeft = config.time;
this._startTimer();
this._generateQuestion(config); this._generateQuestion(config);
} }
_startTimer() {
const timerDisplay = document.getElementById('timer-display');
if (!timerDisplay) return;
this._timer = setInterval(() => {
this._timeLeft--;
timerDisplay.textContent = this._timeLeft;
if (this._timeLeft <= 0) {
clearInterval(this._timer);
this._handleTimeUp();
}
}, 1000);
}
_handleTimeUp() {
this._practiceTotal++;
this._showResult(false, 'Time up!');
setTimeout(() => {
this._generateQuestion();
}, 1500);
}
_generateQuestion(config = null) { _generateQuestion(config = null) {
if (!config) { if (!config) {
const levelConfig = { const levelConfig = {
0: { time: 45, options: 2, type: 'translation' }, 0: { options: 4, type: 'translation' },
1: { time: 30, options: 3, type: 'mixed' }, 1: { options: 3, type: 'mixed' },
2: { time: 20, options: 4, type: 'definition' }, 2: { options: 4, type: 'definition' },
3: { time: 15, options: 4, type: 'context' } 3: { options: 4, type: 'context' }
}; };
config = levelConfig[this._currentPracticeLevel]; config = levelConfig[this._currentPracticeLevel];
} }
@ -482,8 +548,11 @@ class WordDiscovery extends Module {
<div class="question-content"> <div class="question-content">
<h3>What does this word mean?</h3> <h3>What does this word mean?</h3>
<div class="question-word"> <div class="question-word">
${correctWord.word} <div class="word-with-pronunciation">
${correctWord.audio ? `<button class="audio-btn-small" onclick="window.wordDiscovery._playAudio('${correctWord.word}')">🔊</button>` : ''} <span>${correctWord.word}</span>
${correctWord.pronunciation ? `<span class="question-pronunciation">[${correctWord.pronunciation}]</span>` : ''}
</div>
<button class="audio-btn-small" onclick="window.wordDiscovery._playWordSound('${correctWord.word}')">🔊</button>
</div> </div>
<div class="options-grid"> <div class="options-grid">
${this._practiceOptions.map(option => ` ${this._practiceOptions.map(option => `
@ -506,7 +575,10 @@ class WordDiscovery extends Module {
<div class="options-grid"> <div class="options-grid">
${this._practiceOptions.map(option => ` ${this._practiceOptions.map(option => `
<button class="option-btn" onclick="window.wordDiscovery._selectAnswer('${option.word}')"> <button class="option-btn" onclick="window.wordDiscovery._selectAnswer('${option.word}')">
${option.word} <div class="option-word-with-pronunciation">
<span>${option.word}</span>
${option.pronunciation ? `<span class="option-pronunciation">[${option.pronunciation}]</span>` : ''}
</div>
</button> </button>
`).join('')} `).join('')}
</div> </div>
@ -524,7 +596,10 @@ class WordDiscovery extends Module {
<div class="options-grid"> <div class="options-grid">
${this._practiceOptions.map(option => ` ${this._practiceOptions.map(option => `
<button class="option-btn" onclick="window.wordDiscovery._selectAnswer('${option.word}')"> <button class="option-btn" onclick="window.wordDiscovery._selectAnswer('${option.word}')">
${option.word} <div class="option-word-with-pronunciation">
<span>${option.word}</span>
${option.pronunciation ? `<span class="option-pronunciation">[${option.pronunciation}]</span>` : ''}
</div>
</button> </button>
`).join('')} `).join('')}
</div> </div>
@ -538,6 +613,8 @@ class WordDiscovery extends Module {
if (isCorrect) { if (isCorrect) {
this._practiceCorrect++; this._practiceCorrect++;
// Play TTS for correct answer
this._playWordSound(this._correctAnswer.word);
} }
this._showResult(isCorrect, isCorrect ? 'Correct!' : `Wrong! The answer was: ${this._correctAnswer.word}`); this._showResult(isCorrect, isCorrect ? 'Correct!' : `Wrong! The answer was: ${this._correctAnswer.word}`);
@ -649,6 +726,17 @@ class WordDiscovery extends Module {
color: #2c3e50; color: #2c3e50;
margin: 15px 0; margin: 15px 0;
font-weight: bold; font-weight: bold;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.word-pronunciation {
font-size: 0.5em;
color: #7f8c8d;
font-style: italic;
font-weight: normal;
} }
.word-translation { .word-translation {
@ -746,13 +834,6 @@ class WordDiscovery extends Module {
color: #2c3e50; color: #2c3e50;
} }
.timer {
font-size: 1.3em;
font-weight: bold;
color: #e74c3c;
margin: 10px 0;
}
.question-content { .question-content {
background: white; background: white;
border-radius: 12px; border-radius: 12px;
@ -777,6 +858,35 @@ class WordDiscovery extends Module {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px; gap: 10px;
flex-wrap: wrap;
}
.word-with-pronunciation {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.question-pronunciation {
font-size: 0.5em;
color: #7f8c8d;
font-style: italic;
font-weight: normal;
}
.option-word-with-pronunciation {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.option-pronunciation {
font-size: 0.8em;
color: #7f8c8d;
font-style: italic;
font-weight: normal;
} }
.question-definition, .question-context { .question-definition, .question-context {

View File

@ -1,4 +1,5 @@
import Module from '../core/Module.js'; import Module from '../core/Module.js';
import { soundSystem } from '../gameHelpers/MarioEducational/SoundSystem.js';
/** /**
* WordStorm - Fast-paced falling words game where players match vocabulary * WordStorm - Fast-paced falling words game where players match vocabulary
@ -18,7 +19,7 @@ class WordStorm extends Module {
this._config = { this._config = {
container: null, container: null,
maxWords: 50, maxWords: 50,
fallSpeed: 8000, // ms to fall from top to bottom fallSpeedVhPerSecond: 12.5, // % of viewport height per second (100vh in 8s = 12.5vh/s)
spawnRate: 4000, // ms between spawns spawnRate: 4000, // ms between spawns
wordLifetime: 9200, // ms before word disappears (+15% more time) wordLifetime: 9200, // ms before word disappears (+15% more time)
startingLives: 3, startingLives: 3,
@ -110,6 +111,9 @@ class WordStorm extends Module {
this._eventBus.on('game:pause', this._handlePause.bind(this), this.name); this._eventBus.on('game:pause', this._handlePause.bind(this), this.name);
this._eventBus.on('game:resume', this._handleResume.bind(this), this.name); this._eventBus.on('game:resume', this._handleResume.bind(this), this.name);
// Initialize sound system
soundSystem.initialize();
// Inject CSS // Inject CSS
this._injectCSS(); this._injectCSS();
@ -719,14 +723,51 @@ class WordStorm extends Module {
wordElement.className = 'falling-word'; wordElement.className = 'falling-word';
wordElement.textContent = word.original; wordElement.textContent = word.original;
wordElement.style.left = Math.random() * 80 + 10 + '%'; wordElement.style.left = Math.random() * 80 + 10 + '%';
wordElement.style.top = '80px'; // Start just below the HUD wordElement.style.top = '0vh'; // Start at top of viewport
gameArea.appendChild(wordElement); gameArea.appendChild(wordElement);
// Start position check for this word
const positionCheck = setInterval(() => {
if (!wordElement.parentNode) {
clearInterval(positionCheck);
return;
}
const gameArea = document.getElementById('game-area');
if (!gameArea) {
clearInterval(positionCheck);
return;
}
// Get positions using getBoundingClientRect for accuracy
const wordRect = wordElement.getBoundingClientRect();
const gameAreaRect = gameArea.getBoundingClientRect();
// Calculate word's position relative to game area
const wordTop = wordRect.top;
const wordHeight = wordRect.height;
const gameAreaBottom = gameAreaRect.bottom;
// 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) {
clearInterval(positionCheck);
if (wordElement.parentNode) {
this._missWord(wordElement);
}
}
}, 50); // Check every 50ms for smooth detection
this._fallingWords.push({ this._fallingWords.push({
element: wordElement, element: wordElement,
word: word, word: word,
startTime: Date.now() startTime: Date.now(),
positionCheck: positionCheck
}); });
// Generate new answer options when word spawns // Generate new answer options when word spawns
@ -734,19 +775,25 @@ class WordStorm extends Module {
// Animate falling // Animate falling
this._animateFalling(wordElement); this._animateFalling(wordElement);
// Remove after lifetime
setTimeout(() => {
if (wordElement.parentNode) {
this._missWord(wordElement);
}
}, this._config.wordLifetime);
} }
_animateFalling(wordElement) { _animateFalling(wordElement) {
wordElement.style.transition = `top ${this._config.fallSpeed}ms linear`; const gameArea = document.getElementById('game-area');
if (!gameArea) return;
// Calculate fall duration based on gameArea height
const gameAreaHeight = gameArea.offsetHeight;
// Calculate duration based on configured speed (vh per second)
// Convert gameArea height to vh equivalent for timing calculation
const viewportHeight = window.innerHeight;
const gameAreaHeightVh = (gameAreaHeight / viewportHeight) * 100;
const fallDurationMs = (gameAreaHeightVh / this._config.fallSpeedVhPerSecond) * 1000;
wordElement.style.transition = `top ${fallDurationMs}ms linear`;
setTimeout(() => { setTimeout(() => {
wordElement.style.top = 'calc(100vh + 60px)'; // Continue falling past screen // Animate to the bottom of the game area (use pixels for precision)
wordElement.style.top = `${gameAreaHeight}px`;
}, 50); }, 50);
} }
@ -797,6 +844,14 @@ class WordStorm extends Module {
} }
_correctAnswer(fallingWord) { _correctAnswer(fallingWord) {
// Play success sound
soundSystem.play('coin');
// Clear position check interval
if (fallingWord.positionCheck) {
clearInterval(fallingWord.positionCheck);
}
// Remove from game with epic explosion // Remove from game with epic explosion
if (fallingWord.element.parentNode) { if (fallingWord.element.parentNode) {
fallingWord.element.classList.add('exploding'); fallingWord.element.classList.add('exploding');
@ -822,11 +877,15 @@ class WordStorm extends Module {
// Remove from tracking // Remove from tracking
this._fallingWords = this._fallingWords.filter(fw => fw !== fallingWord); this._fallingWords = this._fallingWords.filter(fw => fw !== fallingWord);
// Update score // Update score and combo
this._combo++; this._combo++;
const points = 10 + (this._combo * 2); const points = 10 + (this._combo * 2);
this._score += points; this._score += points;
// Increase speed based on combo (3% per combo, max 2x speed)
const speedMultiplier = Math.min(1 + (this._combo * 0.03), 2);
this._config.fallSpeedVhPerSecond = 12.5 * speedMultiplier;
// Update display // Update display
this._updateHUD(); this._updateHUD();
@ -855,8 +914,14 @@ class WordStorm extends Module {
} }
_wrongAnswer() { _wrongAnswer() {
// Play error sound
soundSystem.play('enemy_defeat');
this._combo = 0; this._combo = 0;
// Reset speed to base when combo breaks
this._config.fallSpeedVhPerSecond = 12.5;
// Enhanced wrong answer animation // Enhanced wrong answer animation
const answerPanel = document.getElementById('answer-panel'); const answerPanel = document.getElementById('answer-panel');
if (answerPanel) { if (answerPanel) {
@ -929,6 +994,14 @@ class WordStorm extends Module {
} }
_missWord(wordElement) { _missWord(wordElement) {
// Find the falling word object
const fallingWord = this._fallingWords.find(fw => fw.element === wordElement);
// Clear position check interval
if (fallingWord && fallingWord.positionCheck) {
clearInterval(fallingWord.positionCheck);
}
// Remove word // Remove word
if (wordElement.parentNode) { if (wordElement.parentNode) {
wordElement.remove(); wordElement.remove();
@ -959,8 +1032,8 @@ class WordStorm extends Module {
_levelUp() { _levelUp() {
this._level++; this._level++;
// Increase difficulty by 5% (x1.05 speed = /1.05 time) // Increase difficulty by 5% (multiply speed by 1.05)
this._config.fallSpeed = Math.max(1000, this._config.fallSpeed / 1.05); this._config.fallSpeedVhPerSecond = Math.min(50, this._config.fallSpeedVhPerSecond * 1.05);
this._config.spawnRate = Math.max(800, this._config.spawnRate / 1.05); this._config.spawnRate = Math.max(800, this._config.spawnRate / 1.05);
// Restart intervals with new timing // Restart intervals with new timing
@ -1020,8 +1093,11 @@ class WordStorm extends Module {
this._spawnInterval = null; this._spawnInterval = null;
} }
// Clear falling words // Clear falling words and their intervals
this._fallingWords.forEach(fw => { this._fallingWords.forEach(fw => {
if (fw.positionCheck) {
clearInterval(fw.positionCheck);
}
if (fw.element.parentNode) { if (fw.element.parentNode) {
fw.element.remove(); fw.element.remove();
} }
@ -1081,7 +1157,7 @@ class WordStorm extends Module {
this._gameStartTime = Date.now(); this._gameStartTime = Date.now();
// Reset fall speed and spawn rate // Reset fall speed and spawn rate
this._config.fallSpeed = 8000; this._config.fallSpeedVhPerSecond = 12.5; // Reset to initial speed (100vh in 8s)
this._config.spawnRate = 4000; this._config.spawnRate = 4000;
// Clear existing intervals // Clear existing intervals
@ -1089,8 +1165,11 @@ class WordStorm extends Module {
clearInterval(this._spawnInterval); clearInterval(this._spawnInterval);
} }
// Clear falling words // Clear falling words and their intervals
this._fallingWords.forEach(fw => { this._fallingWords.forEach(fw => {
if (fw.positionCheck) {
clearInterval(fw.positionCheck);
}
if (fw.element.parentNode) { if (fw.element.parentNode) {
fw.element.remove(); fw.element.remove();
} }

View File

@ -232,77 +232,6 @@ p {
background-color: #c53030; background-color: #c53030;
} }
/* Debug Panel */
.debug-panel {
position: fixed;
top: 70px;
right: 20px;
width: 300px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
z-index: 1000;
font-size: 0.875rem;
}
.debug-header {
background-color: #2d3748;
color: white;
padding: 0.75rem 1rem;
border-radius: 0.5rem 0.5rem 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.debug-header h3 {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
color: white;
}
.debug-toggle {
background: none;
border: none;
color: white;
font-size: 1.25rem;
cursor: pointer;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
}
.debug-content {
padding: 1rem;
max-height: 400px;
overflow-y: auto;
}
.debug-content h4 {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: #718096;
margin-bottom: 0.5rem;
}
.debug-content ul {
list-style: none;
margin-bottom: 1rem;
}
.debug-content li {
padding: 0.25rem 0;
font-size: 0.75rem;
color: #4a5568;
border-bottom: 1px solid #f7fafc;
}
/* Responsive Design */ /* Responsive Design */
@media (max-width: 768px) { @media (max-width: 768px) {
.app-main { .app-main {
@ -326,12 +255,6 @@ p {
height: 6px; height: 6px;
} }
.debug-panel {
width: calc(100% - 40px);
right: 20px;
left: 20px;
}
.loading-screen h2 { .loading-screen h2 {
font-size: 1.25rem; font-size: 1.25rem;
} }
@ -360,8 +283,4 @@ p {
background: #000; background: #000;
color: white; color: white;
} }
.debug-panel {
border: 2px solid black;
}
} }

136
src/utils/TTSHelper.js Normal file
View File

@ -0,0 +1,136 @@
/**
* TTSHelper - Text-to-Speech utility for consistent TTS behavior across the app
* Fixes the common issue where voices aren't loaded on first call
*/
class TTSHelper {
/**
* Get available speech synthesis voices, waiting for them to load if necessary
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
*/
static 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);
});
}
/**
* Select the best voice for a given language
* @param {string} language - Language code (e.g., "zh-CN", "en-US")
* @param {SpeechSynthesisVoice[]} voices - Available voices
* @returns {SpeechSynthesisVoice|null} - Best matching voice or null
*/
static selectVoice(language, voices) {
if (!voices || voices.length === 0) return null;
const langPrefix = language.split('-')[0]; // e.g., "zh" from "zh-CN"
// Try to find:
// 1. Default voice for the exact language
// 2. Any voice for the exact language
// 3. Default voice for the language prefix
// 4. Any voice for the language prefix
const matchingVoice = voices.find(voice =>
voice.lang === language && voice.default
) || voices.find(voice =>
voice.lang === language
) || voices.find(voice =>
voice.lang.startsWith(langPrefix) && voice.default
) || voices.find(voice =>
voice.lang.startsWith(langPrefix)
);
return matchingVoice || null;
}
/**
* Speak text using TTS with proper voice selection and error handling
* @param {string} text - Text to speak
* @param {Object} options - TTS options
* @param {string} options.lang - Language code (e.g., "zh-CN", "en-US")
* @param {number} options.rate - Speech rate (default 0.8)
* @param {number} options.pitch - Speech pitch (default 1.0)
* @param {number} options.volume - Speech volume (default 1.0)
* @param {Function} options.onStart - Callback when speech starts
* @param {Function} options.onEnd - Callback when speech ends
* @param {Function} options.onError - Callback on error
* @returns {Promise<void>}
*/
static async speak(text, options = {}) {
if (!('speechSynthesis' in window)) {
console.warn('🔊 Speech Synthesis not supported in this browser');
if (options.onError) options.onError(new Error('Speech Synthesis not supported'));
return;
}
try {
// Cancel any ongoing speech
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = options.lang || 'en-US';
utterance.rate = options.rate || 0.8;
utterance.pitch = options.pitch || 1.0;
utterance.volume = options.volume || 1.0;
// Wait for voices to be loaded before selecting one
const voices = await TTSHelper.getVoices();
const selectedVoice = TTSHelper.selectVoice(utterance.lang, voices);
if (selectedVoice) {
utterance.voice = selectedVoice;
console.log(`🔊 Using voice: ${selectedVoice.name} (${selectedVoice.lang})`);
} else {
console.warn(`🔊 No voice found for language: ${utterance.lang}, using default`);
}
// Add event handlers
utterance.onstart = () => {
console.log('🔊 TTS started for:', text);
if (options.onStart) options.onStart();
};
utterance.onend = () => {
console.log('🔊 TTS finished for:', text);
if (options.onEnd) options.onEnd();
};
utterance.onerror = (event) => {
console.warn('🔊 TTS error:', event.error);
if (options.onError) options.onError(event);
};
// Speak the text
window.speechSynthesis.speak(utterance);
} catch (error) {
console.warn('🔊 TTS failed:', error);
if (options.onError) options.onError(error);
}
}
}
export default TTSHelper;