diff --git a/content/chapters/sbs-7-8.json b/content/chapters/sbs-7-8.json
index 95e39a7..f35fac6 100644
--- a/content/chapters/sbs-7-8.json
+++ b/content/chapters/sbs-7-8.json
@@ -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
}
}
\ No newline at end of file
diff --git a/index.html b/index.html
index 1a02e89..e0ffd55 100644
--- a/index.html
+++ b/index.html
@@ -43,32 +43,6 @@
-
-
-
-
-
-
-
-
-
- 🧪 Run Integration Tests
-
-
- 🎨 Run UI/UX Tests
-
-
- 🎬 Run E2E Scenarios
-
-
-
-
@@ -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 @@
${game.metadata.name}
${game.metadata.description}
-
@@ -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);
diff --git a/src/games/FillTheBlank.js b/src/games/FillTheBlank.js
index 976c6e3..326dcc9 100644
--- a/src/games/FillTheBlank.js
+++ b/src/games/FillTheBlank.js
@@ -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} 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) {
diff --git a/src/games/FlashcardLearning.js b/src/games/FlashcardLearning.js
index 36320fd..606430c 100644
--- a/src/games/FlashcardLearning.js
+++ b/src/games/FlashcardLearning.js
@@ -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} 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');
diff --git a/src/games/GrammarDiscovery.js b/src/games/GrammarDiscovery.js
index 85d2898..ff2d267 100644
--- a/src/games/GrammarDiscovery.js
+++ b/src/games/GrammarDiscovery.js
@@ -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}`
});
diff --git a/src/games/MarioEducational.js b/src/games/MarioEducational.js
index f4e4364..ea5ba1b 100644
--- a/src/games/MarioEducational.js
+++ b/src/games/MarioEducational.js
@@ -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} 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;
\ No newline at end of file
diff --git a/src/games/QuizGame.js b/src/games/QuizGame.js
index a6a2be3..3232bd2 100644
--- a/src/games/QuizGame.js
+++ b/src/games/QuizGame.js
@@ -713,7 +713,7 @@ class QuizGame extends Module {
data-pronunciation="${pronunciation}"
title="Click to hear pronunciation">
${optionText}
- ${pronunciation ? `[${pronunciation}]
` : ''}
+ ${pronunciation ? `[${pronunciation}]
` : ''}
`;
}).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} 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');
diff --git a/src/games/RiverRun.js b/src/games/RiverRun.js
index 7424706..15f3315 100644
--- a/src/games/RiverRun.js
+++ b/src/games/RiverRun.js
@@ -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;
diff --git a/src/games/WhackAMole.js b/src/games/WhackAMole.js
index 93c3f36..463da0f 100644
--- a/src/games/WhackAMole.js
+++ b/src/games/WhackAMole.js
@@ -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} 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--) {
diff --git a/src/games/WhackAMoleHard.js b/src/games/WhackAMoleHard.js
index b252b31..3c5aca5 100644
--- a/src/games/WhackAMoleHard.js
+++ b/src/games/WhackAMoleHard.js
@@ -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} 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--) {
diff --git a/src/games/WizardSpellCaster.js b/src/games/WizardSpellCaster.js
index c364374..da4c334 100644
--- a/src/games/WizardSpellCaster.js
+++ b/src/games/WizardSpellCaster.js
@@ -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 {
-
Form your spell incantation:
-
+
Form your spell incantation:
+
diff --git a/src/games/WordDiscovery.js b/src/games/WordDiscovery.js
index f16e1bf..f15a6e4 100644
--- a/src/games/WordDiscovery.js
+++ b/src/games/WordDiscovery.js
@@ -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 {
` : ''}
-
${word.word}
+
+ ${word.word}
+ ${word.pronunciation ? `[${word.pronunciation}] ` : ''}
+
${word.translation ? `
${word.translation}
` : ''}
${word.definition ? `
${word.definition}
` : ''}
${word.example ? `
"${word.example}"
` : ''}
- ${word.audio ? `
+
🔊 Listen
- ` : ''}
+
Next Word
@@ -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} 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 {
Total: ${this._practiceTotal}
Accuracy: ${this._practiceTotal > 0 ? Math.round((this._practiceCorrect / this._practiceTotal) * 100) : 0}%
- Time: ${config.time}
@@ -394,41 +485,16 @@ class WordDiscovery extends Module {
`;
- 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 {
What does this word mean?
- ${correctWord.word}
- ${correctWord.audio ? `
🔊 ` : ''}
+
+ ${correctWord.word}
+ ${correctWord.pronunciation ? `[${correctWord.pronunciation}] ` : ''}
+
+
🔊
${this._practiceOptions.map(option => `
@@ -506,7 +575,10 @@ class WordDiscovery extends Module {
${this._practiceOptions.map(option => `
- ${option.word}
+
+ ${option.word}
+ ${option.pronunciation ? `[${option.pronunciation}] ` : ''}
+
`).join('')}
@@ -524,7 +596,10 @@ class WordDiscovery extends Module {
${this._practiceOptions.map(option => `
- ${option.word}
+
+ ${option.word}
+ ${option.pronunciation ? `[${option.pronunciation}] ` : ''}
+
`).join('')}
@@ -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 {
diff --git a/src/games/WordStorm.js b/src/games/WordStorm.js
index 574c718..3ec7019 100644
--- a/src/games/WordStorm.js
+++ b/src/games/WordStorm.js
@@ -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();
}
diff --git a/src/styles/base.css b/src/styles/base.css
index 99fcc99..de225c1 100644
--- a/src/styles/base.css
+++ b/src/styles/base.css
@@ -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;
- }
}
\ No newline at end of file
diff --git a/src/utils/TTSHelper.js b/src/utils/TTSHelper.js
new file mode 100644
index 0000000..7c1f06c
--- /dev/null
+++ b/src/utils/TTSHelper.js
@@ -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
} 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}
+ */
+ 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;