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": {
"vocabulary_count": 67,
"phrases_count": 10,
"dialogs_count": 2,
"exercises_count": 2,
"fillInBlanks_count": 15,
"corrections_count": 10,
"estimated_completion_time": 25
}
}

View File

@ -43,32 +43,6 @@
<main id="app-main" class="app-main">
<!-- Content will be rendered here by modules -->
</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>
@ -151,14 +125,6 @@
if (appContainer) appContainer.style.display = 'block';
// 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');
// Handle navigation events
@ -411,9 +377,11 @@
<h3>${game.metadata.name}</h3>
<p class="game-description">${game.metadata.description}</p>
<div class="game-meta">
<span class="difficulty-badge difficulty-${game.metadata.difficulty}">
${game.metadata.difficulty}
</span>
${typeof game.metadata.difficulty === 'string' ? `
<span class="difficulty-badge difficulty-${game.metadata.difficulty}">
${game.metadata.difficulty}
</span>
` : ''}
<span class="compatibility-score ${game.compatibility.score >= 0.7 ? 'high' : game.compatibility.score >= 0.3 ? 'medium' : 'low'}">
${Math.round(game.compatibility.score * 100)}% compatible
</span>
@ -585,9 +553,6 @@
// Set up keyboard shortcuts after app is ready
setupKeyboardShortcuts();
// Set up debug panel after app is ready
setupDebugPanel();
}
// 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() {
document.addEventListener('keydown', (event) => {
try {
@ -729,9 +668,24 @@
}
};
window.navigateToChapters = function(bookId) {
window.navigateToChapters = function(chapterIdOrBookId) {
try {
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 || ''}`);
} catch (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
if ('speechSynthesis' in window) {
try {
@ -807,8 +807,8 @@ class VocabularyModule extends DRSExerciseInterface {
utterance.pitch = options.pitch || 1;
utterance.volume = options.volume || 1;
// Try to find a suitable voice for the language
const voices = window.speechSynthesis.getVoices();
// Wait for voices to be loaded before selecting one
const voices = await this._getVoices();
if (voices.length > 0) {
// Find voice matching the chapter language
const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
@ -819,6 +819,8 @@ class VocabularyModule extends DRSExerciseInterface {
if (matchingVoice) {
utterance.voice = matchingVoice;
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) {
// Update main TTS button
const ttsBtn = document.getElementById('tts-btn');

View File

@ -122,7 +122,7 @@ export class PhysicsEngine {
static checkCollisions(mario, gameState, callbacks) {
const {
platforms, questionBlocks, enemies, walls, catapults,
piranhaPlants, boulders
piranhaPlants, boulders, flyingEyes
} = gameState;
const {
@ -165,41 +165,43 @@ export class PhysicsEngine {
}
});
// Question block collisions
// Question block collisions (non-blocking - just trigger on contact)
questionBlocks.forEach(block => {
if (!block.hit && this.isColliding(mario, block)) {
// Check if Mario hit from below
if (mario.velocityY < 0 && mario.y < block.y + block.height) {
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;
}
// Trigger question block on any contact (pass-through)
if (onQuestionBlock) onQuestionBlock(block);
}
});
// Wall collisions
walls.forEach(wall => {
if (this.isColliding(mario, wall)) {
// Side collision
if (mario.velocityX > 0) {
mario.x = wall.x - mario.width;
} else if (mario.velocityX < 0) {
mario.x = wall.x + wall.width;
}
mario.velocityX = 0;
// Determine collision direction based on previous position
const overlapLeft = (mario.x + mario.width) - wall.x;
const overlapRight = (wall.x + wall.width) - mario.x;
const overlapTop = (mario.y + mario.height) - wall.y;
const overlapBottom = (wall.y + wall.height) - mario.y;
// Top/bottom collision
if (mario.velocityY > 0) {
// Find the smallest overlap to determine collision side
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.velocityY = 0;
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.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;
}
});
// 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
if (gameState.boss) this.renderBoss(ctx, gameState.boss);
// Castle
if (gameState.castle) this.renderCastle(ctx, gameState.castle);
// Castle structure (emoji-based)
if (gameState.castleStructure) this.renderCastleStructure(ctx, gameState.castleStructure);
// Finish line
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) {
if (!castle) return;
renderCastleStructure(ctx, castleStructure) {
if (!castleStructure) return;
// Main castle body
ctx.fillStyle = '#888';
ctx.fillRect(castle.x, castle.y, castle.width, castle.height);
// Draw castle emoji
ctx.font = `${castleStructure.size}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
castleStructure.emoji,
castleStructure.x,
castleStructure.y
);
// Towers
const towerWidth = 40;
const towerHeight = 80;
// Left tower
ctx.fillRect(castle.x - 20, castle.y - 30, towerWidth, towerHeight);
// 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);
// Draw princess emoji (if exists)
if (castleStructure.princess) {
ctx.font = `${castleStructure.princess.size}px Arial`;
ctx.fillText(
castleStructure.princess.emoji,
castleStructure.princess.x,
castleStructure.princess.y
);
}
}
/**
@ -599,25 +577,150 @@ export class Renderer {
* Render debug hitboxes (for development)
*/
renderDebugHitboxes(ctx, gameState) {
ctx.strokeStyle = '#FF00FF';
ctx.lineWidth = 1;
ctx.lineWidth = 2;
// Mario hitbox
// Mario hitbox (GREEN)
ctx.strokeStyle = '#00FF00';
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) {
gameState.enemies.forEach(enemy => {
ctx.strokeStyle = '#FF0000';
gameState.enemies.forEach((enemy, idx) => {
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) {
gameState.platforms.forEach(platform => {
ctx.strokeStyle = '#00FFFF';
gameState.platforms.forEach((platform, idx) => {
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();
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(
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
@ -100,9 +102,9 @@ export class FlyingEye {
eye.dashDuration = 30; // 30 frames of dash
eye.lastDashTime = currentTime;
// Set dash velocity towards Mario
const dx = mario.x - eye.x;
const dy = mario.y - eye.y;
// Set dash velocity towards Mario center
const dx = marioCenter.x - eye.x;
const dy = marioCenter.y - eye.y;
const distance = Math.sqrt(dx * dx + dy * dy);
eye.velocityX = (dx / distance) * eye.dashSpeed;
eye.velocityY = (dy / distance) * eye.dashSpeed;
@ -116,9 +118,9 @@ export class FlyingEye {
eye.x += eye.velocityX;
eye.y += eye.velocityY;
} else if (eye.isChasing) {
// Chase Mario
const dx = mario.x - eye.x;
const dy = mario.y - eye.y;
// Chase Mario center
const dx = marioCenter.x - eye.x;
const dy = marioCenter.y - eye.y;
const distance = Math.sqrt(dx * dx + dy * dy);
eye.velocityX = (dx / distance) * eye.chaseSpeed;
@ -139,6 +141,7 @@ export class FlyingEye {
}
// Keep eyes within bounds (with some margin)
// Allow eyes to fly anywhere but not too close to edges
if (eye.x < 50) {
eye.x = 50;
eye.velocityX = Math.abs(eye.velocityX);
@ -147,8 +150,9 @@ export class FlyingEye {
eye.y = 50;
eye.velocityY = Math.abs(eye.velocityY);
}
if (eye.y > 400) {
eye.y = 400;
// Allow eyes to go much lower (near ground level) - 600px is just above ground (640)
if (eye.y > 600) {
eye.y = 600;
eye.velocityY = -Math.abs(eye.velocityY);
}
});
@ -162,11 +166,15 @@ export class FlyingEye {
*/
static checkCollision(mario, eyes) {
for (const eye of eyes) {
// Simple rectangle collision
if (mario.x < eye.x + eye.width &&
mario.x + mario.width > eye.x &&
mario.y < eye.y + eye.height &&
mario.y + mario.height > eye.y) {
// 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;
// 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;
}
}

View File

@ -72,14 +72,47 @@ class AdventureReader extends Module {
*/
static getCompatibilityScore(content) {
const vocab = content?.vocabulary || {};
const sentences = content?.sentences || [];
const stories = content?.story?.chapters || content?.texts || [];
const dialogues = content?.dialogues || [];
const stories = content?.story?.chapters || content?.texts || [];
const vocabCount = Object.keys(vocab).length;
const sentenceCount = sentences.length;
const storyCount = stories.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;
@ -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;
if (vocabCount > 0) score += Math.min(vocabCount / 10, 0.3);
if (sentenceCount > 0) score += Math.min(sentenceCount / 10, 0.3);
if (storyCount > 0) score += Math.min(storyCount / 5, 0.2);
if (dialogueCount > 0) score += Math.min(dialogueCount / 3, 0.2);
// Vocabulary: 0.3 points max (reach 100% at 8+ items)
if (vocabCount > 0) score += Math.min(vocabCount / 8, 1) * 0.3;
// 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 {
score: Math.min(score, 1),
reason: `Adventure content: ${vocabCount} vocab, ${sentenceCount} sentences, ${storyCount} stories, ${dialogueCount} 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`
};
}
@ -135,12 +176,16 @@ class AdventureReader extends Module {
// Initialize game interface
this._createGameInterface();
this._initializePlayer();
this._setupEventListeners();
this._updateContentInfo();
this._generateGameObjects();
this._generateDecorations();
this._startGameLoop();
// Wait for DOM to render before initializing player
requestAnimationFrame(() => {
this._initializePlayer();
this._setupEventListeners();
this._updateContentInfo();
this._generateGameObjects();
this._generateDecorations();
this._startGameLoop();
});
// Start the game
this._gameStartTime = Date.now();
@ -246,6 +291,8 @@ class AdventureReader extends Module {
_extractSentences() {
let sentences = [];
console.log('AdventureReader: Extracting sentences from content', this._content);
// Support for Dragon's Pearl structure
if (this._content.story?.chapters) {
this._content.story.chapters.forEach(chapter => {
@ -268,13 +315,61 @@ class AdventureReader extends Module {
if (this._content.sentences) {
this._content.sentences.forEach(sentence => {
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,
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);
}
@ -364,14 +459,14 @@ class AdventureReader extends Module {
}
.stat-icon {
font-size: 1.2rem;
font-size: 0.72rem; /* 1.2 / 1.66 */
}
.progress-info {
background: rgba(255, 255, 255, 0.1);
padding: 8px 15px;
border-radius: 15px;
font-size: 0.9rem;
font-size: 0.54rem; /* 0.9 / 1.66 */
}
.game-map {
@ -384,7 +479,7 @@ class AdventureReader extends Module {
.player {
position: absolute;
font-size: 2.5rem;
font-size: 1.51rem; /* 2.5 / 1.66 */
z-index: 50;
transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.3));
@ -393,7 +488,7 @@ class AdventureReader extends Module {
.pot, .enemy {
position: absolute;
font-size: 2rem;
font-size: 1.2rem; /* 2 / 1.66 */
cursor: pointer;
z-index: 30;
transition: all 0.3s ease;
@ -443,12 +538,12 @@ class AdventureReader extends Module {
}
.instructions {
font-size: 0.9rem;
font-size: 0.54rem; /* 0.9 / 1.66 */
opacity: 0.9;
}
.content-summary {
font-size: 0.85rem;
font-size: 0.51rem; /* 0.85 / 1.66 */
background: rgba(255, 255, 255, 0.1);
padding: 8px 12px;
border-radius: 8px;
@ -459,7 +554,7 @@ class AdventureReader extends Module {
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
font-size: 0.54rem; /* 0.9 / 1.66 */
font-weight: 500;
transition: all 0.3s ease;
}
@ -531,7 +626,7 @@ class AdventureReader extends Module {
.modal-header h3 {
margin: 0;
font-size: 1.3rem;
font-size: 0.78rem; /* 1.3 / 1.66 */
}
.modal-body {
@ -560,7 +655,7 @@ class AdventureReader extends Module {
}
.original-text {
font-size: 1.4rem;
font-size: 0.84rem; /* 1.4 / 1.66 */
font-weight: 600;
color: #1f2937;
margin-bottom: 15px;
@ -568,14 +663,14 @@ class AdventureReader extends Module {
}
.translation-text {
font-size: 1.1rem;
font-size: 0.66rem; /* 1.1 / 1.66 */
color: #6b7280;
margin-bottom: 10px;
line-height: 1.3;
}
.pronunciation-text {
font-size: 1rem;
font-size: 0.60rem; /* 1.0 / 1.66 */
color: #7c3aed;
font-style: italic;
}
@ -601,19 +696,19 @@ class AdventureReader extends Module {
}
.vocab-word {
font-size: 2rem;
font-size: 1.2rem; /* 2.0 / 1.66 */
font-weight: bold;
margin-bottom: 10px;
}
.vocab-translation {
font-size: 1.3rem;
font-size: 0.78rem; /* 1.3 / 1.66 */
margin-bottom: 10px;
opacity: 0.9;
}
.vocab-pronunciation {
font-size: 1rem;
font-size: 0.60rem; /* 1.0 / 1.66 */
opacity: 0.8;
font-style: italic;
}
@ -1042,9 +1137,6 @@ class AdventureReader extends Module {
<!-- Sentence content -->
</div>
</div>
<div class="modal-footer">
<button class="control-btn primary" id="continue-btn">Continue Adventure </button>
</div>
</div>
</div>
@ -1084,7 +1176,6 @@ class AdventureReader extends Module {
_setupEventListeners() {
// Control buttons
document.getElementById('restart-btn').addEventListener('click', () => this._restart());
document.getElementById('continue-btn').addEventListener('click', () => this._closeModal());
// Exit button
const exitButton = document.getElementById('exit-adventure');
@ -1199,7 +1290,7 @@ class AdventureReader extends Module {
y: position.y,
defeated: false,
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,
patrolStartX: position.x,
patrolStartY: position.y,
@ -1209,7 +1300,8 @@ class AdventureReader extends Module {
circleAngle: Math.random() * Math.PI * 2,
changeDirectionTimer: 0,
dashCooldown: 0,
isDashing: false
isDashing: false,
dashDuration: 0
};
}
@ -1257,7 +1349,7 @@ class AdventureReader extends Module {
const position = this._getDecorationPosition(mapWidth, mapHeight, 60);
tree.style.left = position.x + '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);
}
@ -1273,7 +1365,7 @@ class AdventureReader extends Module {
const position = this._getDecorationPosition(mapWidth, mapHeight, 30);
grass.style.left = position.x + '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);
}
@ -1288,7 +1380,7 @@ class AdventureReader extends Module {
const position = this._getDecorationPosition(mapWidth, mapHeight, 40);
rock.style.left = position.x + 'px';
rock.style.top = position.y + 'px';
rock.style.fontSize = (20 + Math.random() * 10) + 'px';
rock.style.fontSize = ((20 + Math.random() * 10) / 1.66) + 'px'; // Reduced by 1.66
gameMap.appendChild(rock);
}
@ -1334,6 +1426,8 @@ class AdventureReader extends Module {
_startGameLoop() {
const animate = () => {
if (this._isDestroyed) return; // Stop animation if game is destroyed
if (!this._isGamePaused) {
this._moveEnemies();
}
@ -1344,6 +1438,8 @@ class AdventureReader extends Module {
_moveEnemies() {
const gameMap = document.getElementById('game-map');
if (!gameMap) return; // Exit if game map doesn't exist
const mapRect = gameMap.getBoundingClientRect();
const mapWidth = mapRect.width;
const mapHeight = mapRect.height;
@ -1366,6 +1462,15 @@ class AdventureReader extends Module {
enemy.element.style.left = enemy.x + '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);
});
}
@ -1401,10 +1506,43 @@ class AdventureReader extends Module {
this._player.y - enemy.y,
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);
enemy.y += Math.sin(enemy.moveDirection) * (enemy.speed * 0.8);
// Decrease dash cooldown
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;
case 'wander':
@ -1629,11 +1767,25 @@ class AdventureReader extends Module {
modal.style.display = 'flex';
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) {
setTimeout(() => {
this._speakText(sentence.original_language, { rate: 0.8 });
}, 600);
}, ttsDelay);
}
// Auto-close modal after TTS completes
setTimeout(() => {
this._closeModal();
}, totalTime);
}
_closeModal() {
@ -1642,6 +1794,9 @@ class AdventureReader extends Module {
setTimeout(() => {
modal.style.display = 'none';
this._isGamePaused = false;
// Grant 1 second invulnerability after closing reading modal
this._grantPostReadingInvulnerability();
}, 300);
this._checkGameComplete();
@ -1705,7 +1860,8 @@ class AdventureReader extends Module {
}
_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(
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;
const playerElement = document.getElementById('player');
// Blinking animation (visual only)
let blinkCount = 0;
const blinkInterval = setInterval(() => {
playerElement.style.opacity = playerElement.style.opacity === '0.3' ? '1' : '0.3';
@ -1738,10 +1895,14 @@ class AdventureReader extends Module {
clearInterval(blinkInterval);
playerElement.style.opacity = '1';
playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))';
this._isPlayerInvulnerable = false;
}
}, 250);
// Actual invulnerability duration (independent of blink animation)
this._invulnerabilityTimeout = setTimeout(() => {
this._isPlayerInvulnerable = false;
}, 2000); // 2 seconds of actual invulnerability
this._showDamagePopup();
}
@ -1763,6 +1924,23 @@ class AdventureReader extends Module {
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() {
if (this._invulnerabilityTimeout) {
clearTimeout(this._invulnerabilityTimeout);

View File

@ -521,12 +521,12 @@ class FillTheBlank extends Module {
const points = 10 * blanks.length;
this._score += points;
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._loadNextExercise();
}, 1500);
});
} else {
this._errors++;
if (correctCount > 0) {
@ -574,42 +574,106 @@ class FillTheBlank extends Module {
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._loadNextExercise();
}, 2000);
});
}
_speakWord(word) {
if (!window.speechSynthesis || !word) return;
async _speakSentence(sentence, callback) {
if (!window.speechSynthesis || !sentence) {
if (callback) setTimeout(callback, 1500);
return;
}
try {
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(word);
utterance.lang = 'en-US';
utterance.rate = 0.9;
// For predefined exercises, replace underscores with actual answers
let textToSpeak = sentence;
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.volume = 1.0;
const voices = window.speechSynthesis.getVoices();
const voices = await this._getVoices();
const langPrefix = targetLanguage.split('-')[0];
const preferredVoice = voices.find(v =>
v.lang.startsWith('en') &&
(v.name.includes('Google') || v.name.includes('Neural') || v.name.includes('Microsoft'))
);
v.lang.startsWith(langPrefix) && v.default
) || voices.find(v => v.lang.startsWith(langPrefix));
if (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);
} catch (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') {
const feedbackArea = document.getElementById('feedback-area');
if (feedbackArea) {

View File

@ -1727,7 +1727,7 @@ class FlashcardLearning extends Module {
}
// Audio System
_playAudio(text) {
async _playAudio(text) {
if ('speechSynthesis' in window) {
// Cancel any ongoing speech
speechSynthesis.cancel();
@ -1742,7 +1742,7 @@ class FlashcardLearning extends Module {
utterance.volume = 1.0;
// Try to find a suitable voice for the language
const voices = speechSynthesis.getVoices();
const voices = await this._getVoices();
if (voices.length > 0) {
// Find voice matching the chapter language
const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
@ -1753,6 +1753,8 @@ class FlashcardLearning extends Module {
if (matchingVoice) {
utterance.voice = matchingVoice;
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() {
// Highlight pronunciation when TTS is played
const pronunciation = document.getElementById('pronunciation-display');

View File

@ -333,19 +333,39 @@ class GrammarDiscovery extends Module {
if (Array.isArray(content.vocabulary)) {
vocabulary = content.vocabulary.slice(0, 10); // Limit to first 10
} else if (content.vocabulary && typeof content.vocabulary === 'object') {
vocabulary = Object.entries(content.vocabulary).slice(0, 10).map(([word, data]) => ({
chinese: word,
english: data.english || data.translation || data,
pronunciation: data.pronunciation || data.prononciation || '',
text: word
}));
vocabulary = Object.entries(content.vocabulary).slice(0, 10).map(([word, data]) => {
// Extract translation properly from different formats
let translation;
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 => {
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({
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 || '',
explanation: `Practice word: ${item.chinese || item.text || item.word}`
});
@ -370,19 +390,41 @@ class GrammarDiscovery extends Module {
if (Array.isArray(content.vocabulary)) {
vocabulary = content.vocabulary.slice(0, 5);
} else if (content.vocabulary && typeof content.vocabulary === 'object') {
vocabulary = Object.entries(content.vocabulary).slice(0, 5).map(([word, data]) => ({
chinese: word,
english: data.english || data.translation || data,
text: word
}));
vocabulary = Object.entries(content.vocabulary).slice(0, 5).map(([word, data]) => {
// Extract translation properly from different formats
let translation;
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 => {
if (typeof item === 'object') {
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({
chinese: word,
english: item.english || item.translation || `Formation example: ${word}`,
english: translation || `Formation example: ${word}`,
pronunciation: item.pronunciation || item.prononciation || '',
explanation: `Analyze the structure and formation of: ${word}`
});

View File

@ -69,6 +69,7 @@ class MarioEducational extends Module {
this._particles = [];
this._walls = [];
this._castleStructure = null;
this._finishLine = null;
// Advanced level elements
this._piranhaPlants = [];
@ -115,6 +116,9 @@ class MarioEducational extends Module {
// UI elements (need to be declared before seal)
this._uiOverlay = null;
// Debug mode (toggle with 'D' key)
this._debugMode = false;
Object.seal(this);
}
@ -140,63 +144,60 @@ class MarioEducational extends Module {
*/
static getCompatibilityScore(content) {
const sentences = content?.sentences || [];
const vocab = content?.vocabulary || {};
const phrases = content?.phrases || {};
const dialogs = content?.dialogs || {};
const texts = content?.texts || [];
const story = content?.story || '';
const vocabCount = Object.keys(vocab).length;
const sentenceCount = sentences.length;
let totalSentences = sentences.length;
// Count sentences from texts and story
let extraSentenceCount = 0;
// Count phrases (SBS format)
totalSentences += Object.keys(phrases).length;
if (story && typeof story === 'string') {
extraSentenceCount += story.split(/[.!?]+/).filter(s => s.trim().length > 0).length;
}
// Count dialog lines (SBS format)
Object.values(dialogs).forEach(dialog => {
if (dialog.lines && Array.isArray(dialog.lines)) {
totalSentences += dialog.lines.length;
}
});
// Count sentences from texts
if (Array.isArray(texts)) {
texts.forEach(text => {
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) {
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 {
score: 0,
reason: `Insufficient content (${totalSentences} sentences, ${vocabCount} vocabulary words)`,
requirements: ['sentences', 'vocabulary', 'texts', 'story'],
minSentences: 3,
minVocabulary: 10,
details: 'Mario Educational needs at least 3 sentences OR 10+ vocabulary words'
reason: `Insufficient sentences (${totalSentences}/5 minimum)`,
requirements: ['sentences', 'phrases', 'dialogs', 'texts', 'story'],
minSentences: 5,
details: 'Mario Educational needs at least 5 complete sentences from any source'
};
}
if (vocabCount < 5) {
return {
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);
// Good score for 10+ sentences, perfect at 30+
const score = Math.min(totalSentences / 30, 1);
return {
score,
reason: `${totalSentences} sentences and ${vocabCount} vocabulary words available`,
requirements: ['sentences', 'vocabulary', 'texts', 'story'],
minSentences: 3,
reason: `${totalSentences} sentences available`,
requirements: ['sentences', 'phrases', 'dialogs', 'texts', 'story'],
minSentences: 5,
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
_extractSentences() {
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 story = this._content.story || '';
// Combine sentences and vocabulary for questions
// Combine all sentence sources
this._sentences = [];
// Add actual sentences - handle both formats
// Add sentences from 'sentences' array
sentences.forEach(sentence => {
// Format 1: Modern format with english/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
if (story && typeof story === 'string') {
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
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._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
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'Space'].includes(e.code)) {
e.preventDefault();
@ -456,6 +477,17 @@ class MarioEducational extends Module {
// Generate floating platforms with intelligent placement
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
const questionCount = 3 + difficulty;
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
});
// Check if enemy has solid ground/platform/stair support below
const hasSolidSupport = this._hasSolidSupportBelow(enemyX, enemyY + 20, level); // Check below enemy position
// Enemy is spawned on a platform, so it has support by definition
// 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
const isHelmetEnemy = index >= 1 && !helmetEnemyPlaced && i === 0; // Only first enemy can be helmet
@ -555,8 +587,10 @@ class MarioEducational extends Module {
hasHelmet: isHelmetEnemy
});
enemyPlaced = true;
console.log(`✅ Enemy ${i} placed at x=${enemyX.toFixed(0)}, y=${enemyY.toFixed(0)} on platform`);
} 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++;
}
@ -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) {
this._generateHoles(level, difficulty);
this._generateStairs(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) {
this._generateBossStairs(level, difficulty);
// Reduced piranha plants for boss level
if (difficulty > 3) {
this._generatePiranhaPlants(level, Math.min(difficulty - 2, 2));
}
@ -592,10 +622,10 @@ class MarioEducational extends Module {
this._generateFlyingEyes(level, difficulty);
}
// Level 6 gets colossal boss
if (index === 5) {
this._generateColossalBoss(level, difficulty);
}
// Level 6 gets colossal boss (DISABLED)
// if (index === 5) {
// this._generateColossalBoss(level, difficulty);
// }
return level;
}
@ -1527,6 +1557,13 @@ class MarioEducational extends Module {
this._walls = [...(level.walls || [])];
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
this._piranhaPlants = [...(level.piranhaPlants || [])];
this._projectiles = []; // Reset projectiles each level
@ -1649,7 +1686,8 @@ class MarioEducational extends Module {
walls: this._walls,
catapults: this._catapults,
piranhaPlants: this._piranhaPlants,
boulders: this._boulders
boulders: this._boulders,
flyingEyes: this._flyingEyes
};
const callbacks = {
@ -1749,7 +1787,7 @@ class MarioEducational extends Module {
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)
const words = text.split(' ').length;
const wordsPerMinute = 150; // Average speaking speed
@ -1765,15 +1803,23 @@ class MarioEducational extends Module {
utterance.pitch = 1.0;
utterance.volume = 0.8;
// Try to use a nice English voice
const voices = speechSynthesis.getVoices();
const englishVoice = voices.find(voice =>
voice.lang.startsWith('en') && (voice.name.includes('Female') || voice.name.includes('Google'))
) || voices.find(voice => voice.lang.startsWith('en'));
// Get target language from content
const targetLanguage = this._content?.language || 'en-US';
utterance.lang = targetLanguage;
if (englishVoice) {
utterance.voice = englishVoice;
console.log(`🎤 Using voice: ${englishVoice.name}`);
// Wait for voices to be loaded before selecting one
const voices = await this._getVoices();
const langPrefix = targetLanguage.split('-')[0];
const matchingVoice = voices.find(voice =>
voice.lang.startsWith(langPrefix) && voice.default
) || voices.find(voice => voice.lang.startsWith(langPrefix));
if (matchingVoice) {
utterance.voice = matchingVoice;
console.log(`🎤 Using voice: ${matchingVoice.name} (${matchingVoice.lang})`);
} else {
console.warn(`🔊 No voice found for language: ${targetLanguage}, available:`, voices.map(v => v.lang));
}
speechSynthesis.speak(utterance);
@ -2252,6 +2298,44 @@ class MarioEducational extends Module {
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() {
// Build game state object for renderer
const gameState = {
@ -2268,19 +2352,53 @@ class MarioEducational extends Module {
stones: this._stones,
flyingEyes: this._flyingEyes,
boss: this._boss,
castle: this._castle,
castleStructure: this._castleStructure,
finishLine: this._finishLine,
particles: this._particles,
currentLevel: this._currentLevel,
lives: this._lives,
score: this._score,
debugMode: false // Can be toggled
debugMode: this._debugMode // Toggle with 'D' key during gameplay
};
// Delegate rendering to helper
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;

View File

@ -713,7 +713,7 @@ class QuizGame extends Module {
data-pronunciation="${pronunciation}"
title="Click to hear pronunciation">
${optionText}
${pronunciation ? `<div class="option-pronunciation">[${pronunciation}]</div>` : ''}
${pronunciation ? `<div class="option-pronunciation" style="display: none;">[${pronunciation}]</div>` : ''}
</div>
`;
}).join('');
@ -784,16 +784,6 @@ class QuizGame extends Module {
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;
const question = this._questions[this._currentQuestion];
const selectedAnswer = optionElement.dataset.value;
@ -816,6 +806,40 @@ class QuizGame extends Module {
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
this._showAnswerFeedback(isCorrect, question);
this._updateStats();
@ -831,6 +855,19 @@ class QuizGame extends Module {
}, 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() {
if (this._timer) {
clearInterval(this._timer);
@ -991,7 +1028,7 @@ class QuizGame extends Module {
this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name);
}
_playAudio(text) {
async _playAudio(text) {
if ('speechSynthesis' in window) {
// Cancel any ongoing speech
speechSynthesis.cancel();
@ -1006,7 +1043,7 @@ class QuizGame extends Module {
utterance.volume = 1.0;
// Try to find a suitable voice for the language
const voices = speechSynthesis.getVoices();
const voices = await this._getVoices();
if (voices.length > 0) {
// Find voice matching the chapter language
const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
@ -1017,6 +1054,8 @@ class QuizGame extends Module {
if (matchingVoice) {
utterance.voice = matchingVoice;
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) {
const pronunciation = optionElement.querySelector('.option-pronunciation');

View File

@ -1,4 +1,5 @@
import Module from '../core/Module.js';
import { soundSystem } from '../gameHelpers/MarioEducational/SoundSystem.js';
class RiverRun extends Module {
constructor(name, dependencies, config = {}) {
@ -55,6 +56,12 @@ class RiverRun extends Module {
this._gameContainer = null;
this._animationFrame = null;
// Background music
this._audioContext = null;
this._backgroundMusicNodes = [];
this._isMusicPlaying = false;
this._musicLoopTimeout = null;
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('navigation:change', this._handleNavigationChange.bind(this), this.name);
soundSystem.initialize();
this._injectCSS();
// Start game immediately
@ -219,6 +228,8 @@ class RiverRun extends Module {
this._animationFrame = null;
}
this._stopBackgroundMusic();
if (this._gameContainer) {
this._gameContainer.innerHTML = '';
}
@ -332,6 +343,7 @@ class RiverRun extends Module {
this._isRunning = true;
this._gameStartTime = Date.now();
this._setNextTarget();
this._startBackgroundMusic();
this._gameLoop();
console.log('River Run started!');
@ -343,7 +355,29 @@ class RiverRun extends Module {
const now = Date.now();
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;
}
@ -392,16 +426,37 @@ class RiverRun extends Module {
const spacePadding = ' '.repeat(this._level * 2);
wordElement.textContent = spacePadding + word.french + spacePadding;
wordElement.style.left = `${Math.random() * 80 + 10}%`;
wordElement.style.top = '-60px';
// More random positioning with different strategies
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;
riverCanvas.appendChild(wordElement);
this._floatingWords.push({
element: wordElement,
y: -60,
x: parseFloat(wordElement.style.left),
y: yStart,
x: xPosition,
wordData: word
});
@ -421,27 +476,35 @@ class RiverRun extends Module {
const powerUpElement = document.createElement('div');
powerUpElement.className = 'power-up';
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);
this._powerUps.push({
element: powerUpElement,
y: -40,
x: parseFloat(powerUpElement.style.left),
y: yStart,
x: xPosition,
type: 'slowTime'
});
}
_updateFloatingWords() {
const riverCanvas = document.getElementById('river-canvas');
const canvasHeight = riverCanvas ? riverCanvas.offsetHeight : window.innerHeight;
this._floatingWords = this._floatingWords.filter(word => {
word.y += this._speed;
word.element.style.top = `${word.y}px`;
if (word.y > window.innerHeight + 60) {
if (word.wordData.french === this._currentTarget.french) {
this._loseLife();
}
// Check if word has gone below the visible game area
if (word.y > canvasHeight - 50) {
// Note: No longer losing life when target word escapes
// Life loss only happens on collision with wrong words
word.element.remove();
return false;
}
@ -453,7 +516,8 @@ class RiverRun extends Module {
powerUp.y += this._speed;
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();
return false;
}
@ -532,45 +596,61 @@ class RiverRun extends Module {
_checkCollisions() {
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);
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);
if (this._isColliding(playerRect, powerUpRect)) {
this._handlePowerUpCollision(powerUp, index);
this._handlePowerUpCollision(powerUp, i);
}
});
}
}
_getPlayerRect() {
const playerElement = document.getElementById('player');
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 canvas = document.getElementById('river-canvas').getBoundingClientRect();
const canvasRect = canvas.getBoundingClientRect();
return {
x: rect.left - canvas.left,
y: rect.top - canvas.top,
x: rect.left - canvasRect.left,
y: rect.top - canvasRect.top,
width: rect.width,
height: rect.height
};
}
_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 canvas = document.getElementById('river-canvas').getBoundingClientRect();
const canvasRect = canvas.getBoundingClientRect();
return {
x: rect.left - canvas.left,
y: rect.top - canvas.top,
x: rect.left - canvasRect.left,
y: rect.top - canvasRect.top,
width: rect.width,
height: rect.height
};
@ -594,22 +674,43 @@ class RiverRun extends Module {
}
_handleWordCollision(word, index) {
if (word.wordData.french === this._currentTarget.french) {
this._collectWord(word.element, true);
} else {
this._missWord(word.element);
}
// Handle collision for ALL words:
// - TARGET word: auto-collect (points)
// - WRONG word: lose life
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) {
wordElement.classList.add('collected');
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++;
// Show points earned (visual feedback)
this._showPointsPopup(wordElement, pointsEarned);
this._eventBus.emit('game:score-update', {
gameId: 'river-run',
score: this._score,
@ -626,6 +727,7 @@ class RiverRun extends Module {
}
_missWord(wordElement) {
soundSystem.play('enemy_defeat');
wordElement.classList.add('missed');
this._loseLife();
@ -653,24 +755,103 @@ class RiverRun extends Module {
_updateDifficulty() {
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) {
this._level = newLevel;
this._speed += 0.5;
this._config.spawnInterval = Math.max(500, this._config.spawnInterval - 100);
// Decrease spawn interval with each level (spawn words more frequently)
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) {
// Cancel any ongoing speech
speechSynthesis.cancel();
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);
}
}
_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() {
this._lives--;
@ -681,6 +862,7 @@ class RiverRun extends Module {
_gameOver() {
this._isRunning = false;
this._stopBackgroundMusic();
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() {
// Stop any playing music before restarting
this._stopBackgroundMusic();
this._isRunning = false;
this._score = 0;
this._lives = this._config.initialLives;
@ -783,6 +968,123 @@ class RiverRun extends Module {
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) {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
@ -1055,6 +1357,35 @@ class RiverRun extends Module {
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 {
background: rgba(239, 68, 68, 0.1);
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
if ('speechSynthesis' in window) {
// Cancel any ongoing speech
speechSynthesis.cancel();
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.pitch = 1.0;
utterance.volume = 1.0;
// Try to use a good English voice
const voices = speechSynthesis.getVoices();
const englishVoice = voices.find(voice =>
voice.lang.startsWith('en') && (voice.name.includes('Google') || voice.name.includes('Neural'))
) || voices.find(voice => voice.lang.startsWith('en'));
// Try to use a good voice for the target language
const voices = await this._getVoices();
const langPrefix = targetLanguage.split('-')[0];
const preferredVoice = voices.find(voice =>
voice.lang.startsWith(langPrefix) && (voice.name.includes('Google') || voice.name.includes('Neural') || voice.default)
) || voices.find(voice => voice.lang.startsWith(langPrefix));
if (englishVoice) {
utterance.voice = englishVoice;
if (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));
}
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) {
const shuffled = [...array];
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
if ('speechSynthesis' in window) {
// Cancel any ongoing speech
speechSynthesis.cancel();
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.pitch = 1.0;
utterance.volume = 1.0;
// Try to use a good English voice
const voices = speechSynthesis.getVoices();
const englishVoice = voices.find(voice =>
voice.lang.startsWith('en') && (voice.name.includes('Google') || voice.name.includes('Neural'))
) || voices.find(voice => voice.lang.startsWith('en'));
// Try to use a good voice for the target language
const voices = await this._getVoices();
const langPrefix = targetLanguage.split('-')[0];
const preferredVoice = voices.find(voice =>
voice.lang.startsWith(langPrefix) && (voice.name.includes('Google') || voice.name.includes('Neural') || voice.default)
) || voices.find(voice => voice.lang.startsWith(langPrefix));
if (englishVoice) {
utterance.voice = englishVoice;
if (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));
}
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) {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {

View File

@ -70,6 +70,9 @@ class WizardSpellCaster extends Module {
const sentences = content?.sentences || [];
const storyChapters = content?.story?.chapters || [];
const dialogues = content?.dialogues || [];
const texts = content?.texts || [];
const phrases = content?.phrases || {};
const dialogs = content?.dialogs || {};
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 (totalSentences >= 9) {
const score = Math.min(totalSentences / 30, 1);
return {
score,
reason: `${totalSentences} sentences available for spell construction`,
requirements: ['sentences', 'story', 'dialogues'],
reason: `${totalSentences} sentences/phrases available for spell construction`,
requirements: ['sentences', 'story', 'dialogues', 'texts', 'phrases', 'dialogs'],
minSentences: 9,
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 {
score: 0,
reason: `Insufficient content (${totalSentences} sentences, ${vocabCount} vocabulary words)`,
requirements: ['sentences', 'story', 'dialogues', 'vocabulary'],
reason: `Insufficient content (${totalSentences} sentences/phrases, ${vocabCount} vocabulary words)`,
requirements: ['sentences', 'story', 'dialogues', 'texts', 'phrases', 'dialogs', 'vocabulary'],
minSentences: 9,
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) {
@ -371,19 +457,22 @@ class WizardSpellCaster extends Module {
style.textContent = `
.wizard-game-wrapper {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
min-height: 100vh;
height: 100vh;
color: white;
font-family: 'Fantasy', serif;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
.wizard-hud {
display: flex;
justify-content: space-between;
padding: 15px;
padding: 8px 15px;
background: rgba(0,0,0,0.3);
border-bottom: 2px solid #ffd700;
flex-shrink: 0;
}
.wizard-stats {
@ -393,10 +482,10 @@ class WizardSpellCaster extends Module {
}
.health-bar {
width: 150px;
height: 20px;
width: 120px;
height: 16px;
background: rgba(255,255,255,0.2);
border-radius: 10px;
border-radius: 8px;
overflow: hidden;
border: 2px solid #ffd700;
}
@ -409,8 +498,9 @@ class WizardSpellCaster extends Module {
.battle-area {
display: flex;
height: 60vh;
padding: 20px;
height: 180px;
padding: 10px 20px;
flex-shrink: 0;
}
.wizard-side {
@ -430,29 +520,29 @@ class WizardSpellCaster extends Module {
}
.wizard-character {
width: 120px;
height: 120px;
width: 80px;
height: 80px;
background: linear-gradient(45deg, #6c5ce7, #a29bfe);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
margin-bottom: 20px;
font-size: 36px;
margin-bottom: 8px;
animation: float 3s ease-in-out infinite;
box-shadow: 0 0 30px rgba(108, 92, 231, 0.6);
}
.enemy-character {
width: 150px;
height: 150px;
width: 100px;
height: 100px;
background: linear-gradient(45deg, #ff4757, #ff6b7a);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 64px;
margin-bottom: 20px;
font-size: 48px;
margin-bottom: 8px;
animation: enemyPulse 2s ease-in-out infinite;
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);
border: 2px solid #ffd700;
border-radius: 15px;
padding: 20px;
margin: 20px;
padding: 12px 15px;
margin: 0 15px 10px 15px;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.spell-selection {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
margin-bottom: 20px;
gap: 10px;
margin-bottom: 10px;
flex-shrink: 0;
}
.spell-card {
background: linear-gradient(135deg, #2c2c54, #40407a);
border: 2px solid #ffd700;
border-radius: 10px;
padding: 15px;
border-radius: 8px;
padding: 10px;
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
@ -520,29 +615,32 @@ class WizardSpellCaster extends Module {
.sentence-builder {
background: rgba(255,255,255,0.1);
border-radius: 10px;
padding: 15px;
margin-bottom: 20px;
min-height: 80px;
border-radius: 8px;
padding: 10px;
margin-bottom: 10px;
min-height: 60px;
border: 2px dashed #ffd700;
flex-shrink: 0;
}
.word-bank {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 20px;
gap: 8px;
margin-bottom: 10px;
flex-shrink: 0;
}
.word-tile {
background: linear-gradient(135deg, #5f27cd, #8854d0);
color: white;
padding: 8px 15px;
border-radius: 20px;
padding: 6px 12px;
border-radius: 15px;
cursor: grab;
user-select: none;
transition: all 0.3s ease;
border: 2px solid transparent;
font-size: 0.9em;
}
.word-tile:hover {
@ -564,14 +662,15 @@ class WizardSpellCaster extends Module {
background: linear-gradient(135deg, #ff6b7a, #ff4757);
border: none;
color: white;
padding: 15px 30px;
border-radius: 25px;
font-size: 18px;
padding: 12px 25px;
border-radius: 20px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 5px 15px rgba(255, 71, 87, 0.3);
width: 100%;
flex-shrink: 0;
}
.cast-button:hover {
@ -1055,8 +1154,8 @@ class WizardSpellCaster extends Module {
</div>
<div class="sentence-builder" id="sentence-builder">
<div style="color: #ffd700; margin-bottom: 10px;">Form your spell incantation:</div>
<div id="current-sentence" style="font-size: 18px; min-height: 30px;"></div>
<div style="color: #ffd700; margin-bottom: 5px; font-size: 0.9em;">Form your spell incantation:</div>
<div id="current-sentence" style="font-size: 16px; min-height: 24px;"></div>
</div>
<div class="word-bank" id="word-bank">

View File

@ -14,7 +14,6 @@ class WordDiscovery extends Module {
container: null,
difficulty: 'medium',
practiceCount: 10,
timerDuration: 30,
...config
};
@ -294,16 +293,19 @@ class WordDiscovery extends Module {
</div>` : ''}
<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.definition ? `<p class="word-definition">${word.definition}</p>` : ''}
${word.example ? `<p class="word-example">"${word.example}"</p>` : ''}
</div>
<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
</button>` : ''}
</button>
<button class="next-btn" onclick="window.wordDiscovery._nextWord()">
Next Word
</button>
@ -319,6 +321,11 @@ class WordDiscovery extends Module {
`;
window.wordDiscovery = this;
// Auto-play TTS when word is revealed (with slight delay for better UX)
setTimeout(() => {
this._playWordSound(word.word);
}, 300);
}
_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() {
if (this._discoveredWords.length === 0) {
this._discoveredWords = [...this._practiceWords];
@ -357,10 +449,10 @@ class WordDiscovery extends Module {
_renderPracticeLevel() {
const levels = ['Easy', 'Medium', 'Hard', 'Expert'];
const levelConfig = {
0: { time: 45, options: 2, type: 'translation' },
1: { time: 30, options: 3, type: 'mixed' },
2: { time: 20, options: 4, type: 'definition' },
3: { time: 15, options: 4, type: 'context' }
0: { options: 4, type: 'translation' },
1: { options: 3, type: 'mixed' },
2: { options: 4, type: 'definition' },
3: { options: 4, type: 'context' }
};
const config = levelConfig[this._currentPracticeLevel];
@ -375,7 +467,6 @@ class WordDiscovery extends Module {
<span>Total: ${this._practiceTotal}</span>
<span>Accuracy: ${this._practiceTotal > 0 ? Math.round((this._practiceCorrect / this._practiceTotal) * 100) : 0}%</span>
</div>
<div class="timer">Time: <span id="timer-display">${config.time}</span></div>
</div>
<div class="practice-question" id="practice-question">
@ -394,41 +485,16 @@ class WordDiscovery extends Module {
</div>
`;
this._timeLeft = config.time;
this._startTimer();
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) {
if (!config) {
const levelConfig = {
0: { time: 45, options: 2, type: 'translation' },
1: { time: 30, options: 3, type: 'mixed' },
2: { time: 20, options: 4, type: 'definition' },
3: { time: 15, options: 4, type: 'context' }
0: { options: 4, type: 'translation' },
1: { options: 3, type: 'mixed' },
2: { options: 4, type: 'definition' },
3: { options: 4, type: 'context' }
};
config = levelConfig[this._currentPracticeLevel];
}
@ -482,8 +548,11 @@ class WordDiscovery extends Module {
<div class="question-content">
<h3>What does this word mean?</h3>
<div class="question-word">
${correctWord.word}
${correctWord.audio ? `<button class="audio-btn-small" onclick="window.wordDiscovery._playAudio('${correctWord.word}')">🔊</button>` : ''}
<div class="word-with-pronunciation">
<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 class="options-grid">
${this._practiceOptions.map(option => `
@ -506,7 +575,10 @@ class WordDiscovery extends Module {
<div class="options-grid">
${this._practiceOptions.map(option => `
<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>
`).join('')}
</div>
@ -524,7 +596,10 @@ class WordDiscovery extends Module {
<div class="options-grid">
${this._practiceOptions.map(option => `
<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>
`).join('')}
</div>
@ -538,6 +613,8 @@ class WordDiscovery extends Module {
if (isCorrect) {
this._practiceCorrect++;
// Play TTS for correct answer
this._playWordSound(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;
margin: 15px 0;
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 {
@ -746,13 +834,6 @@ class WordDiscovery extends Module {
color: #2c3e50;
}
.timer {
font-size: 1.3em;
font-weight: bold;
color: #e74c3c;
margin: 10px 0;
}
.question-content {
background: white;
border-radius: 12px;
@ -777,6 +858,35 @@ class WordDiscovery extends Module {
align-items: center;
justify-content: center;
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 {

View File

@ -1,4 +1,5 @@
import Module from '../core/Module.js';
import { soundSystem } from '../gameHelpers/MarioEducational/SoundSystem.js';
/**
* WordStorm - Fast-paced falling words game where players match vocabulary
@ -18,7 +19,7 @@ class WordStorm extends Module {
this._config = {
container: null,
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
wordLifetime: 9200, // ms before word disappears (+15% more time)
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:resume', this._handleResume.bind(this), this.name);
// Initialize sound system
soundSystem.initialize();
// Inject CSS
this._injectCSS();
@ -719,14 +723,51 @@ class WordStorm extends Module {
wordElement.className = 'falling-word';
wordElement.textContent = word.original;
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);
// 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({
element: wordElement,
word: word,
startTime: Date.now()
startTime: Date.now(),
positionCheck: positionCheck
});
// Generate new answer options when word spawns
@ -734,19 +775,25 @@ class WordStorm extends Module {
// Animate falling
this._animateFalling(wordElement);
// Remove after lifetime
setTimeout(() => {
if (wordElement.parentNode) {
this._missWord(wordElement);
}
}, this._config.wordLifetime);
}
_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(() => {
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);
}
@ -797,6 +844,14 @@ class WordStorm extends Module {
}
_correctAnswer(fallingWord) {
// Play success sound
soundSystem.play('coin');
// Clear position check interval
if (fallingWord.positionCheck) {
clearInterval(fallingWord.positionCheck);
}
// Remove from game with epic explosion
if (fallingWord.element.parentNode) {
fallingWord.element.classList.add('exploding');
@ -822,11 +877,15 @@ class WordStorm extends Module {
// Remove from tracking
this._fallingWords = this._fallingWords.filter(fw => fw !== fallingWord);
// Update score
// Update score and combo
this._combo++;
const points = 10 + (this._combo * 2);
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
this._updateHUD();
@ -855,8 +914,14 @@ class WordStorm extends Module {
}
_wrongAnswer() {
// Play error sound
soundSystem.play('enemy_defeat');
this._combo = 0;
// Reset speed to base when combo breaks
this._config.fallSpeedVhPerSecond = 12.5;
// Enhanced wrong answer animation
const answerPanel = document.getElementById('answer-panel');
if (answerPanel) {
@ -929,6 +994,14 @@ class WordStorm extends Module {
}
_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
if (wordElement.parentNode) {
wordElement.remove();
@ -959,8 +1032,8 @@ class WordStorm extends Module {
_levelUp() {
this._level++;
// Increase difficulty by 5% (x1.05 speed = /1.05 time)
this._config.fallSpeed = Math.max(1000, this._config.fallSpeed / 1.05);
// Increase difficulty by 5% (multiply speed by 1.05)
this._config.fallSpeedVhPerSecond = Math.min(50, this._config.fallSpeedVhPerSecond * 1.05);
this._config.spawnRate = Math.max(800, this._config.spawnRate / 1.05);
// Restart intervals with new timing
@ -1020,8 +1093,11 @@ class WordStorm extends Module {
this._spawnInterval = null;
}
// Clear falling words
// Clear falling words and their intervals
this._fallingWords.forEach(fw => {
if (fw.positionCheck) {
clearInterval(fw.positionCheck);
}
if (fw.element.parentNode) {
fw.element.remove();
}
@ -1081,7 +1157,7 @@ class WordStorm extends Module {
this._gameStartTime = Date.now();
// 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;
// Clear existing intervals
@ -1089,8 +1165,11 @@ class WordStorm extends Module {
clearInterval(this._spawnInterval);
}
// Clear falling words
// Clear falling words and their intervals
this._fallingWords.forEach(fw => {
if (fw.positionCheck) {
clearInterval(fw.positionCheck);
}
if (fw.element.parentNode) {
fw.element.remove();
}

View File

@ -232,77 +232,6 @@ p {
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 */
@media (max-width: 768px) {
.app-main {
@ -326,12 +255,6 @@ p {
height: 6px;
}
.debug-panel {
width: calc(100% - 40px);
right: 20px;
left: 20px;
}
.loading-screen h2 {
font-size: 1.25rem;
}
@ -360,8 +283,4 @@ p {
background: #000;
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;