Add TTS support and improve content compatibility system
Major improvements: - Add TTSHelper utility for text-to-speech functionality - Enhance content compatibility scoring across all games - Improve sentence extraction from multiple content sources - Update all game modules to support diverse content formats - Refine MarioEducational physics and rendering - Polish UI styles and remove unused CSS Games updated: AdventureReader, FillTheBlank, FlashcardLearning, GrammarDiscovery, MarioEducational, QuizGame, RiverRun, WhackAMole, WhackAMoleHard, WizardSpellCaster, WordDiscovery, WordStorm 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
325b97060c
commit
4714a4a1c6
@ -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
|
||||
}
|
||||
}
|
||||
88
index.html
88
index.html
@ -43,32 +43,6 @@
|
||||
<main id="app-main" class="app-main">
|
||||
<!-- Content will be rendered here by modules -->
|
||||
</main>
|
||||
|
||||
<!-- Debug panel (only shown in debug mode) -->
|
||||
<div id="debug-panel" class="debug-panel" style="display: none;">
|
||||
<div class="debug-header">
|
||||
<h3>Debug Panel</h3>
|
||||
<button id="debug-toggle" class="debug-toggle">×</button>
|
||||
</div>
|
||||
<div class="debug-content">
|
||||
<div id="debug-status"></div>
|
||||
<div id="debug-events"></div>
|
||||
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #ccc;">
|
||||
<button id="run-integration-tests" onclick="runDRSTests()"
|
||||
style="background: #28a745; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; width: 100%; margin-bottom: 8px;">
|
||||
🧪 Run Integration Tests
|
||||
</button>
|
||||
<button id="run-uiux-tests" onclick="runUIUXTests()"
|
||||
style="background: #6f42c1; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; width: 100%; margin-bottom: 8px;">
|
||||
🎨 Run UI/UX Tests
|
||||
</button>
|
||||
<button id="run-e2e-tests" onclick="runE2ETests()"
|
||||
style="background: #fd7e14; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; width: 100%;">
|
||||
🎬 Run E2E Scenarios
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -151,14 +125,6 @@
|
||||
if (appContainer) appContainer.style.display = 'block';
|
||||
|
||||
// Smart Preview Orchestrator is automatically initialized by Application.js
|
||||
|
||||
// Show debug panel if enabled
|
||||
const status = app.getStatus();
|
||||
if (status.config.enableDebug) {
|
||||
const debugPanel = document.getElementById('debug-panel');
|
||||
if (debugPanel) debugPanel.style.display = 'block';
|
||||
updateDebugInfo();
|
||||
}
|
||||
}, 'Bootstrap');
|
||||
|
||||
// Handle navigation events
|
||||
@ -411,9 +377,11 @@
|
||||
<h3>${game.metadata.name}</h3>
|
||||
<p class="game-description">${game.metadata.description}</p>
|
||||
<div class="game-meta">
|
||||
<span class="difficulty-badge difficulty-${game.metadata.difficulty}">
|
||||
${game.metadata.difficulty}
|
||||
</span>
|
||||
${typeof game.metadata.difficulty === 'string' ? `
|
||||
<span class="difficulty-badge difficulty-${game.metadata.difficulty}">
|
||||
${game.metadata.difficulty}
|
||||
</span>
|
||||
` : ''}
|
||||
<span class="compatibility-score ${game.compatibility.score >= 0.7 ? 'high' : game.compatibility.score >= 0.3 ? 'medium' : 'low'}">
|
||||
${Math.round(game.compatibility.score * 100)}% compatible
|
||||
</span>
|
||||
@ -585,9 +553,6 @@
|
||||
|
||||
// Set up keyboard shortcuts after app is ready
|
||||
setupKeyboardShortcuts();
|
||||
|
||||
// Set up debug panel after app is ready
|
||||
setupDebugPanel();
|
||||
}
|
||||
|
||||
// Tooltip functions - Make them global
|
||||
@ -644,32 +609,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function updateDebugInfo() {
|
||||
const debugStatus = document.getElementById('debug-status');
|
||||
if (debugStatus && app) {
|
||||
const status = app.getStatus();
|
||||
debugStatus.innerHTML = `
|
||||
<h4>System Status</h4>
|
||||
<ul>
|
||||
<li>Running: ${status.isRunning}</li>
|
||||
<li>Uptime: ${Math.round(status.uptime / 1000)}s</li>
|
||||
<li>Loaded Modules: ${status.modules.loaded.length}</li>
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function setupDebugPanel() {
|
||||
const debugToggle = document.getElementById('debug-toggle');
|
||||
const debugPanel = document.getElementById('debug-panel');
|
||||
|
||||
if (debugToggle && debugPanel) {
|
||||
debugToggle.addEventListener('click', () => {
|
||||
debugPanel.style.display = 'none';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setupKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', (event) => {
|
||||
try {
|
||||
@ -729,9 +668,24 @@
|
||||
}
|
||||
};
|
||||
|
||||
window.navigateToChapters = function(bookId) {
|
||||
window.navigateToChapters = function(chapterIdOrBookId) {
|
||||
try {
|
||||
const { router } = app.getCore();
|
||||
|
||||
// If a chapterId is passed (e.g., "sbs-7-8"), extract the bookId from it
|
||||
// If a bookId is passed (e.g., "sbs"), use it directly
|
||||
let bookId = chapterIdOrBookId;
|
||||
|
||||
// Check if window.currentBookId is already set (most reliable)
|
||||
if (window.currentBookId) {
|
||||
bookId = window.currentBookId;
|
||||
} else if (chapterIdOrBookId && chapterIdOrBookId.includes('-')) {
|
||||
// Extract bookId from chapterId (e.g., "sbs-7-8" -> "sbs")
|
||||
const parts = chapterIdOrBookId.split('-');
|
||||
bookId = parts[0];
|
||||
}
|
||||
|
||||
console.log(`📚 Navigating to chapters for book: ${bookId}`);
|
||||
router.navigate(`/chapters/${bookId || ''}`);
|
||||
} catch (error) {
|
||||
console.error('Navigation error:', error);
|
||||
|
||||
@ -791,7 +791,7 @@ class VocabularyModule extends DRSExerciseInterface {
|
||||
}
|
||||
}
|
||||
|
||||
_speakWord(text, options = {}) {
|
||||
async _speakWord(text, options = {}) {
|
||||
// Check if browser supports Speech Synthesis
|
||||
if ('speechSynthesis' in window) {
|
||||
try {
|
||||
@ -807,8 +807,8 @@ class VocabularyModule extends DRSExerciseInterface {
|
||||
utterance.pitch = options.pitch || 1;
|
||||
utterance.volume = options.volume || 1;
|
||||
|
||||
// Try to find a suitable voice for the language
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
// Wait for voices to be loaded before selecting one
|
||||
const voices = await this._getVoices();
|
||||
if (voices.length > 0) {
|
||||
// Find voice matching the chapter language
|
||||
const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
|
||||
@ -819,6 +819,8 @@ class VocabularyModule extends DRSExerciseInterface {
|
||||
if (matchingVoice) {
|
||||
utterance.voice = matchingVoice;
|
||||
console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang);
|
||||
} else {
|
||||
console.warn(`🔊 No voice found for language: ${chapterLanguage}, available:`, voices.map(v => v.lang));
|
||||
}
|
||||
}
|
||||
|
||||
@ -851,6 +853,40 @@ class VocabularyModule extends DRSExerciseInterface {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available speech synthesis voices, waiting for them to load if necessary
|
||||
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||
* @private
|
||||
*/
|
||||
_getVoices() {
|
||||
return new Promise((resolve) => {
|
||||
let voices = window.speechSynthesis.getVoices();
|
||||
|
||||
// If voices are already loaded, return them immediately
|
||||
if (voices.length > 0) {
|
||||
resolve(voices);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, wait for voiceschanged event
|
||||
const voicesChangedHandler = () => {
|
||||
voices = window.speechSynthesis.getVoices();
|
||||
if (voices.length > 0) {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(voices);
|
||||
}
|
||||
};
|
||||
|
||||
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||
|
||||
// Fallback timeout in case voices never load
|
||||
setTimeout(() => {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(window.speechSynthesis.getVoices());
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
_updateTTSButton(isPlaying) {
|
||||
// Update main TTS button
|
||||
const ttsBtn = document.getElementById('tts-btn');
|
||||
|
||||
@ -122,7 +122,7 @@ export class PhysicsEngine {
|
||||
static checkCollisions(mario, gameState, callbacks) {
|
||||
const {
|
||||
platforms, questionBlocks, enemies, walls, catapults,
|
||||
piranhaPlants, boulders
|
||||
piranhaPlants, boulders, flyingEyes
|
||||
} = gameState;
|
||||
|
||||
const {
|
||||
@ -165,41 +165,43 @@ export class PhysicsEngine {
|
||||
}
|
||||
});
|
||||
|
||||
// Question block collisions
|
||||
// Question block collisions (non-blocking - just trigger on contact)
|
||||
questionBlocks.forEach(block => {
|
||||
if (!block.hit && this.isColliding(mario, block)) {
|
||||
// Check if Mario hit from below
|
||||
if (mario.velocityY < 0 && mario.y < block.y + block.height) {
|
||||
if (onQuestionBlock) onQuestionBlock(block);
|
||||
}
|
||||
// Solid collision (treat as platform)
|
||||
else if (mario.velocityY > 0) {
|
||||
mario.y = block.y - mario.height;
|
||||
mario.velocityY = 0;
|
||||
mario.onGround = true;
|
||||
}
|
||||
// Trigger question block on any contact (pass-through)
|
||||
if (onQuestionBlock) onQuestionBlock(block);
|
||||
}
|
||||
});
|
||||
|
||||
// Wall collisions
|
||||
walls.forEach(wall => {
|
||||
if (this.isColliding(mario, wall)) {
|
||||
// Side collision
|
||||
if (mario.velocityX > 0) {
|
||||
mario.x = wall.x - mario.width;
|
||||
} else if (mario.velocityX < 0) {
|
||||
mario.x = wall.x + wall.width;
|
||||
}
|
||||
mario.velocityX = 0;
|
||||
// Determine collision direction based on previous position
|
||||
const overlapLeft = (mario.x + mario.width) - wall.x;
|
||||
const overlapRight = (wall.x + wall.width) - mario.x;
|
||||
const overlapTop = (mario.y + mario.height) - wall.y;
|
||||
const overlapBottom = (wall.y + wall.height) - mario.y;
|
||||
|
||||
// Top/bottom collision
|
||||
if (mario.velocityY > 0) {
|
||||
// Find the smallest overlap to determine collision side
|
||||
const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom);
|
||||
|
||||
if (minOverlap === overlapTop && mario.velocityY > 0) {
|
||||
// Landing on top of wall
|
||||
mario.y = wall.y - mario.height;
|
||||
mario.velocityY = 0;
|
||||
mario.onGround = true;
|
||||
} else if (mario.velocityY < 0) {
|
||||
} else if (minOverlap === overlapBottom && mario.velocityY < 0) {
|
||||
// Hit wall from below
|
||||
mario.y = wall.y + wall.height;
|
||||
mario.velocityY = 0;
|
||||
} else if (minOverlap === overlapLeft && mario.velocityX > 0) {
|
||||
// Hit wall from left side
|
||||
mario.x = wall.x - mario.width;
|
||||
mario.velocityX = 0;
|
||||
} else if (minOverlap === overlapRight && mario.velocityX < 0) {
|
||||
// Hit wall from right side
|
||||
mario.x = wall.x + wall.width;
|
||||
mario.velocityX = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -256,6 +258,36 @@ export class PhysicsEngine {
|
||||
mario.onGround = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Flying Eye collisions
|
||||
if (flyingEyes) {
|
||||
flyingEyes.forEach((eye, index) => {
|
||||
// Eye position is CENTER, convert to top-left corner for collision
|
||||
const eyeLeft = eye.x - eye.width / 2;
|
||||
const eyeTop = eye.y - eye.height / 2;
|
||||
const eyeRect = {
|
||||
x: eyeLeft,
|
||||
y: eyeTop,
|
||||
width: eye.width,
|
||||
height: eye.height
|
||||
};
|
||||
|
||||
if (this.isColliding(mario, eyeRect)) {
|
||||
// Check if Mario jumped on eye from above
|
||||
if (mario.velocityY > 0 && mario.y < eyeTop + eye.height / 2) {
|
||||
// Eye defeated
|
||||
mario.velocityY = -8; // Bounce
|
||||
flyingEyes.splice(index, 1); // Remove eye
|
||||
if (onAddParticles) onAddParticles(eye.x, eye.y, '#DC143C');
|
||||
console.log(`👁️ Mario defeated flying eye`);
|
||||
} else {
|
||||
// Mario hit by eye
|
||||
console.log(`👁️ Mario hit by flying eye - restarting level`);
|
||||
if (onMarioDeath) onMarioDeath();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -47,8 +47,8 @@ export class Renderer {
|
||||
// Level 6 boss elements
|
||||
if (gameState.boss) this.renderBoss(ctx, gameState.boss);
|
||||
|
||||
// Castle
|
||||
if (gameState.castle) this.renderCastle(ctx, gameState.castle);
|
||||
// Castle structure (emoji-based)
|
||||
if (gameState.castleStructure) this.renderCastleStructure(ctx, gameState.castleStructure);
|
||||
|
||||
// Finish line
|
||||
if (gameState.finishLine) this.renderFinishLine(ctx, gameState.finishLine, gameState.currentLevel);
|
||||
@ -447,52 +447,30 @@ export class Renderer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render castle
|
||||
* Render castle structure with emojis
|
||||
*/
|
||||
renderCastle(ctx, castle) {
|
||||
if (!castle) return;
|
||||
renderCastleStructure(ctx, castleStructure) {
|
||||
if (!castleStructure) return;
|
||||
|
||||
// Main castle body
|
||||
ctx.fillStyle = '#888';
|
||||
ctx.fillRect(castle.x, castle.y, castle.width, castle.height);
|
||||
// Draw castle emoji
|
||||
ctx.font = `${castleStructure.size}px Arial`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(
|
||||
castleStructure.emoji,
|
||||
castleStructure.x,
|
||||
castleStructure.y
|
||||
);
|
||||
|
||||
// Towers
|
||||
const towerWidth = 40;
|
||||
const towerHeight = 80;
|
||||
|
||||
// Left tower
|
||||
ctx.fillRect(castle.x - 20, castle.y - 30, towerWidth, towerHeight);
|
||||
|
||||
// Right tower
|
||||
ctx.fillRect(castle.x + castle.width - 20, castle.y - 30, towerWidth, towerHeight);
|
||||
|
||||
// Tower tops (triangular)
|
||||
ctx.fillStyle = '#666';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(castle.x - 20, castle.y - 30);
|
||||
ctx.lineTo(castle.x + 20, castle.y - 60);
|
||||
ctx.lineTo(castle.x + 20, castle.y - 30);
|
||||
ctx.fill();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(castle.x + castle.width - 20, castle.y - 30);
|
||||
ctx.lineTo(castle.x + castle.width + 20, castle.y - 60);
|
||||
ctx.lineTo(castle.x + castle.width + 20, castle.y - 30);
|
||||
ctx.fill();
|
||||
|
||||
// Door
|
||||
ctx.fillStyle = '#654321';
|
||||
ctx.fillRect(castle.x + castle.width / 2 - 20, castle.y + castle.height - 50, 40, 50);
|
||||
|
||||
// Windows
|
||||
ctx.fillStyle = '#FFFF00';
|
||||
ctx.fillRect(castle.x + 20, castle.y + 20, 15, 20);
|
||||
ctx.fillRect(castle.x + castle.width - 35, castle.y + 20, 15, 20);
|
||||
|
||||
// Borders
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(castle.x, castle.y, castle.width, castle.height);
|
||||
// Draw princess emoji (if exists)
|
||||
if (castleStructure.princess) {
|
||||
ctx.font = `${castleStructure.princess.size}px Arial`;
|
||||
ctx.fillText(
|
||||
castleStructure.princess.emoji,
|
||||
castleStructure.princess.x,
|
||||
castleStructure.princess.y
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -599,25 +577,150 @@ export class Renderer {
|
||||
* Render debug hitboxes (for development)
|
||||
*/
|
||||
renderDebugHitboxes(ctx, gameState) {
|
||||
ctx.strokeStyle = '#FF00FF';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
// Mario hitbox
|
||||
// Mario hitbox (GREEN)
|
||||
ctx.strokeStyle = '#00FF00';
|
||||
ctx.strokeRect(gameState.mario.x, gameState.mario.y, gameState.mario.width, gameState.mario.height);
|
||||
|
||||
// Enemy hitboxes
|
||||
// Mario position info
|
||||
ctx.fillStyle = '#00FF00';
|
||||
ctx.font = 'bold 10px monospace';
|
||||
ctx.fillText(`M: ${Math.floor(gameState.mario.x)},${Math.floor(gameState.mario.y)}`,
|
||||
gameState.mario.x, gameState.mario.y - 5);
|
||||
|
||||
// Enemy hitboxes (RED)
|
||||
if (gameState.enemies) {
|
||||
gameState.enemies.forEach(enemy => {
|
||||
ctx.strokeStyle = '#FF0000';
|
||||
gameState.enemies.forEach((enemy, idx) => {
|
||||
ctx.strokeRect(enemy.x, enemy.y, enemy.width, enemy.height);
|
||||
ctx.fillStyle = '#FF0000';
|
||||
ctx.fillText(`E${idx}: ${enemy.type || 'goomba'}`, enemy.x, enemy.y - 5);
|
||||
});
|
||||
}
|
||||
|
||||
// Platform hitboxes
|
||||
// Platform hitboxes (CYAN)
|
||||
if (gameState.platforms) {
|
||||
gameState.platforms.forEach(platform => {
|
||||
ctx.strokeStyle = '#00FFFF';
|
||||
gameState.platforms.forEach((platform, idx) => {
|
||||
ctx.strokeRect(platform.x, platform.y, platform.width, platform.height);
|
||||
// Only show info for floating platforms (not ground)
|
||||
if (platform.type !== 'ground') {
|
||||
ctx.fillStyle = '#00FFFF';
|
||||
ctx.fillText(`P${idx}: ${platform.type}`, platform.x, platform.y - 5);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Question block hitboxes (YELLOW)
|
||||
if (gameState.questionBlocks) {
|
||||
ctx.strokeStyle = '#FFFF00';
|
||||
gameState.questionBlocks.forEach((block, idx) => {
|
||||
ctx.strokeRect(block.x, block.y, block.width, block.height);
|
||||
ctx.fillStyle = '#FFFF00';
|
||||
ctx.fillText(`Q${idx}: ${block.hit ? 'HIT' : 'ACTIVE'}`, block.x, block.y - 5);
|
||||
});
|
||||
}
|
||||
|
||||
// Wall hitboxes (ORANGE)
|
||||
if (gameState.walls) {
|
||||
ctx.strokeStyle = '#FF8800';
|
||||
gameState.walls.forEach((wall, idx) => {
|
||||
ctx.strokeRect(wall.x, wall.y, wall.width, wall.height);
|
||||
ctx.fillStyle = '#FF8800';
|
||||
ctx.fillText(`W${idx}: ${Math.floor(wall.height)}h`, wall.x, wall.y - 5);
|
||||
});
|
||||
}
|
||||
|
||||
// Boss hitboxes (MAGENTA)
|
||||
if (gameState.boss) {
|
||||
ctx.strokeStyle = '#FF00FF';
|
||||
ctx.strokeRect(gameState.boss.x, gameState.boss.y, gameState.boss.width, gameState.boss.height);
|
||||
|
||||
// Boss knee hitboxes (smaller boxes)
|
||||
if (gameState.boss.leftKnee) {
|
||||
ctx.strokeStyle = '#FF00FF';
|
||||
ctx.strokeRect(
|
||||
gameState.boss.leftKnee.x,
|
||||
gameState.boss.leftKnee.y,
|
||||
gameState.boss.leftKnee.width,
|
||||
gameState.boss.leftKnee.height
|
||||
);
|
||||
ctx.fillStyle = '#FF00FF';
|
||||
ctx.fillText('LEFT KNEE', gameState.boss.leftKnee.x, gameState.boss.leftKnee.y - 5);
|
||||
}
|
||||
|
||||
if (gameState.boss.rightKnee) {
|
||||
ctx.strokeStyle = '#FF00FF';
|
||||
ctx.strokeRect(
|
||||
gameState.boss.rightKnee.x,
|
||||
gameState.boss.rightKnee.y,
|
||||
gameState.boss.rightKnee.width,
|
||||
gameState.boss.rightKnee.height
|
||||
);
|
||||
ctx.fillStyle = '#FF00FF';
|
||||
ctx.fillText('RIGHT KNEE', gameState.boss.rightKnee.x, gameState.boss.rightKnee.y - 5);
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#FF00FF';
|
||||
ctx.fillText(`BOSS HP: ${gameState.boss.health}/${gameState.boss.maxHealth}`,
|
||||
gameState.boss.x, gameState.boss.y - 5);
|
||||
}
|
||||
|
||||
// Flying eyes hitboxes (PINK)
|
||||
if (gameState.flyingEyes) {
|
||||
ctx.strokeStyle = '#FF69B4';
|
||||
gameState.flyingEyes.forEach((eye, idx) => {
|
||||
// Eye position is CENTER - draw rectangle hitbox as it's used in collision
|
||||
const eyeLeft = eye.x - eye.width / 2;
|
||||
const eyeTop = eye.y - eye.height / 2;
|
||||
|
||||
// Draw rectangle hitbox (actual collision box)
|
||||
ctx.strokeRect(eyeLeft, eyeTop, eye.width, eye.height);
|
||||
|
||||
// Draw center point
|
||||
ctx.fillStyle = '#FF69B4';
|
||||
ctx.fillRect(eye.x - 2, eye.y - 2, 4, 4);
|
||||
|
||||
ctx.fillText(`EYE${idx}: ${eye.isChasing ? 'CHASE' : 'IDLE'}`, eyeLeft, eyeTop - 5);
|
||||
});
|
||||
}
|
||||
|
||||
// Projectiles hitboxes (RED)
|
||||
if (gameState.projectiles) {
|
||||
ctx.strokeStyle = '#FF4444';
|
||||
gameState.projectiles.forEach((proj, idx) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(proj.x, proj.y, proj.radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
// Boulders hitboxes (GRAY)
|
||||
if (gameState.boulders) {
|
||||
ctx.strokeStyle = '#888888';
|
||||
gameState.boulders.forEach((boulder, idx) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(boulder.x, boulder.y, boulder.radius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = '#888888';
|
||||
ctx.fillText(`B${idx}: ${boulder.hasLanded ? 'LANDED' : 'FLYING'}`,
|
||||
boulder.x - 20, boulder.y - boulder.radius - 5);
|
||||
});
|
||||
}
|
||||
|
||||
// Debug info overlay
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||
ctx.fillRect(10, 50, 200, 80);
|
||||
|
||||
ctx.fillStyle = '#00FF00';
|
||||
ctx.font = 'bold 12px monospace';
|
||||
ctx.fillText('DEBUG MODE (D to toggle)', 15, 65);
|
||||
ctx.fillText(`Mario: ${Math.floor(gameState.mario.x)}, ${Math.floor(gameState.mario.y)}`, 15, 80);
|
||||
ctx.fillText(`Velocity: ${gameState.mario.velocityX.toFixed(1)}, ${gameState.mario.velocityY.toFixed(1)}`, 15, 95);
|
||||
ctx.fillText(`Ground: ${gameState.mario.onGround ? 'YES' : 'NO'}`, 15, 110);
|
||||
ctx.fillText(`Facing: ${gameState.mario.facing}`, 15, 125);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -68,8 +68,10 @@ export class FlyingEye {
|
||||
const currentTime = Date.now();
|
||||
|
||||
eyes.forEach(eye => {
|
||||
// Calculate distance from eye center to Mario center
|
||||
const marioCenter = { x: mario.x + mario.width / 2, y: mario.y + mario.height / 2 };
|
||||
const distanceToMario = Math.sqrt(
|
||||
Math.pow(eye.x - mario.x, 2) + Math.pow(eye.y - mario.y, 2)
|
||||
Math.pow(eye.x - marioCenter.x, 2) + Math.pow(eye.y - marioCenter.y, 2)
|
||||
);
|
||||
|
||||
// Blinking animation
|
||||
@ -100,9 +102,9 @@ export class FlyingEye {
|
||||
eye.dashDuration = 30; // 30 frames of dash
|
||||
eye.lastDashTime = currentTime;
|
||||
|
||||
// Set dash velocity towards Mario
|
||||
const dx = mario.x - eye.x;
|
||||
const dy = mario.y - eye.y;
|
||||
// Set dash velocity towards Mario center
|
||||
const dx = marioCenter.x - eye.x;
|
||||
const dy = marioCenter.y - eye.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
eye.velocityX = (dx / distance) * eye.dashSpeed;
|
||||
eye.velocityY = (dy / distance) * eye.dashSpeed;
|
||||
@ -116,9 +118,9 @@ export class FlyingEye {
|
||||
eye.x += eye.velocityX;
|
||||
eye.y += eye.velocityY;
|
||||
} else if (eye.isChasing) {
|
||||
// Chase Mario
|
||||
const dx = mario.x - eye.x;
|
||||
const dy = mario.y - eye.y;
|
||||
// Chase Mario center
|
||||
const dx = marioCenter.x - eye.x;
|
||||
const dy = marioCenter.y - eye.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
eye.velocityX = (dx / distance) * eye.chaseSpeed;
|
||||
@ -139,6 +141,7 @@ export class FlyingEye {
|
||||
}
|
||||
|
||||
// Keep eyes within bounds (with some margin)
|
||||
// Allow eyes to fly anywhere but not too close to edges
|
||||
if (eye.x < 50) {
|
||||
eye.x = 50;
|
||||
eye.velocityX = Math.abs(eye.velocityX);
|
||||
@ -147,8 +150,9 @@ export class FlyingEye {
|
||||
eye.y = 50;
|
||||
eye.velocityY = Math.abs(eye.velocityY);
|
||||
}
|
||||
if (eye.y > 400) {
|
||||
eye.y = 400;
|
||||
// Allow eyes to go much lower (near ground level) - 600px is just above ground (640)
|
||||
if (eye.y > 600) {
|
||||
eye.y = 600;
|
||||
eye.velocityY = -Math.abs(eye.velocityY);
|
||||
}
|
||||
});
|
||||
@ -162,11 +166,15 @@ export class FlyingEye {
|
||||
*/
|
||||
static checkCollision(mario, eyes) {
|
||||
for (const eye of eyes) {
|
||||
// Simple rectangle collision
|
||||
if (mario.x < eye.x + eye.width &&
|
||||
mario.x + mario.width > eye.x &&
|
||||
mario.y < eye.y + eye.height &&
|
||||
mario.y + mario.height > eye.y) {
|
||||
// Eye position is CENTER, convert to top-left corner for collision
|
||||
const eyeLeft = eye.x - eye.width / 2;
|
||||
const eyeTop = eye.y - eye.height / 2;
|
||||
|
||||
// Rectangle collision with centered eye position
|
||||
if (mario.x < eyeLeft + eye.width &&
|
||||
mario.x + mario.width > eyeLeft &&
|
||||
mario.y < eyeTop + eye.height &&
|
||||
mario.y + mario.height > eyeTop) {
|
||||
return eye;
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,14 +72,47 @@ class AdventureReader extends Module {
|
||||
*/
|
||||
static getCompatibilityScore(content) {
|
||||
const vocab = content?.vocabulary || {};
|
||||
const sentences = content?.sentences || [];
|
||||
const stories = content?.story?.chapters || content?.texts || [];
|
||||
const dialogues = content?.dialogues || [];
|
||||
const stories = content?.story?.chapters || content?.texts || [];
|
||||
|
||||
const vocabCount = Object.keys(vocab).length;
|
||||
const sentenceCount = sentences.length;
|
||||
const storyCount = stories.length;
|
||||
const dialogueCount = dialogues.length;
|
||||
const storyCount = stories.length;
|
||||
|
||||
// Count sentences from ALL possible sources (matching _extractSentences logic)
|
||||
let sentenceCount = 0;
|
||||
|
||||
// From story chapters
|
||||
if (content?.story?.chapters) {
|
||||
content.story.chapters.forEach(chapter => {
|
||||
if (chapter.sentences) {
|
||||
sentenceCount += chapter.sentences.filter(s => s.original && s.translation).length;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// From direct sentences array
|
||||
if (content?.sentences) {
|
||||
sentenceCount += content.sentences.length;
|
||||
}
|
||||
|
||||
// From phrases (array or object format)
|
||||
if (content?.phrases) {
|
||||
if (Array.isArray(content.phrases)) {
|
||||
sentenceCount += content.phrases.filter(p => p.chinese && p.english).length;
|
||||
} else if (typeof content.phrases === 'object') {
|
||||
sentenceCount += Object.keys(content.phrases).length;
|
||||
}
|
||||
}
|
||||
|
||||
// From lessons
|
||||
if (content?.lessons) {
|
||||
content.lessons.forEach(lesson => {
|
||||
if (lesson.sentences) {
|
||||
sentenceCount += lesson.sentences.filter(s => s.chinese && s.english).length;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const totalContent = vocabCount + sentenceCount + storyCount + dialogueCount;
|
||||
|
||||
@ -93,18 +126,26 @@ class AdventureReader extends Module {
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate weighted score based on content diversity
|
||||
// Calculate weighted score based on content diversity and quantity
|
||||
let score = 0;
|
||||
if (vocabCount > 0) score += Math.min(vocabCount / 10, 0.3);
|
||||
if (sentenceCount > 0) score += Math.min(sentenceCount / 10, 0.3);
|
||||
if (storyCount > 0) score += Math.min(storyCount / 5, 0.2);
|
||||
if (dialogueCount > 0) score += Math.min(dialogueCount / 3, 0.2);
|
||||
|
||||
// Vocabulary: 0.3 points max (reach 100% at 8+ items)
|
||||
if (vocabCount > 0) score += Math.min(vocabCount / 8, 1) * 0.3;
|
||||
|
||||
// Sentences: 0.4 points max (reach 100% at 8+ items) - most important for gameplay
|
||||
if (sentenceCount > 0) score += Math.min(sentenceCount / 8, 1) * 0.4;
|
||||
|
||||
// Stories: 0.15 points max (reach 100% at 3+ items)
|
||||
if (storyCount > 0) score += Math.min(storyCount / 3, 1) * 0.15;
|
||||
|
||||
// Dialogues: 0.15 points max (reach 100% at 3+ items)
|
||||
if (dialogueCount > 0) score += Math.min(dialogueCount / 3, 1) * 0.15;
|
||||
|
||||
return {
|
||||
score: Math.min(score, 1),
|
||||
reason: `Adventure content: ${vocabCount} vocab, ${sentenceCount} sentences, ${storyCount} stories, ${dialogueCount} dialogues`,
|
||||
requirements: ['vocabulary', 'sentences', 'stories', 'dialogues'],
|
||||
optimalContent: { vocab: 10, sentences: 10, stories: 5, dialogues: 3 },
|
||||
optimalContent: { vocab: 8, sentences: 8, stories: 3, dialogues: 3 },
|
||||
details: `Rich adventure content with ${totalContent} total elements`
|
||||
};
|
||||
}
|
||||
@ -135,12 +176,16 @@ class AdventureReader extends Module {
|
||||
|
||||
// Initialize game interface
|
||||
this._createGameInterface();
|
||||
this._initializePlayer();
|
||||
this._setupEventListeners();
|
||||
this._updateContentInfo();
|
||||
this._generateGameObjects();
|
||||
this._generateDecorations();
|
||||
this._startGameLoop();
|
||||
|
||||
// Wait for DOM to render before initializing player
|
||||
requestAnimationFrame(() => {
|
||||
this._initializePlayer();
|
||||
this._setupEventListeners();
|
||||
this._updateContentInfo();
|
||||
this._generateGameObjects();
|
||||
this._generateDecorations();
|
||||
this._startGameLoop();
|
||||
});
|
||||
|
||||
// Start the game
|
||||
this._gameStartTime = Date.now();
|
||||
@ -246,6 +291,8 @@ class AdventureReader extends Module {
|
||||
_extractSentences() {
|
||||
let sentences = [];
|
||||
|
||||
console.log('AdventureReader: Extracting sentences from content', this._content);
|
||||
|
||||
// Support for Dragon's Pearl structure
|
||||
if (this._content.story?.chapters) {
|
||||
this._content.story.chapters.forEach(chapter => {
|
||||
@ -268,13 +315,61 @@ class AdventureReader extends Module {
|
||||
if (this._content.sentences) {
|
||||
this._content.sentences.forEach(sentence => {
|
||||
sentences.push({
|
||||
original_language: sentence.english || sentence.original_language,
|
||||
original_language: sentence.english || sentence.original_language || sentence.target_language,
|
||||
user_language: sentence.chinese || sentence.french || sentence.user_language || sentence.translation,
|
||||
pronunciation: sentence.pronunciation || sentence.prononciation
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Support for LEDU format with phrases/lessons
|
||||
if (this._content.phrases) {
|
||||
// Check if phrases is an array or object
|
||||
if (Array.isArray(this._content.phrases)) {
|
||||
this._content.phrases.forEach(phrase => {
|
||||
if (phrase.chinese && phrase.english) {
|
||||
sentences.push({
|
||||
original_language: phrase.chinese,
|
||||
user_language: phrase.english,
|
||||
pronunciation: phrase.pinyin
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (typeof this._content.phrases === 'object') {
|
||||
// Handle object format (key-value pairs)
|
||||
Object.entries(this._content.phrases).forEach(([phraseText, phraseData]) => {
|
||||
const translation = typeof phraseData === 'object' ? phraseData.user_language : phraseData;
|
||||
const pronunciation = typeof phraseData === 'object' ? phraseData.pronunciation : undefined;
|
||||
|
||||
if (phraseText && translation) {
|
||||
sentences.push({
|
||||
original_language: phraseText,
|
||||
user_language: translation,
|
||||
pronunciation: pronunciation
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Support for lessons with sentences
|
||||
if (this._content.lessons) {
|
||||
this._content.lessons.forEach(lesson => {
|
||||
if (lesson.sentences) {
|
||||
lesson.sentences.forEach(sentence => {
|
||||
if (sentence.chinese && sentence.english) {
|
||||
sentences.push({
|
||||
original_language: sentence.chinese,
|
||||
user_language: sentence.english,
|
||||
pronunciation: sentence.pinyin
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('AdventureReader: Extracted sentences:', sentences.length);
|
||||
return sentences.filter(s => s.original_language && s.user_language);
|
||||
}
|
||||
|
||||
@ -364,14 +459,14 @@ class AdventureReader extends Module {
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 1.2rem;
|
||||
font-size: 0.72rem; /* 1.2 / 1.66 */
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 8px 15px;
|
||||
border-radius: 15px;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.54rem; /* 0.9 / 1.66 */
|
||||
}
|
||||
|
||||
.game-map {
|
||||
@ -384,7 +479,7 @@ class AdventureReader extends Module {
|
||||
|
||||
.player {
|
||||
position: absolute;
|
||||
font-size: 2.5rem;
|
||||
font-size: 1.51rem; /* 2.5 / 1.66 */
|
||||
z-index: 50;
|
||||
transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.3));
|
||||
@ -393,7 +488,7 @@ class AdventureReader extends Module {
|
||||
|
||||
.pot, .enemy {
|
||||
position: absolute;
|
||||
font-size: 2rem;
|
||||
font-size: 1.2rem; /* 2 / 1.66 */
|
||||
cursor: pointer;
|
||||
z-index: 30;
|
||||
transition: all 0.3s ease;
|
||||
@ -443,12 +538,12 @@ class AdventureReader extends Module {
|
||||
}
|
||||
|
||||
.instructions {
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.54rem; /* 0.9 / 1.66 */
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.content-summary {
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.51rem; /* 0.85 / 1.66 */
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
@ -459,7 +554,7 @@ class AdventureReader extends Module {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.54rem; /* 0.9 / 1.66 */
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
@ -531,7 +626,7 @@ class AdventureReader extends Module {
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
font-size: 0.78rem; /* 1.3 / 1.66 */
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@ -560,7 +655,7 @@ class AdventureReader extends Module {
|
||||
}
|
||||
|
||||
.original-text {
|
||||
font-size: 1.4rem;
|
||||
font-size: 0.84rem; /* 1.4 / 1.66 */
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 15px;
|
||||
@ -568,14 +663,14 @@ class AdventureReader extends Module {
|
||||
}
|
||||
|
||||
.translation-text {
|
||||
font-size: 1.1rem;
|
||||
font-size: 0.66rem; /* 1.1 / 1.66 */
|
||||
color: #6b7280;
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.pronunciation-text {
|
||||
font-size: 1rem;
|
||||
font-size: 0.60rem; /* 1.0 / 1.66 */
|
||||
color: #7c3aed;
|
||||
font-style: italic;
|
||||
}
|
||||
@ -601,19 +696,19 @@ class AdventureReader extends Module {
|
||||
}
|
||||
|
||||
.vocab-word {
|
||||
font-size: 2rem;
|
||||
font-size: 1.2rem; /* 2.0 / 1.66 */
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.vocab-translation {
|
||||
font-size: 1.3rem;
|
||||
font-size: 0.78rem; /* 1.3 / 1.66 */
|
||||
margin-bottom: 10px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.vocab-pronunciation {
|
||||
font-size: 1rem;
|
||||
font-size: 0.60rem; /* 1.0 / 1.66 */
|
||||
opacity: 0.8;
|
||||
font-style: italic;
|
||||
}
|
||||
@ -1042,9 +1137,6 @@ class AdventureReader extends Module {
|
||||
<!-- Sentence content -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="control-btn primary" id="continue-btn">Continue Adventure →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1084,7 +1176,6 @@ class AdventureReader extends Module {
|
||||
_setupEventListeners() {
|
||||
// Control buttons
|
||||
document.getElementById('restart-btn').addEventListener('click', () => this._restart());
|
||||
document.getElementById('continue-btn').addEventListener('click', () => this._closeModal());
|
||||
|
||||
// Exit button
|
||||
const exitButton = document.getElementById('exit-adventure');
|
||||
@ -1199,7 +1290,7 @@ class AdventureReader extends Module {
|
||||
y: position.y,
|
||||
defeated: false,
|
||||
moveDirection: Math.random() * Math.PI * 2,
|
||||
speed: 0.6 + Math.random() * 0.6,
|
||||
speed: 1.2 + Math.random() * 1.2, // 2x faster (was 0.6 + 0.6)
|
||||
pattern: pattern,
|
||||
patrolStartX: position.x,
|
||||
patrolStartY: position.y,
|
||||
@ -1209,7 +1300,8 @@ class AdventureReader extends Module {
|
||||
circleAngle: Math.random() * Math.PI * 2,
|
||||
changeDirectionTimer: 0,
|
||||
dashCooldown: 0,
|
||||
isDashing: false
|
||||
isDashing: false,
|
||||
dashDuration: 0
|
||||
};
|
||||
}
|
||||
|
||||
@ -1257,7 +1349,7 @@ class AdventureReader extends Module {
|
||||
const position = this._getDecorationPosition(mapWidth, mapHeight, 60);
|
||||
tree.style.left = position.x + 'px';
|
||||
tree.style.top = position.y + 'px';
|
||||
tree.style.fontSize = (25 + Math.random() * 15) + 'px';
|
||||
tree.style.fontSize = ((25 + Math.random() * 15) / 1.66) + 'px'; // Reduced by 1.66
|
||||
|
||||
gameMap.appendChild(tree);
|
||||
}
|
||||
@ -1273,7 +1365,7 @@ class AdventureReader extends Module {
|
||||
const position = this._getDecorationPosition(mapWidth, mapHeight, 30);
|
||||
grass.style.left = position.x + 'px';
|
||||
grass.style.top = position.y + 'px';
|
||||
grass.style.fontSize = (15 + Math.random() * 8) + 'px';
|
||||
grass.style.fontSize = ((15 + Math.random() * 8) / 1.66) + 'px'; // Reduced by 1.66
|
||||
|
||||
gameMap.appendChild(grass);
|
||||
}
|
||||
@ -1288,7 +1380,7 @@ class AdventureReader extends Module {
|
||||
const position = this._getDecorationPosition(mapWidth, mapHeight, 40);
|
||||
rock.style.left = position.x + 'px';
|
||||
rock.style.top = position.y + 'px';
|
||||
rock.style.fontSize = (20 + Math.random() * 10) + 'px';
|
||||
rock.style.fontSize = ((20 + Math.random() * 10) / 1.66) + 'px'; // Reduced by 1.66
|
||||
|
||||
gameMap.appendChild(rock);
|
||||
}
|
||||
@ -1334,6 +1426,8 @@ class AdventureReader extends Module {
|
||||
|
||||
_startGameLoop() {
|
||||
const animate = () => {
|
||||
if (this._isDestroyed) return; // Stop animation if game is destroyed
|
||||
|
||||
if (!this._isGamePaused) {
|
||||
this._moveEnemies();
|
||||
}
|
||||
@ -1344,6 +1438,8 @@ class AdventureReader extends Module {
|
||||
|
||||
_moveEnemies() {
|
||||
const gameMap = document.getElementById('game-map');
|
||||
if (!gameMap) return; // Exit if game map doesn't exist
|
||||
|
||||
const mapRect = gameMap.getBoundingClientRect();
|
||||
const mapWidth = mapRect.width;
|
||||
const mapHeight = mapRect.height;
|
||||
@ -1366,6 +1462,15 @@ class AdventureReader extends Module {
|
||||
enemy.element.style.left = enemy.x + 'px';
|
||||
enemy.element.style.top = enemy.y + 'px';
|
||||
|
||||
// Add red shadow effect during dash
|
||||
if (enemy.isDashing) {
|
||||
enemy.element.style.filter = 'drop-shadow(0 0 10px rgba(255, 0, 0, 0.8)) drop-shadow(0 0 20px rgba(255, 0, 0, 0.5))';
|
||||
enemy.element.style.transform = 'scale(1.1)'; // Slightly larger during dash
|
||||
} else {
|
||||
enemy.element.style.filter = '';
|
||||
enemy.element.style.transform = '';
|
||||
}
|
||||
|
||||
this._checkPlayerEnemyCollision(enemy);
|
||||
});
|
||||
}
|
||||
@ -1401,10 +1506,43 @@ class AdventureReader extends Module {
|
||||
this._player.y - enemy.y,
|
||||
this._player.x - enemy.x
|
||||
);
|
||||
enemy.moveDirection = angleToPlayer + (Math.random() - 0.5) * 0.3;
|
||||
const distanceToPlayer = Math.sqrt(
|
||||
Math.pow(this._player.x - enemy.x, 2) + Math.pow(this._player.y - enemy.y, 2)
|
||||
);
|
||||
|
||||
enemy.x += Math.cos(enemy.moveDirection) * (enemy.speed * 0.8);
|
||||
enemy.y += Math.sin(enemy.moveDirection) * (enemy.speed * 0.8);
|
||||
// Decrease dash cooldown
|
||||
if (enemy.dashCooldown > 0) {
|
||||
enemy.dashCooldown--;
|
||||
}
|
||||
|
||||
// Trigger dash if close enough and cooldown is ready
|
||||
if (!enemy.isDashing && enemy.dashCooldown <= 0 && distanceToPlayer < 300 && distanceToPlayer > 80) {
|
||||
enemy.isDashing = true;
|
||||
enemy.dashDuration = 30; // 30 frames of dash
|
||||
enemy.dashCooldown = 120; // 120 frames cooldown (~2 seconds)
|
||||
|
||||
// Choose perpendicular direction (90° or -90° randomly)
|
||||
const perpendicularOffset = Math.random() < 0.5 ? Math.PI / 2 : -Math.PI / 2;
|
||||
enemy.dashAngle = angleToPlayer + perpendicularOffset;
|
||||
}
|
||||
|
||||
// Handle dashing (perpendicular to player direction - evasive maneuver)
|
||||
if (enemy.isDashing) {
|
||||
// Use stored dash angle (perpendicular to player at dash start)
|
||||
enemy.moveDirection = enemy.dashAngle;
|
||||
enemy.x += Math.cos(enemy.dashAngle) * (enemy.speed * 3.5); // 3.5x speed during dash
|
||||
enemy.y += Math.sin(enemy.dashAngle) * (enemy.speed * 3.5);
|
||||
|
||||
enemy.dashDuration--;
|
||||
if (enemy.dashDuration <= 0) {
|
||||
enemy.isDashing = false;
|
||||
}
|
||||
} else {
|
||||
// Normal chase movement
|
||||
enemy.moveDirection = angleToPlayer + (Math.random() - 0.5) * 0.3;
|
||||
enemy.x += Math.cos(enemy.moveDirection) * (enemy.speed * 0.8);
|
||||
enemy.y += Math.sin(enemy.moveDirection) * (enemy.speed * 0.8);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'wander':
|
||||
@ -1629,11 +1767,25 @@ class AdventureReader extends Module {
|
||||
modal.style.display = 'flex';
|
||||
modal.classList.add('show');
|
||||
|
||||
// Calculate reading time based on text length and TTS
|
||||
const textLength = sentence.original_language.length;
|
||||
// Average reading speed: ~5 chars/second at 0.8 rate
|
||||
// Add base delay of 800ms (600ms initial + 200ms buffer)
|
||||
const ttsDelay = 600; // Initial delay before TTS starts
|
||||
const readingTime = (textLength / 5) * 1000; // Characters to milliseconds
|
||||
const bufferTime = 500; // Extra buffer after TTS ends
|
||||
const totalTime = ttsDelay + readingTime + bufferTime;
|
||||
|
||||
if (this._config.autoPlayTTS && this._config.ttsEnabled) {
|
||||
setTimeout(() => {
|
||||
this._speakText(sentence.original_language, { rate: 0.8 });
|
||||
}, 600);
|
||||
}, ttsDelay);
|
||||
}
|
||||
|
||||
// Auto-close modal after TTS completes
|
||||
setTimeout(() => {
|
||||
this._closeModal();
|
||||
}, totalTime);
|
||||
}
|
||||
|
||||
_closeModal() {
|
||||
@ -1642,6 +1794,9 @@ class AdventureReader extends Module {
|
||||
setTimeout(() => {
|
||||
modal.style.display = 'none';
|
||||
this._isGamePaused = false;
|
||||
|
||||
// Grant 1 second invulnerability after closing reading modal
|
||||
this._grantPostReadingInvulnerability();
|
||||
}, 300);
|
||||
|
||||
this._checkGameComplete();
|
||||
@ -1705,7 +1860,8 @@ class AdventureReader extends Module {
|
||||
}
|
||||
|
||||
_checkPlayerEnemyCollision(enemy) {
|
||||
if (this._isPlayerInvulnerable || enemy.defeated) return;
|
||||
// Skip collision check during pause (reading), invulnerability, or defeated enemy
|
||||
if (this._isGamePaused || this._isPlayerInvulnerable || enemy.defeated) return;
|
||||
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(this._player.x - enemy.x, 2) + Math.pow(this._player.y - enemy.y, 2)
|
||||
@ -1729,6 +1885,7 @@ class AdventureReader extends Module {
|
||||
this._isPlayerInvulnerable = true;
|
||||
const playerElement = document.getElementById('player');
|
||||
|
||||
// Blinking animation (visual only)
|
||||
let blinkCount = 0;
|
||||
const blinkInterval = setInterval(() => {
|
||||
playerElement.style.opacity = playerElement.style.opacity === '0.3' ? '1' : '0.3';
|
||||
@ -1738,10 +1895,14 @@ class AdventureReader extends Module {
|
||||
clearInterval(blinkInterval);
|
||||
playerElement.style.opacity = '1';
|
||||
playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))';
|
||||
this._isPlayerInvulnerable = false;
|
||||
}
|
||||
}, 250);
|
||||
|
||||
// Actual invulnerability duration (independent of blink animation)
|
||||
this._invulnerabilityTimeout = setTimeout(() => {
|
||||
this._isPlayerInvulnerable = false;
|
||||
}, 2000); // 2 seconds of actual invulnerability
|
||||
|
||||
this._showDamagePopup();
|
||||
}
|
||||
|
||||
@ -1763,6 +1924,23 @@ class AdventureReader extends Module {
|
||||
this._showInvulnerabilityPopup();
|
||||
}
|
||||
|
||||
_grantPostReadingInvulnerability() {
|
||||
this._isPlayerInvulnerable = true;
|
||||
const playerElement = document.getElementById('player');
|
||||
|
||||
if (this._invulnerabilityTimeout) {
|
||||
clearTimeout(this._invulnerabilityTimeout);
|
||||
}
|
||||
|
||||
// Brief blue glow to indicate post-reading protection
|
||||
playerElement.style.filter = 'drop-shadow(0 0 10px rgba(100, 150, 255, 0.8)) brightness(1.2)';
|
||||
|
||||
this._invulnerabilityTimeout = setTimeout(() => {
|
||||
playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))';
|
||||
this._isPlayerInvulnerable = false;
|
||||
}, 1000); // 1 second protection
|
||||
}
|
||||
|
||||
_refreshAttackInvulnerability() {
|
||||
if (this._invulnerabilityTimeout) {
|
||||
clearTimeout(this._invulnerabilityTimeout);
|
||||
|
||||
@ -521,12 +521,12 @@ class FillTheBlank extends Module {
|
||||
const points = 10 * blanks.length;
|
||||
this._score += points;
|
||||
this._showFeedback(`🎉 Perfect! +${points} points`, 'success');
|
||||
this._speakWord(blanks[0].answer); // Pronounce first blank word
|
||||
|
||||
setTimeout(() => {
|
||||
// Read the complete sentence
|
||||
this._speakSentence(this._currentExercise.original, () => {
|
||||
this._currentIndex++;
|
||||
this._loadNextExercise();
|
||||
}, 1500);
|
||||
});
|
||||
} else {
|
||||
this._errors++;
|
||||
if (correctCount > 0) {
|
||||
@ -574,42 +574,106 @@ class FillTheBlank extends Module {
|
||||
input.classList.add('revealed');
|
||||
});
|
||||
|
||||
this._showFeedback('📖 Answers revealed! Next exercise...', 'info');
|
||||
this._showFeedback('📖 Answers revealed!', 'info');
|
||||
|
||||
setTimeout(() => {
|
||||
// Read the complete sentence before moving on
|
||||
this._speakSentence(this._currentExercise.original, () => {
|
||||
this._currentIndex++;
|
||||
this._loadNextExercise();
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
_speakWord(word) {
|
||||
if (!window.speechSynthesis || !word) return;
|
||||
async _speakSentence(sentence, callback) {
|
||||
if (!window.speechSynthesis || !sentence) {
|
||||
if (callback) setTimeout(callback, 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.speechSynthesis.cancel();
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(word);
|
||||
utterance.lang = 'en-US';
|
||||
utterance.rate = 0.9;
|
||||
// For predefined exercises, replace underscores with actual answers
|
||||
let textToSpeak = sentence;
|
||||
if (this._currentExercise && this._currentExercise.type === 'predefined') {
|
||||
// Replace each _______ with the correct answer
|
||||
this._currentExercise.blanks.forEach(blank => {
|
||||
textToSpeak = textToSpeak.replace('_______', blank.answer);
|
||||
});
|
||||
}
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(textToSpeak);
|
||||
const targetLanguage = this._content?.language || 'en-US';
|
||||
utterance.lang = targetLanguage;
|
||||
utterance.rate = 0.8;
|
||||
utterance.pitch = 1.0;
|
||||
utterance.volume = 1.0;
|
||||
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
const voices = await this._getVoices();
|
||||
const langPrefix = targetLanguage.split('-')[0];
|
||||
const preferredVoice = voices.find(v =>
|
||||
v.lang.startsWith('en') &&
|
||||
(v.name.includes('Google') || v.name.includes('Neural') || v.name.includes('Microsoft'))
|
||||
);
|
||||
v.lang.startsWith(langPrefix) && v.default
|
||||
) || voices.find(v => v.lang.startsWith(langPrefix));
|
||||
|
||||
if (preferredVoice) {
|
||||
utterance.voice = preferredVoice;
|
||||
console.log(`🔊 Using voice: ${preferredVoice.name} (${preferredVoice.lang})`);
|
||||
} else {
|
||||
console.warn(`🔊 No voice found for: ${targetLanguage}, available:`, voices.map(v => v.lang));
|
||||
}
|
||||
|
||||
// Call callback when speech ends
|
||||
utterance.onend = () => {
|
||||
if (callback) {
|
||||
setTimeout(callback, 500); // Small delay after speech
|
||||
}
|
||||
};
|
||||
|
||||
utterance.onerror = () => {
|
||||
console.warn('Speech synthesis error');
|
||||
if (callback) setTimeout(callback, 1500);
|
||||
};
|
||||
|
||||
window.speechSynthesis.speak(utterance);
|
||||
} catch (error) {
|
||||
console.warn('Speech synthesis failed:', error);
|
||||
if (callback) setTimeout(callback, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available speech synthesis voices, waiting for them to load if necessary
|
||||
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||
* @private
|
||||
*/
|
||||
_getVoices() {
|
||||
return new Promise((resolve) => {
|
||||
let voices = window.speechSynthesis.getVoices();
|
||||
|
||||
// If voices are already loaded, return them immediately
|
||||
if (voices.length > 0) {
|
||||
resolve(voices);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, wait for voiceschanged event
|
||||
const voicesChangedHandler = () => {
|
||||
voices = window.speechSynthesis.getVoices();
|
||||
if (voices.length > 0) {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(voices);
|
||||
}
|
||||
};
|
||||
|
||||
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||
|
||||
// Fallback timeout in case voices never load
|
||||
setTimeout(() => {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(window.speechSynthesis.getVoices());
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
_showFeedback(message, type = 'info') {
|
||||
const feedbackArea = document.getElementById('feedback-area');
|
||||
if (feedbackArea) {
|
||||
|
||||
@ -1727,7 +1727,7 @@ class FlashcardLearning extends Module {
|
||||
}
|
||||
|
||||
// Audio System
|
||||
_playAudio(text) {
|
||||
async _playAudio(text) {
|
||||
if ('speechSynthesis' in window) {
|
||||
// Cancel any ongoing speech
|
||||
speechSynthesis.cancel();
|
||||
@ -1742,7 +1742,7 @@ class FlashcardLearning extends Module {
|
||||
utterance.volume = 1.0;
|
||||
|
||||
// Try to find a suitable voice for the language
|
||||
const voices = speechSynthesis.getVoices();
|
||||
const voices = await this._getVoices();
|
||||
if (voices.length > 0) {
|
||||
// Find voice matching the chapter language
|
||||
const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
|
||||
@ -1753,6 +1753,8 @@ class FlashcardLearning extends Module {
|
||||
if (matchingVoice) {
|
||||
utterance.voice = matchingVoice;
|
||||
console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang);
|
||||
} else {
|
||||
console.warn(`🔊 No voice found for: ${chapterLanguage}, available:`, voices.map(v => v.lang));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1760,6 +1762,40 @@ class FlashcardLearning extends Module {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available speech synthesis voices, waiting for them to load if necessary
|
||||
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||
* @private
|
||||
*/
|
||||
_getVoices() {
|
||||
return new Promise((resolve) => {
|
||||
let voices = window.speechSynthesis.getVoices();
|
||||
|
||||
// If voices are already loaded, return them immediately
|
||||
if (voices.length > 0) {
|
||||
resolve(voices);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, wait for voiceschanged event
|
||||
const voicesChangedHandler = () => {
|
||||
voices = window.speechSynthesis.getVoices();
|
||||
if (voices.length > 0) {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(voices);
|
||||
}
|
||||
};
|
||||
|
||||
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||
|
||||
// Fallback timeout in case voices never load
|
||||
setTimeout(() => {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(window.speechSynthesis.getVoices());
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
_highlightPronunciation() {
|
||||
// Highlight pronunciation when TTS is played
|
||||
const pronunciation = document.getElementById('pronunciation-display');
|
||||
|
||||
@ -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}`
|
||||
});
|
||||
|
||||
@ -69,6 +69,7 @@ class MarioEducational extends Module {
|
||||
this._particles = [];
|
||||
this._walls = [];
|
||||
this._castleStructure = null;
|
||||
this._finishLine = null;
|
||||
|
||||
// Advanced level elements
|
||||
this._piranhaPlants = [];
|
||||
@ -115,6 +116,9 @@ class MarioEducational extends Module {
|
||||
// UI elements (need to be declared before seal)
|
||||
this._uiOverlay = null;
|
||||
|
||||
// Debug mode (toggle with 'D' key)
|
||||
this._debugMode = false;
|
||||
|
||||
Object.seal(this);
|
||||
}
|
||||
|
||||
@ -140,63 +144,60 @@ class MarioEducational extends Module {
|
||||
*/
|
||||
static getCompatibilityScore(content) {
|
||||
const sentences = content?.sentences || [];
|
||||
const vocab = content?.vocabulary || {};
|
||||
const phrases = content?.phrases || {};
|
||||
const dialogs = content?.dialogs || {};
|
||||
const texts = content?.texts || [];
|
||||
const story = content?.story || '';
|
||||
|
||||
const vocabCount = Object.keys(vocab).length;
|
||||
const sentenceCount = sentences.length;
|
||||
let totalSentences = sentences.length;
|
||||
|
||||
// Count sentences from texts and story
|
||||
let extraSentenceCount = 0;
|
||||
// Count phrases (SBS format)
|
||||
totalSentences += Object.keys(phrases).length;
|
||||
|
||||
if (story && typeof story === 'string') {
|
||||
extraSentenceCount += story.split(/[.!?]+/).filter(s => s.trim().length > 0).length;
|
||||
}
|
||||
// Count dialog lines (SBS format)
|
||||
Object.values(dialogs).forEach(dialog => {
|
||||
if (dialog.lines && Array.isArray(dialog.lines)) {
|
||||
totalSentences += dialog.lines.length;
|
||||
}
|
||||
});
|
||||
|
||||
// Count sentences from texts
|
||||
if (Array.isArray(texts)) {
|
||||
texts.forEach(text => {
|
||||
if (typeof text === 'string') {
|
||||
extraSentenceCount += text.split(/[.!?]+/).filter(s => s.trim().length > 0).length;
|
||||
totalSentences += text.split(/[.!?]+/).filter(s => s.trim().length > 10).length;
|
||||
} else if (text.content) {
|
||||
extraSentenceCount += text.content.split(/[.!?]+/).filter(s => s.trim().length > 0).length;
|
||||
totalSentences += text.content.split(/[.!?]+/).filter(s => s.trim().length > 10).length;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const totalSentences = sentenceCount + extraSentenceCount;
|
||||
// Count sentences from story
|
||||
if (story && typeof story === 'string') {
|
||||
totalSentences += story.split(/[.!?]+/).filter(s => s.trim().length > 10).length;
|
||||
}
|
||||
|
||||
if (totalSentences < 3 && vocabCount < 10) {
|
||||
// Mario Educational requires at least 5 sentences
|
||||
if (totalSentences < 5) {
|
||||
return {
|
||||
score: 0,
|
||||
reason: `Insufficient content (${totalSentences} sentences, ${vocabCount} vocabulary words)`,
|
||||
requirements: ['sentences', 'vocabulary', 'texts', 'story'],
|
||||
minSentences: 3,
|
||||
minVocabulary: 10,
|
||||
details: 'Mario Educational needs at least 3 sentences OR 10+ vocabulary words'
|
||||
reason: `Insufficient sentences (${totalSentences}/5 minimum)`,
|
||||
requirements: ['sentences', 'phrases', 'dialogs', 'texts', 'story'],
|
||||
minSentences: 5,
|
||||
details: 'Mario Educational needs at least 5 complete sentences from any source'
|
||||
};
|
||||
}
|
||||
|
||||
if (vocabCount < 5) {
|
||||
return {
|
||||
score: 0.3,
|
||||
reason: `Limited vocabulary (${vocabCount}/5 minimum)`,
|
||||
requirements: ['sentences', 'vocabulary'],
|
||||
minWords: 5,
|
||||
details: 'Game can work with sentences but vocabulary enhances learning'
|
||||
};
|
||||
}
|
||||
|
||||
// Perfect score at 30+ sentences, good score for 10+
|
||||
const score = Math.min((totalSentences + vocabCount) / 50, 1);
|
||||
// Good score for 10+ sentences, perfect at 30+
|
||||
const score = Math.min(totalSentences / 30, 1);
|
||||
|
||||
return {
|
||||
score,
|
||||
reason: `${totalSentences} sentences and ${vocabCount} vocabulary words available`,
|
||||
requirements: ['sentences', 'vocabulary', 'texts', 'story'],
|
||||
minSentences: 3,
|
||||
reason: `${totalSentences} sentences available`,
|
||||
requirements: ['sentences', 'phrases', 'dialogs', 'texts', 'story'],
|
||||
minSentences: 5,
|
||||
optimalSentences: 30,
|
||||
details: `Can create ${Math.min(totalSentences + vocabCount, 50)} question blocks from all content sources`
|
||||
details: `Can create ${Math.min(totalSentences, 50)} question blocks from sentence sources`
|
||||
};
|
||||
}
|
||||
|
||||
@ -279,14 +280,15 @@ class MarioEducational extends Module {
|
||||
// Private methods
|
||||
_extractSentences() {
|
||||
const sentences = this._content.sentences || [];
|
||||
const vocab = this._content.vocabulary || {};
|
||||
const phrases = this._content.phrases || {};
|
||||
const dialogs = this._content.dialogs || {};
|
||||
const texts = this._content.texts || [];
|
||||
const story = this._content.story || '';
|
||||
|
||||
// Combine sentences and vocabulary for questions
|
||||
// Combine all sentence sources
|
||||
this._sentences = [];
|
||||
|
||||
// Add actual sentences - handle both formats
|
||||
// Add sentences from 'sentences' array
|
||||
sentences.forEach(sentence => {
|
||||
// Format 1: Modern format with english/user_language
|
||||
if (sentence.english && sentence.user_language) {
|
||||
@ -308,6 +310,34 @@ class MarioEducational extends Module {
|
||||
}
|
||||
});
|
||||
|
||||
// Add phrases from 'phrases' object (SBS format)
|
||||
Object.entries(phrases).forEach(([english, data]) => {
|
||||
if (data.user_language) {
|
||||
this._sentences.push({
|
||||
type: 'phrase',
|
||||
english: english,
|
||||
translation: data.user_language,
|
||||
context: data.context || 'phrase'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add dialog lines from 'dialogs' object (SBS format)
|
||||
Object.values(dialogs).forEach(dialog => {
|
||||
if (dialog.lines && Array.isArray(dialog.lines)) {
|
||||
dialog.lines.forEach(line => {
|
||||
if (line.text && line.user_language) {
|
||||
this._sentences.push({
|
||||
type: 'dialog',
|
||||
english: line.text,
|
||||
translation: line.user_language,
|
||||
context: dialog.title || 'dialog'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Extract sentences from story text
|
||||
if (story && typeof story === 'string') {
|
||||
const storySentences = sentenceGenerator.splitTextIntoSentences(story);
|
||||
@ -348,25 +378,10 @@ class MarioEducational extends Module {
|
||||
});
|
||||
}
|
||||
|
||||
// Add vocabulary as contextual sentences
|
||||
Object.entries(vocab).forEach(([word, data]) => {
|
||||
if (data.user_language) {
|
||||
const generatedSentence = sentenceGenerator.generateSentence(word, data);
|
||||
this._sentences.push({
|
||||
type: 'vocabulary',
|
||||
english: generatedSentence.english,
|
||||
translation: generatedSentence.translation,
|
||||
context: data.type || 'vocabulary',
|
||||
difficulty: generatedSentence.difficulty,
|
||||
wordType: generatedSentence.wordType
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Shuffle sentences for variety
|
||||
this._sentences = this._shuffleArray(this._sentences);
|
||||
|
||||
console.log(`📝 Extracted ${this._sentences.length} sentences/vocabulary for questions`);
|
||||
console.log(`📝 Extracted ${this._sentences.length} sentences for questions`);
|
||||
}
|
||||
|
||||
|
||||
@ -388,6 +403,12 @@ class MarioEducational extends Module {
|
||||
this._handleKeyDown = (e) => {
|
||||
this._keys[e.code] = true;
|
||||
|
||||
// Toggle debug mode with 'D' key
|
||||
if (e.code === 'KeyD') {
|
||||
this._debugMode = !this._debugMode;
|
||||
console.log(`🐛 Debug mode: ${this._debugMode ? 'ON' : 'OFF'}`);
|
||||
}
|
||||
|
||||
// Prevent default for game controls
|
||||
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'Space'].includes(e.code)) {
|
||||
e.preventDefault();
|
||||
@ -456,6 +477,17 @@ class MarioEducational extends Module {
|
||||
// Generate floating platforms with intelligent placement
|
||||
this._generateIntelligentPlatforms(level, difficulty);
|
||||
|
||||
// Level 3+ gets advanced features BEFORE enemies (so enemy spawning can avoid them)
|
||||
if (index >= 2 && index <= 4) {
|
||||
this._generateHoles(level, difficulty);
|
||||
this._generateStairs(level, difficulty);
|
||||
}
|
||||
|
||||
// Level 6 boss level: only stairs (no holes)
|
||||
if (index === 5) {
|
||||
this._generateBossStairs(level, difficulty);
|
||||
}
|
||||
|
||||
// Generate question blocks on reachable platforms
|
||||
const questionCount = 3 + difficulty;
|
||||
const availablePlatforms = level.platforms.filter(p => p.y < this._config.canvasHeight - 100);
|
||||
@ -533,10 +565,10 @@ class MarioEducational extends Module {
|
||||
return enemyX + 10 >= hole.x && enemyX + 10 <= hole.x + hole.width; // Check center of enemy
|
||||
});
|
||||
|
||||
// Check if enemy has solid ground/platform/stair support below
|
||||
const hasSolidSupport = this._hasSolidSupportBelow(enemyX, enemyY + 20, level); // Check below enemy position
|
||||
// Enemy is spawned on a platform, so it has support by definition
|
||||
// No need to check hasSolidSupport - the platform we selected IS the support
|
||||
|
||||
if (!wouldOverlapWall && !wouldOverlapHole && hasSolidSupport) {
|
||||
if (!wouldOverlapWall && !wouldOverlapHole) {
|
||||
// Level 2+ gets exactly ONE helmet enemy per level
|
||||
const isHelmetEnemy = index >= 1 && !helmetEnemyPlaced && i === 0; // Only first enemy can be helmet
|
||||
|
||||
@ -555,8 +587,10 @@ class MarioEducational extends Module {
|
||||
hasHelmet: isHelmetEnemy
|
||||
});
|
||||
enemyPlaced = true;
|
||||
console.log(`✅ Enemy ${i} placed at x=${enemyX.toFixed(0)}, y=${enemyY.toFixed(0)} on platform`);
|
||||
} else {
|
||||
console.log(`🚫 Enemy ${i} attempt ${attempts} would overlap wall/hole/unsupported ground, retrying...`);
|
||||
const reason = wouldOverlapWall ? 'wall' : 'hole';
|
||||
console.log(`🚫 Enemy ${i} attempt ${attempts} failed: ${reason}`);
|
||||
}
|
||||
attempts++;
|
||||
}
|
||||
@ -566,17 +600,13 @@ class MarioEducational extends Module {
|
||||
}
|
||||
}
|
||||
|
||||
// Level 3+ gets advanced features (except level 6 boss level)
|
||||
// Generate piranha plants AFTER enemies (visual decoration)
|
||||
if (index >= 2 && index <= 4) {
|
||||
this._generateHoles(level, difficulty);
|
||||
this._generateStairs(level, difficulty);
|
||||
this._generatePiranhaPlants(level, difficulty);
|
||||
}
|
||||
|
||||
// Level 6 boss level: only stairs (no holes, limited piranha plants)
|
||||
// Level 6 boss level: limited piranha plants
|
||||
if (index === 5) {
|
||||
this._generateBossStairs(level, difficulty);
|
||||
// Reduced piranha plants for boss level
|
||||
if (difficulty > 3) {
|
||||
this._generatePiranhaPlants(level, Math.min(difficulty - 2, 2));
|
||||
}
|
||||
@ -592,10 +622,10 @@ class MarioEducational extends Module {
|
||||
this._generateFlyingEyes(level, difficulty);
|
||||
}
|
||||
|
||||
// Level 6 gets colossal boss
|
||||
if (index === 5) {
|
||||
this._generateColossalBoss(level, difficulty);
|
||||
}
|
||||
// Level 6 gets colossal boss (DISABLED)
|
||||
// if (index === 5) {
|
||||
// this._generateColossalBoss(level, difficulty);
|
||||
// }
|
||||
|
||||
return level;
|
||||
}
|
||||
@ -1527,6 +1557,13 @@ class MarioEducational extends Module {
|
||||
this._walls = [...(level.walls || [])];
|
||||
this._castleStructure = level.castleStructure || null;
|
||||
|
||||
// Create finish line at level end
|
||||
this._finishLine = {
|
||||
x: level.endX,
|
||||
y: this._config.canvasHeight - 150,
|
||||
height: 150
|
||||
};
|
||||
|
||||
// Advanced level elements
|
||||
this._piranhaPlants = [...(level.piranhaPlants || [])];
|
||||
this._projectiles = []; // Reset projectiles each level
|
||||
@ -1649,7 +1686,8 @@ class MarioEducational extends Module {
|
||||
walls: this._walls,
|
||||
catapults: this._catapults,
|
||||
piranhaPlants: this._piranhaPlants,
|
||||
boulders: this._boulders
|
||||
boulders: this._boulders,
|
||||
flyingEyes: this._flyingEyes
|
||||
};
|
||||
|
||||
const callbacks = {
|
||||
@ -1749,7 +1787,7 @@ class MarioEducational extends Module {
|
||||
this._playTTSAndAutoClose(sentence.english, overlay, progressFill);
|
||||
}
|
||||
|
||||
_playTTSAndAutoClose(text, overlay, progressBar) {
|
||||
async _playTTSAndAutoClose(text, overlay, progressBar) {
|
||||
// Calculate duration based on text length (words per minute estimation)
|
||||
const words = text.split(' ').length;
|
||||
const wordsPerMinute = 150; // Average speaking speed
|
||||
@ -1765,15 +1803,23 @@ class MarioEducational extends Module {
|
||||
utterance.pitch = 1.0;
|
||||
utterance.volume = 0.8;
|
||||
|
||||
// Try to use a nice English voice
|
||||
const voices = speechSynthesis.getVoices();
|
||||
const englishVoice = voices.find(voice =>
|
||||
voice.lang.startsWith('en') && (voice.name.includes('Female') || voice.name.includes('Google'))
|
||||
) || voices.find(voice => voice.lang.startsWith('en'));
|
||||
// Get target language from content
|
||||
const targetLanguage = this._content?.language || 'en-US';
|
||||
utterance.lang = targetLanguage;
|
||||
|
||||
if (englishVoice) {
|
||||
utterance.voice = englishVoice;
|
||||
console.log(`🎤 Using voice: ${englishVoice.name}`);
|
||||
// Wait for voices to be loaded before selecting one
|
||||
const voices = await this._getVoices();
|
||||
const langPrefix = targetLanguage.split('-')[0];
|
||||
|
||||
const matchingVoice = voices.find(voice =>
|
||||
voice.lang.startsWith(langPrefix) && voice.default
|
||||
) || voices.find(voice => voice.lang.startsWith(langPrefix));
|
||||
|
||||
if (matchingVoice) {
|
||||
utterance.voice = matchingVoice;
|
||||
console.log(`🎤 Using voice: ${matchingVoice.name} (${matchingVoice.lang})`);
|
||||
} else {
|
||||
console.warn(`🔊 No voice found for language: ${targetLanguage}, available:`, voices.map(v => v.lang));
|
||||
}
|
||||
|
||||
speechSynthesis.speak(utterance);
|
||||
@ -2252,6 +2298,44 @@ class MarioEducational extends Module {
|
||||
console.log(`💀 Mario died! Score penalty: -${penalty}. New score: ${this._score}`);
|
||||
}
|
||||
|
||||
_shuffleArray(array) {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
_getRandomSentence() {
|
||||
if (this._sentences.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get a sentence that hasn't been used yet, or recycle if all used
|
||||
const availableSentences = this._sentences.filter(
|
||||
s => !this._usedSentences.includes(s)
|
||||
);
|
||||
|
||||
if (availableSentences.length === 0) {
|
||||
// All sentences used, reset the used list
|
||||
this._usedSentences = [];
|
||||
return this._sentences[Math.floor(Math.random() * this._sentences.length)];
|
||||
}
|
||||
|
||||
const randomSentence = availableSentences[Math.floor(Math.random() * availableSentences.length)];
|
||||
this._usedSentences.push(randomSentence);
|
||||
return randomSentence;
|
||||
}
|
||||
|
||||
_updateFlyingEyes() {
|
||||
FlyingEye.update(this._flyingEyes, this._mario, () => this._restartLevel());
|
||||
}
|
||||
|
||||
_updateBoss() {
|
||||
Boss.update(this._boss, this._bossTurrets, this._bossMinions, this._mario, this._projectiles, (sound) => soundSystem.play(sound));
|
||||
}
|
||||
|
||||
_render() {
|
||||
// Build game state object for renderer
|
||||
const gameState = {
|
||||
@ -2268,19 +2352,53 @@ class MarioEducational extends Module {
|
||||
stones: this._stones,
|
||||
flyingEyes: this._flyingEyes,
|
||||
boss: this._boss,
|
||||
castle: this._castle,
|
||||
castleStructure: this._castleStructure,
|
||||
finishLine: this._finishLine,
|
||||
particles: this._particles,
|
||||
currentLevel: this._currentLevel,
|
||||
lives: this._lives,
|
||||
score: this._score,
|
||||
debugMode: false // Can be toggled
|
||||
debugMode: this._debugMode // Toggle with 'D' key during gameplay
|
||||
};
|
||||
|
||||
// Delegate rendering to helper
|
||||
renderer.render(this._ctx, gameState, this._config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available speech synthesis voices, waiting for them to load if necessary
|
||||
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||
* @private
|
||||
*/
|
||||
_getVoices() {
|
||||
return new Promise((resolve) => {
|
||||
let voices = window.speechSynthesis.getVoices();
|
||||
|
||||
// If voices are already loaded, return them immediately
|
||||
if (voices.length > 0) {
|
||||
resolve(voices);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, wait for voiceschanged event
|
||||
const voicesChangedHandler = () => {
|
||||
voices = window.speechSynthesis.getVoices();
|
||||
if (voices.length > 0) {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(voices);
|
||||
}
|
||||
};
|
||||
|
||||
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||
|
||||
// Fallback timeout in case voices never load
|
||||
setTimeout(() => {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(window.speechSynthesis.getVoices());
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default MarioEducational;
|
||||
@ -713,7 +713,7 @@ class QuizGame extends Module {
|
||||
data-pronunciation="${pronunciation}"
|
||||
title="Click to hear pronunciation">
|
||||
${optionText}
|
||||
${pronunciation ? `<div class="option-pronunciation">[${pronunciation}]</div>` : ''}
|
||||
${pronunciation ? `<div class="option-pronunciation" style="display: none;">[${pronunciation}]</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
@ -784,16 +784,6 @@ class QuizGame extends Module {
|
||||
return;
|
||||
}
|
||||
|
||||
// Play TTS when clicking on an option
|
||||
const word = optionElement.dataset.word;
|
||||
const pronunciation = optionElement.dataset.pronunciation;
|
||||
if (word) {
|
||||
this._playAudio(word);
|
||||
if (pronunciation) {
|
||||
this._highlightPronunciation(optionElement);
|
||||
}
|
||||
}
|
||||
|
||||
this._isAnswering = true;
|
||||
const question = this._questions[this._currentQuestion];
|
||||
const selectedAnswer = optionElement.dataset.value;
|
||||
@ -816,6 +806,40 @@ class QuizGame extends Module {
|
||||
this._score += 100 + timeBonus;
|
||||
}
|
||||
|
||||
// Play TTS and show pronunciation
|
||||
if (isCorrect) {
|
||||
// For correct answer, play the clicked option
|
||||
const word = optionElement.dataset.word;
|
||||
const pronunciation = optionElement.dataset.pronunciation;
|
||||
if (word) {
|
||||
this._playAudio(word);
|
||||
if (pronunciation) {
|
||||
const pronunciationElement = optionElement.querySelector('.option-pronunciation');
|
||||
if (pronunciationElement) {
|
||||
pronunciationElement.style.display = 'block';
|
||||
this._highlightPronunciation(optionElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For incorrect answer, find and play the correct option's TTS
|
||||
const correctOption = this._findCorrectOption(question.correctAnswer);
|
||||
if (correctOption) {
|
||||
const word = correctOption.dataset.word;
|
||||
const pronunciation = correctOption.dataset.pronunciation;
|
||||
if (word) {
|
||||
this._playAudio(word);
|
||||
if (pronunciation) {
|
||||
const pronunciationElement = correctOption.querySelector('.option-pronunciation');
|
||||
if (pronunciationElement) {
|
||||
pronunciationElement.style.display = 'block';
|
||||
this._highlightPronunciation(correctOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show feedback
|
||||
this._showAnswerFeedback(isCorrect, question);
|
||||
this._updateStats();
|
||||
@ -831,6 +855,19 @@ class QuizGame extends Module {
|
||||
}, this.name);
|
||||
}
|
||||
|
||||
_findCorrectOption(correctAnswer) {
|
||||
const optionsContainer = document.getElementById('quiz-options');
|
||||
if (!optionsContainer) return null;
|
||||
|
||||
const options = optionsContainer.querySelectorAll('.quiz-option');
|
||||
for (const option of options) {
|
||||
if (option.dataset.value === correctAnswer) {
|
||||
return option;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_handleTimeout() {
|
||||
if (this._timer) {
|
||||
clearInterval(this._timer);
|
||||
@ -991,7 +1028,7 @@ class QuizGame extends Module {
|
||||
this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name);
|
||||
}
|
||||
|
||||
_playAudio(text) {
|
||||
async _playAudio(text) {
|
||||
if ('speechSynthesis' in window) {
|
||||
// Cancel any ongoing speech
|
||||
speechSynthesis.cancel();
|
||||
@ -1006,7 +1043,7 @@ class QuizGame extends Module {
|
||||
utterance.volume = 1.0;
|
||||
|
||||
// Try to find a suitable voice for the language
|
||||
const voices = speechSynthesis.getVoices();
|
||||
const voices = await this._getVoices();
|
||||
if (voices.length > 0) {
|
||||
// Find voice matching the chapter language
|
||||
const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
|
||||
@ -1017,6 +1054,8 @@ class QuizGame extends Module {
|
||||
if (matchingVoice) {
|
||||
utterance.voice = matchingVoice;
|
||||
console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang);
|
||||
} else {
|
||||
console.warn(`🔊 No voice found for: ${chapterLanguage}, available:`, voices.map(v => v.lang));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1024,6 +1063,40 @@ class QuizGame extends Module {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available speech synthesis voices, waiting for them to load if necessary
|
||||
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||
* @private
|
||||
*/
|
||||
_getVoices() {
|
||||
return new Promise((resolve) => {
|
||||
let voices = window.speechSynthesis.getVoices();
|
||||
|
||||
// If voices are already loaded, return them immediately
|
||||
if (voices.length > 0) {
|
||||
resolve(voices);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, wait for voiceschanged event
|
||||
const voicesChangedHandler = () => {
|
||||
voices = window.speechSynthesis.getVoices();
|
||||
if (voices.length > 0) {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(voices);
|
||||
}
|
||||
};
|
||||
|
||||
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||
|
||||
// Fallback timeout in case voices never load
|
||||
setTimeout(() => {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(window.speechSynthesis.getVoices());
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
_highlightPronunciation(optionElement) {
|
||||
const pronunciation = optionElement.querySelector('.option-pronunciation');
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1140,32 +1140,71 @@ class WhackAMole extends Module {
|
||||
}
|
||||
}
|
||||
|
||||
_speakWord(word) {
|
||||
async _speakWord(word) {
|
||||
// Use Web Speech API to pronounce the word
|
||||
if ('speechSynthesis' in window) {
|
||||
// Cancel any ongoing speech
|
||||
speechSynthesis.cancel();
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(word);
|
||||
utterance.lang = 'en-US'; // English pronunciation
|
||||
const targetLanguage = this._content?.language || 'en-US';
|
||||
utterance.lang = targetLanguage;
|
||||
utterance.rate = 0.9; // Slightly slower for clarity
|
||||
utterance.pitch = 1.0;
|
||||
utterance.volume = 1.0;
|
||||
|
||||
// Try to use a good English voice
|
||||
const voices = speechSynthesis.getVoices();
|
||||
const englishVoice = voices.find(voice =>
|
||||
voice.lang.startsWith('en') && (voice.name.includes('Google') || voice.name.includes('Neural'))
|
||||
) || voices.find(voice => voice.lang.startsWith('en'));
|
||||
// Try to use a good voice for the target language
|
||||
const voices = await this._getVoices();
|
||||
const langPrefix = targetLanguage.split('-')[0];
|
||||
const preferredVoice = voices.find(voice =>
|
||||
voice.lang.startsWith(langPrefix) && (voice.name.includes('Google') || voice.name.includes('Neural') || voice.default)
|
||||
) || voices.find(voice => voice.lang.startsWith(langPrefix));
|
||||
|
||||
if (englishVoice) {
|
||||
utterance.voice = englishVoice;
|
||||
if (preferredVoice) {
|
||||
utterance.voice = preferredVoice;
|
||||
console.log(`🔊 Using voice: ${preferredVoice.name} (${preferredVoice.lang})`);
|
||||
} else {
|
||||
console.warn(`🔊 No voice found for: ${targetLanguage}, available:`, voices.map(v => v.lang));
|
||||
}
|
||||
|
||||
speechSynthesis.speak(utterance);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available speech synthesis voices, waiting for them to load if necessary
|
||||
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||
* @private
|
||||
*/
|
||||
_getVoices() {
|
||||
return new Promise((resolve) => {
|
||||
let voices = window.speechSynthesis.getVoices();
|
||||
|
||||
// If voices are already loaded, return them immediately
|
||||
if (voices.length > 0) {
|
||||
resolve(voices);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, wait for voiceschanged event
|
||||
const voicesChangedHandler = () => {
|
||||
voices = window.speechSynthesis.getVoices();
|
||||
if (voices.length > 0) {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(voices);
|
||||
}
|
||||
};
|
||||
|
||||
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||
|
||||
// Fallback timeout in case voices never load
|
||||
setTimeout(() => {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(window.speechSynthesis.getVoices());
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
_shuffleArray(array) {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
|
||||
@ -1351,32 +1351,71 @@ class WhackAMoleHard extends Module {
|
||||
];
|
||||
}
|
||||
|
||||
_speakWord(word) {
|
||||
async _speakWord(word) {
|
||||
// Use Web Speech API to pronounce the word
|
||||
if ('speechSynthesis' in window) {
|
||||
// Cancel any ongoing speech
|
||||
speechSynthesis.cancel();
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(word);
|
||||
utterance.lang = 'en-US'; // English pronunciation
|
||||
const targetLanguage = this._content?.language || 'en-US';
|
||||
utterance.lang = targetLanguage;
|
||||
utterance.rate = 0.9; // Slightly slower for clarity
|
||||
utterance.pitch = 1.0;
|
||||
utterance.volume = 1.0;
|
||||
|
||||
// Try to use a good English voice
|
||||
const voices = speechSynthesis.getVoices();
|
||||
const englishVoice = voices.find(voice =>
|
||||
voice.lang.startsWith('en') && (voice.name.includes('Google') || voice.name.includes('Neural'))
|
||||
) || voices.find(voice => voice.lang.startsWith('en'));
|
||||
// Try to use a good voice for the target language
|
||||
const voices = await this._getVoices();
|
||||
const langPrefix = targetLanguage.split('-')[0];
|
||||
const preferredVoice = voices.find(voice =>
|
||||
voice.lang.startsWith(langPrefix) && (voice.name.includes('Google') || voice.name.includes('Neural') || voice.default)
|
||||
) || voices.find(voice => voice.lang.startsWith(langPrefix));
|
||||
|
||||
if (englishVoice) {
|
||||
utterance.voice = englishVoice;
|
||||
if (preferredVoice) {
|
||||
utterance.voice = preferredVoice;
|
||||
console.log(`🔊 Using voice: ${preferredVoice.name} (${preferredVoice.lang})`);
|
||||
} else {
|
||||
console.warn(`🔊 No voice found for: ${targetLanguage}, available:`, voices.map(v => v.lang));
|
||||
}
|
||||
|
||||
speechSynthesis.speak(utterance);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available speech synthesis voices, waiting for them to load if necessary
|
||||
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||
* @private
|
||||
*/
|
||||
_getVoices() {
|
||||
return new Promise((resolve) => {
|
||||
let voices = window.speechSynthesis.getVoices();
|
||||
|
||||
// If voices are already loaded, return them immediately
|
||||
if (voices.length > 0) {
|
||||
resolve(voices);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, wait for voiceschanged event
|
||||
const voicesChangedHandler = () => {
|
||||
voices = window.speechSynthesis.getVoices();
|
||||
if (voices.length > 0) {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(voices);
|
||||
}
|
||||
};
|
||||
|
||||
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||
|
||||
// Fallback timeout in case voices never load
|
||||
setTimeout(() => {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(window.speechSynthesis.getVoices());
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
_shuffleArray(array) {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
|
||||
@ -70,6 +70,9 @@ class WizardSpellCaster extends Module {
|
||||
const sentences = content?.sentences || [];
|
||||
const storyChapters = content?.story?.chapters || [];
|
||||
const dialogues = content?.dialogues || [];
|
||||
const texts = content?.texts || [];
|
||||
const phrases = content?.phrases || {};
|
||||
const dialogs = content?.dialogs || {};
|
||||
|
||||
let totalSentences = sentences.length;
|
||||
|
||||
@ -87,16 +90,45 @@ class WizardSpellCaster extends Module {
|
||||
}
|
||||
});
|
||||
|
||||
// Count phrases (object format with key-value pairs)
|
||||
if (typeof phrases === 'object') {
|
||||
totalSentences += Object.keys(phrases).length;
|
||||
}
|
||||
|
||||
// Count dialog lines (alternative spelling, object format)
|
||||
if (typeof dialogs === 'object') {
|
||||
Object.values(dialogs).forEach(dialog => {
|
||||
if (dialog.lines && Array.isArray(dialog.lines)) {
|
||||
totalSentences += dialog.lines.length;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Count extractable sentences from texts (LEDU-style content)
|
||||
let extractableSentences = 0;
|
||||
texts.forEach(text => {
|
||||
if (text.content) {
|
||||
const sentencesInText = text.content.split(/[。!?\.\!\?]+/).filter(s => {
|
||||
const trimmed = s.trim();
|
||||
const wordCount = trimmed.split(/\s+/).length;
|
||||
return trimmed && wordCount >= 3 && wordCount <= 15;
|
||||
});
|
||||
extractableSentences += sentencesInText.length;
|
||||
}
|
||||
});
|
||||
|
||||
totalSentences += extractableSentences;
|
||||
|
||||
// If we have enough sentences, use them
|
||||
if (totalSentences >= 9) {
|
||||
const score = Math.min(totalSentences / 30, 1);
|
||||
return {
|
||||
score,
|
||||
reason: `${totalSentences} sentences available for spell construction`,
|
||||
requirements: ['sentences', 'story', 'dialogues'],
|
||||
reason: `${totalSentences} sentences/phrases available for spell construction`,
|
||||
requirements: ['sentences', 'story', 'dialogues', 'texts', 'phrases', 'dialogs'],
|
||||
minSentences: 9,
|
||||
optimalSentences: 30,
|
||||
details: `Can create engaging spell combat with ${totalSentences} sentences`
|
||||
details: `Can create engaging spell combat with ${totalSentences} sentences/phrases`
|
||||
};
|
||||
}
|
||||
|
||||
@ -132,11 +164,11 @@ class WizardSpellCaster extends Module {
|
||||
|
||||
return {
|
||||
score: 0,
|
||||
reason: `Insufficient content (${totalSentences} sentences, ${vocabCount} vocabulary words)`,
|
||||
requirements: ['sentences', 'story', 'dialogues', 'vocabulary'],
|
||||
reason: `Insufficient content (${totalSentences} sentences/phrases, ${vocabCount} vocabulary words)`,
|
||||
requirements: ['sentences', 'story', 'dialogues', 'texts', 'phrases', 'dialogs', 'vocabulary'],
|
||||
minSentences: 9,
|
||||
minWords: 15,
|
||||
details: 'Wizard Spell Caster needs at least 9 sentences or 15 vocabulary words'
|
||||
details: 'Wizard Spell Caster needs at least 9 sentences/phrases or 15 vocabulary words'
|
||||
};
|
||||
}
|
||||
|
||||
@ -292,6 +324,60 @@ class WizardSpellCaster extends Module {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extract from phrases (key-value object format)
|
||||
if (this._content.phrases && typeof this._content.phrases === 'object') {
|
||||
Object.entries(this._content.phrases).forEach(([english, phraseData]) => {
|
||||
const translation = typeof phraseData === 'string' ? phraseData : phraseData.user_language || phraseData.chinese;
|
||||
if (english && translation) {
|
||||
this._processSentence({
|
||||
original: english,
|
||||
translation: translation,
|
||||
words: this._extractWordsFromSentence(english)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Extract from dialogs (alternative spelling of dialogues)
|
||||
if (this._content.dialogs && typeof this._content.dialogs === 'object') {
|
||||
Object.values(this._content.dialogs).forEach(dialog => {
|
||||
if (dialog.lines && Array.isArray(dialog.lines)) {
|
||||
dialog.lines.forEach(line => {
|
||||
if (line.text && line.user_language) {
|
||||
this._processSentence({
|
||||
original: line.text,
|
||||
translation: line.user_language,
|
||||
words: this._extractWordsFromSentence(line.text)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback: Extract from texts (for LEDU-style content)
|
||||
if (this._getTotalSpellCount() < 9 && this._content.texts && Array.isArray(this._content.texts)) {
|
||||
console.log('WizardSpellCaster: Extracting spells from texts as fallback');
|
||||
this._content.texts.forEach(text => {
|
||||
if (text.content) {
|
||||
// Split text into sentences using Chinese punctuation and periods
|
||||
const sentences = text.content.split(/[。!?\.\!\?]+/).filter(s => s.trim().length > 0);
|
||||
sentences.forEach(sentence => {
|
||||
const trimmed = sentence.trim();
|
||||
// Only use sentences with reasonable length (3-15 words)
|
||||
const wordCount = trimmed.split(/\s+/).length;
|
||||
if (trimmed && wordCount >= 3 && wordCount <= 15) {
|
||||
this._processSentence({
|
||||
original: trimmed,
|
||||
translation: trimmed, // In Chinese content, use same for both
|
||||
words: this._extractWordsFromSentence(trimmed)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_processSentence(sentenceData) {
|
||||
@ -371,19 +457,22 @@ class WizardSpellCaster extends Module {
|
||||
style.textContent = `
|
||||
.wizard-game-wrapper {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
color: white;
|
||||
font-family: 'Fantasy', serif;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wizard-hud {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 15px;
|
||||
padding: 8px 15px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-bottom: 2px solid #ffd700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wizard-stats {
|
||||
@ -393,10 +482,10 @@ class WizardSpellCaster extends Module {
|
||||
}
|
||||
|
||||
.health-bar {
|
||||
width: 150px;
|
||||
height: 20px;
|
||||
width: 120px;
|
||||
height: 16px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 2px solid #ffd700;
|
||||
}
|
||||
@ -409,8 +498,9 @@ class WizardSpellCaster extends Module {
|
||||
|
||||
.battle-area {
|
||||
display: flex;
|
||||
height: 60vh;
|
||||
padding: 20px;
|
||||
height: 180px;
|
||||
padding: 10px 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wizard-side {
|
||||
@ -430,29 +520,29 @@ class WizardSpellCaster extends Module {
|
||||
}
|
||||
|
||||
.wizard-character {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: linear-gradient(45deg, #6c5ce7, #a29bfe);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 36px;
|
||||
margin-bottom: 8px;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
box-shadow: 0 0 30px rgba(108, 92, 231, 0.6);
|
||||
}
|
||||
|
||||
.enemy-character {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: linear-gradient(45deg, #ff4757, #ff6b7a);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 64px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 48px;
|
||||
margin-bottom: 8px;
|
||||
animation: enemyPulse 2s ease-in-out infinite;
|
||||
box-shadow: 0 0 40px rgba(255, 71, 87, 0.6);
|
||||
}
|
||||
@ -471,22 +561,27 @@ class WizardSpellCaster extends Module {
|
||||
background: rgba(0,0,0,0.4);
|
||||
border: 2px solid #ffd700;
|
||||
border-radius: 15px;
|
||||
padding: 20px;
|
||||
margin: 20px;
|
||||
padding: 12px 15px;
|
||||
margin: 0 15px 10px 15px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.spell-selection {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spell-card {
|
||||
background: linear-gradient(135deg, #2c2c54, #40407a);
|
||||
border: 2px solid #ffd700;
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
@ -520,29 +615,32 @@ class WizardSpellCaster extends Module {
|
||||
|
||||
.sentence-builder {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
min-height: 80px;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
min-height: 60px;
|
||||
border: 2px dashed #ffd700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.word-bank {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.word-tile {
|
||||
background: linear-gradient(135deg, #5f27cd, #8854d0);
|
||||
color: white;
|
||||
padding: 8px 15px;
|
||||
border-radius: 20px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 15px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.word-tile:hover {
|
||||
@ -564,14 +662,15 @@ class WizardSpellCaster extends Module {
|
||||
background: linear-gradient(135deg, #ff6b7a, #ff4757);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 15px 30px;
|
||||
border-radius: 25px;
|
||||
font-size: 18px;
|
||||
padding: 12px 25px;
|
||||
border-radius: 20px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 5px 15px rgba(255, 71, 87, 0.3);
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cast-button:hover {
|
||||
@ -1055,8 +1154,8 @@ class WizardSpellCaster extends Module {
|
||||
</div>
|
||||
|
||||
<div class="sentence-builder" id="sentence-builder">
|
||||
<div style="color: #ffd700; margin-bottom: 10px;">Form your spell incantation:</div>
|
||||
<div id="current-sentence" style="font-size: 18px; min-height: 30px;"></div>
|
||||
<div style="color: #ffd700; margin-bottom: 5px; font-size: 0.9em;">Form your spell incantation:</div>
|
||||
<div id="current-sentence" style="font-size: 16px; min-height: 24px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="word-bank" id="word-bank">
|
||||
|
||||
@ -14,7 +14,6 @@ class WordDiscovery extends Module {
|
||||
container: null,
|
||||
difficulty: 'medium',
|
||||
practiceCount: 10,
|
||||
timerDuration: 30,
|
||||
...config
|
||||
};
|
||||
|
||||
@ -294,16 +293,19 @@ class WordDiscovery extends Module {
|
||||
</div>` : ''}
|
||||
|
||||
<div class="word-content">
|
||||
<h3 class="word-text">${word.word}</h3>
|
||||
<h3 class="word-text">
|
||||
${word.word}
|
||||
${word.pronunciation ? `<span class="word-pronunciation">[${word.pronunciation}]</span>` : ''}
|
||||
</h3>
|
||||
${word.translation ? `<p class="word-translation">${word.translation}</p>` : ''}
|
||||
${word.definition ? `<p class="word-definition">${word.definition}</p>` : ''}
|
||||
${word.example ? `<p class="word-example">"${word.example}"</p>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="word-controls">
|
||||
${word.audio ? `<button class="audio-btn" onclick="window.wordDiscovery._playAudio('${word.word}')">
|
||||
<button class="audio-btn" onclick="window.wordDiscovery._playWordSound('${word.word}')">
|
||||
🔊 Listen
|
||||
</button>` : ''}
|
||||
</button>
|
||||
<button class="next-btn" onclick="window.wordDiscovery._nextWord()">
|
||||
Next Word
|
||||
</button>
|
||||
@ -319,6 +321,11 @@ class WordDiscovery extends Module {
|
||||
`;
|
||||
|
||||
window.wordDiscovery = this;
|
||||
|
||||
// Auto-play TTS when word is revealed (with slight delay for better UX)
|
||||
setTimeout(() => {
|
||||
this._playWordSound(word.word);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
_nextWord() {
|
||||
@ -341,6 +348,91 @@ class WordDiscovery extends Module {
|
||||
}
|
||||
}
|
||||
|
||||
_playWordSound(wordText) {
|
||||
// First, try to play preloaded audio file if available
|
||||
const audio = this._audioElements.get(wordText);
|
||||
if (audio) {
|
||||
audio.currentTime = 0;
|
||||
audio.play().catch(error => {
|
||||
console.warn(`Failed to play audio for ${wordText}:`, error);
|
||||
// Fallback to TTS if audio file fails
|
||||
this._playTTS(wordText);
|
||||
});
|
||||
} else {
|
||||
// No audio file, use TTS
|
||||
this._playTTS(wordText);
|
||||
}
|
||||
}
|
||||
|
||||
async _playTTS(text) {
|
||||
if ('speechSynthesis' in window) {
|
||||
// Cancel any ongoing speech
|
||||
window.speechSynthesis.cancel();
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.rate = 0.9; // Slightly slower for clarity
|
||||
utterance.pitch = 1.0;
|
||||
utterance.volume = 1.0;
|
||||
|
||||
// Get target language from content
|
||||
const targetLanguage = this._content?.language || 'en-US';
|
||||
utterance.lang = targetLanguage;
|
||||
|
||||
// Wait for voices to be loaded before selecting one
|
||||
const voices = await this._getVoices();
|
||||
const langPrefix = targetLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
|
||||
|
||||
const matchingVoice = voices.find(voice =>
|
||||
voice.lang.startsWith(langPrefix) && voice.default
|
||||
) || voices.find(voice => voice.lang.startsWith(langPrefix));
|
||||
|
||||
if (matchingVoice) {
|
||||
utterance.voice = matchingVoice;
|
||||
console.log(`🔊 Using TTS voice: ${matchingVoice.name} (${matchingVoice.lang})`);
|
||||
} else {
|
||||
console.warn(`🔊 No voice found for language: ${targetLanguage}, available:`, voices.map(v => v.lang));
|
||||
}
|
||||
|
||||
window.speechSynthesis.speak(utterance);
|
||||
} else {
|
||||
console.warn('Text-to-speech not supported in this browser');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available speech synthesis voices, waiting for them to load if necessary
|
||||
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||
* @private
|
||||
*/
|
||||
_getVoices() {
|
||||
return new Promise((resolve) => {
|
||||
let voices = window.speechSynthesis.getVoices();
|
||||
|
||||
// If voices are already loaded, return them immediately
|
||||
if (voices.length > 0) {
|
||||
resolve(voices);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, wait for voiceschanged event
|
||||
const voicesChangedHandler = () => {
|
||||
voices = window.speechSynthesis.getVoices();
|
||||
if (voices.length > 0) {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(voices);
|
||||
}
|
||||
};
|
||||
|
||||
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||
|
||||
// Fallback timeout in case voices never load
|
||||
setTimeout(() => {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(window.speechSynthesis.getVoices());
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
_startPracticePhase() {
|
||||
if (this._discoveredWords.length === 0) {
|
||||
this._discoveredWords = [...this._practiceWords];
|
||||
@ -357,10 +449,10 @@ class WordDiscovery extends Module {
|
||||
_renderPracticeLevel() {
|
||||
const levels = ['Easy', 'Medium', 'Hard', 'Expert'];
|
||||
const levelConfig = {
|
||||
0: { time: 45, options: 2, type: 'translation' },
|
||||
1: { time: 30, options: 3, type: 'mixed' },
|
||||
2: { time: 20, options: 4, type: 'definition' },
|
||||
3: { time: 15, options: 4, type: 'context' }
|
||||
0: { options: 4, type: 'translation' },
|
||||
1: { options: 3, type: 'mixed' },
|
||||
2: { options: 4, type: 'definition' },
|
||||
3: { options: 4, type: 'context' }
|
||||
};
|
||||
|
||||
const config = levelConfig[this._currentPracticeLevel];
|
||||
@ -375,7 +467,6 @@ class WordDiscovery extends Module {
|
||||
<span>Total: ${this._practiceTotal}</span>
|
||||
<span>Accuracy: ${this._practiceTotal > 0 ? Math.round((this._practiceCorrect / this._practiceTotal) * 100) : 0}%</span>
|
||||
</div>
|
||||
<div class="timer">Time: <span id="timer-display">${config.time}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="practice-question" id="practice-question">
|
||||
@ -394,41 +485,16 @@ class WordDiscovery extends Module {
|
||||
</div>
|
||||
`;
|
||||
|
||||
this._timeLeft = config.time;
|
||||
this._startTimer();
|
||||
this._generateQuestion(config);
|
||||
}
|
||||
|
||||
_startTimer() {
|
||||
const timerDisplay = document.getElementById('timer-display');
|
||||
if (!timerDisplay) return;
|
||||
|
||||
this._timer = setInterval(() => {
|
||||
this._timeLeft--;
|
||||
timerDisplay.textContent = this._timeLeft;
|
||||
|
||||
if (this._timeLeft <= 0) {
|
||||
clearInterval(this._timer);
|
||||
this._handleTimeUp();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
_handleTimeUp() {
|
||||
this._practiceTotal++;
|
||||
this._showResult(false, 'Time up!');
|
||||
setTimeout(() => {
|
||||
this._generateQuestion();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
_generateQuestion(config = null) {
|
||||
if (!config) {
|
||||
const levelConfig = {
|
||||
0: { time: 45, options: 2, type: 'translation' },
|
||||
1: { time: 30, options: 3, type: 'mixed' },
|
||||
2: { time: 20, options: 4, type: 'definition' },
|
||||
3: { time: 15, options: 4, type: 'context' }
|
||||
0: { options: 4, type: 'translation' },
|
||||
1: { options: 3, type: 'mixed' },
|
||||
2: { options: 4, type: 'definition' },
|
||||
3: { options: 4, type: 'context' }
|
||||
};
|
||||
config = levelConfig[this._currentPracticeLevel];
|
||||
}
|
||||
@ -482,8 +548,11 @@ class WordDiscovery extends Module {
|
||||
<div class="question-content">
|
||||
<h3>What does this word mean?</h3>
|
||||
<div class="question-word">
|
||||
${correctWord.word}
|
||||
${correctWord.audio ? `<button class="audio-btn-small" onclick="window.wordDiscovery._playAudio('${correctWord.word}')">🔊</button>` : ''}
|
||||
<div class="word-with-pronunciation">
|
||||
<span>${correctWord.word}</span>
|
||||
${correctWord.pronunciation ? `<span class="question-pronunciation">[${correctWord.pronunciation}]</span>` : ''}
|
||||
</div>
|
||||
<button class="audio-btn-small" onclick="window.wordDiscovery._playWordSound('${correctWord.word}')">🔊</button>
|
||||
</div>
|
||||
<div class="options-grid">
|
||||
${this._practiceOptions.map(option => `
|
||||
@ -506,7 +575,10 @@ class WordDiscovery extends Module {
|
||||
<div class="options-grid">
|
||||
${this._practiceOptions.map(option => `
|
||||
<button class="option-btn" onclick="window.wordDiscovery._selectAnswer('${option.word}')">
|
||||
${option.word}
|
||||
<div class="option-word-with-pronunciation">
|
||||
<span>${option.word}</span>
|
||||
${option.pronunciation ? `<span class="option-pronunciation">[${option.pronunciation}]</span>` : ''}
|
||||
</div>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
@ -524,7 +596,10 @@ class WordDiscovery extends Module {
|
||||
<div class="options-grid">
|
||||
${this._practiceOptions.map(option => `
|
||||
<button class="option-btn" onclick="window.wordDiscovery._selectAnswer('${option.word}')">
|
||||
${option.word}
|
||||
<div class="option-word-with-pronunciation">
|
||||
<span>${option.word}</span>
|
||||
${option.pronunciation ? `<span class="option-pronunciation">[${option.pronunciation}]</span>` : ''}
|
||||
</div>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
@ -538,6 +613,8 @@ class WordDiscovery extends Module {
|
||||
|
||||
if (isCorrect) {
|
||||
this._practiceCorrect++;
|
||||
// Play TTS for correct answer
|
||||
this._playWordSound(this._correctAnswer.word);
|
||||
}
|
||||
|
||||
this._showResult(isCorrect, isCorrect ? 'Correct!' : `Wrong! The answer was: ${this._correctAnswer.word}`);
|
||||
@ -649,6 +726,17 @@ class WordDiscovery extends Module {
|
||||
color: #2c3e50;
|
||||
margin: 15px 0;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.word-pronunciation {
|
||||
font-size: 0.5em;
|
||||
color: #7f8c8d;
|
||||
font-style: italic;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.word-translation {
|
||||
@ -746,13 +834,6 @@ class WordDiscovery extends Module {
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.timer {
|
||||
font-size: 1.3em;
|
||||
font-weight: bold;
|
||||
color: #e74c3c;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.question-content {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
@ -777,6 +858,35 @@ class WordDiscovery extends Module {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.word-with-pronunciation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.question-pronunciation {
|
||||
font-size: 0.5em;
|
||||
color: #7f8c8d;
|
||||
font-style: italic;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.option-word-with-pronunciation {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.option-pronunciation {
|
||||
font-size: 0.8em;
|
||||
color: #7f8c8d;
|
||||
font-style: italic;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.question-definition, .question-context {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -232,77 +232,6 @@ p {
|
||||
background-color: #c53030;
|
||||
}
|
||||
|
||||
/* Debug Panel */
|
||||
.debug-panel {
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
right: 20px;
|
||||
width: 300px;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.debug-header {
|
||||
background-color: #2d3748;
|
||||
color: white;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem 0.5rem 0 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.debug-header h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.debug-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.debug-content {
|
||||
padding: 1rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.debug-content h4 {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: #718096;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.debug-content ul {
|
||||
list-style: none;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.debug-content li {
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.75rem;
|
||||
color: #4a5568;
|
||||
border-bottom: 1px solid #f7fafc;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.app-main {
|
||||
@ -326,12 +255,6 @@ p {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.debug-panel {
|
||||
width: calc(100% - 40px);
|
||||
right: 20px;
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.loading-screen h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
@ -360,8 +283,4 @@ p {
|
||||
background: #000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.debug-panel {
|
||||
border: 2px solid black;
|
||||
}
|
||||
}
|
||||
136
src/utils/TTSHelper.js
Normal file
136
src/utils/TTSHelper.js
Normal file
@ -0,0 +1,136 @@
|
||||
/**
|
||||
* TTSHelper - Text-to-Speech utility for consistent TTS behavior across the app
|
||||
* Fixes the common issue where voices aren't loaded on first call
|
||||
*/
|
||||
|
||||
class TTSHelper {
|
||||
/**
|
||||
* Get available speech synthesis voices, waiting for them to load if necessary
|
||||
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||
*/
|
||||
static getVoices() {
|
||||
return new Promise((resolve) => {
|
||||
let voices = window.speechSynthesis.getVoices();
|
||||
|
||||
// If voices are already loaded, return them immediately
|
||||
if (voices.length > 0) {
|
||||
resolve(voices);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, wait for voiceschanged event
|
||||
const voicesChangedHandler = () => {
|
||||
voices = window.speechSynthesis.getVoices();
|
||||
if (voices.length > 0) {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(voices);
|
||||
}
|
||||
};
|
||||
|
||||
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||
|
||||
// Fallback timeout in case voices never load
|
||||
setTimeout(() => {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||
resolve(window.speechSynthesis.getVoices());
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the best voice for a given language
|
||||
* @param {string} language - Language code (e.g., "zh-CN", "en-US")
|
||||
* @param {SpeechSynthesisVoice[]} voices - Available voices
|
||||
* @returns {SpeechSynthesisVoice|null} - Best matching voice or null
|
||||
*/
|
||||
static selectVoice(language, voices) {
|
||||
if (!voices || voices.length === 0) return null;
|
||||
|
||||
const langPrefix = language.split('-')[0]; // e.g., "zh" from "zh-CN"
|
||||
|
||||
// Try to find:
|
||||
// 1. Default voice for the exact language
|
||||
// 2. Any voice for the exact language
|
||||
// 3. Default voice for the language prefix
|
||||
// 4. Any voice for the language prefix
|
||||
const matchingVoice = voices.find(voice =>
|
||||
voice.lang === language && voice.default
|
||||
) || voices.find(voice =>
|
||||
voice.lang === language
|
||||
) || voices.find(voice =>
|
||||
voice.lang.startsWith(langPrefix) && voice.default
|
||||
) || voices.find(voice =>
|
||||
voice.lang.startsWith(langPrefix)
|
||||
);
|
||||
|
||||
return matchingVoice || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Speak text using TTS with proper voice selection and error handling
|
||||
* @param {string} text - Text to speak
|
||||
* @param {Object} options - TTS options
|
||||
* @param {string} options.lang - Language code (e.g., "zh-CN", "en-US")
|
||||
* @param {number} options.rate - Speech rate (default 0.8)
|
||||
* @param {number} options.pitch - Speech pitch (default 1.0)
|
||||
* @param {number} options.volume - Speech volume (default 1.0)
|
||||
* @param {Function} options.onStart - Callback when speech starts
|
||||
* @param {Function} options.onEnd - Callback when speech ends
|
||||
* @param {Function} options.onError - Callback on error
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static async speak(text, options = {}) {
|
||||
if (!('speechSynthesis' in window)) {
|
||||
console.warn('🔊 Speech Synthesis not supported in this browser');
|
||||
if (options.onError) options.onError(new Error('Speech Synthesis not supported'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Cancel any ongoing speech
|
||||
window.speechSynthesis.cancel();
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.lang = options.lang || 'en-US';
|
||||
utterance.rate = options.rate || 0.8;
|
||||
utterance.pitch = options.pitch || 1.0;
|
||||
utterance.volume = options.volume || 1.0;
|
||||
|
||||
// Wait for voices to be loaded before selecting one
|
||||
const voices = await TTSHelper.getVoices();
|
||||
const selectedVoice = TTSHelper.selectVoice(utterance.lang, voices);
|
||||
|
||||
if (selectedVoice) {
|
||||
utterance.voice = selectedVoice;
|
||||
console.log(`🔊 Using voice: ${selectedVoice.name} (${selectedVoice.lang})`);
|
||||
} else {
|
||||
console.warn(`🔊 No voice found for language: ${utterance.lang}, using default`);
|
||||
}
|
||||
|
||||
// Add event handlers
|
||||
utterance.onstart = () => {
|
||||
console.log('🔊 TTS started for:', text);
|
||||
if (options.onStart) options.onStart();
|
||||
};
|
||||
|
||||
utterance.onend = () => {
|
||||
console.log('🔊 TTS finished for:', text);
|
||||
if (options.onEnd) options.onEnd();
|
||||
};
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
console.warn('🔊 TTS error:', event.error);
|
||||
if (options.onError) options.onError(event);
|
||||
};
|
||||
|
||||
// Speak the text
|
||||
window.speechSynthesis.speak(utterance);
|
||||
|
||||
} catch (error) {
|
||||
console.warn('🔊 TTS failed:', error);
|
||||
if (options.onError) options.onError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default TTSHelper;
|
||||
Loading…
Reference in New Issue
Block a user