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": {
|
"statistics": {
|
||||||
"vocabulary_count": 67,
|
"vocabulary_count": 67,
|
||||||
"phrases_count": 10,
|
"phrases_count": 10,
|
||||||
"dialogs_count": 2,
|
"dialogs_count": 2,
|
||||||
"exercises_count": 2,
|
"exercises_count": 2,
|
||||||
|
"fillInBlanks_count": 15,
|
||||||
|
"corrections_count": 10,
|
||||||
"estimated_completion_time": 25
|
"estimated_completion_time": 25
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
88
index.html
88
index.html
@ -43,32 +43,6 @@
|
|||||||
<main id="app-main" class="app-main">
|
<main id="app-main" class="app-main">
|
||||||
<!-- Content will be rendered here by modules -->
|
<!-- Content will be rendered here by modules -->
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Debug panel (only shown in debug mode) -->
|
|
||||||
<div id="debug-panel" class="debug-panel" style="display: none;">
|
|
||||||
<div class="debug-header">
|
|
||||||
<h3>Debug Panel</h3>
|
|
||||||
<button id="debug-toggle" class="debug-toggle">×</button>
|
|
||||||
</div>
|
|
||||||
<div class="debug-content">
|
|
||||||
<div id="debug-status"></div>
|
|
||||||
<div id="debug-events"></div>
|
|
||||||
<div style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #ccc;">
|
|
||||||
<button id="run-integration-tests" onclick="runDRSTests()"
|
|
||||||
style="background: #28a745; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; width: 100%; margin-bottom: 8px;">
|
|
||||||
🧪 Run Integration Tests
|
|
||||||
</button>
|
|
||||||
<button id="run-uiux-tests" onclick="runUIUXTests()"
|
|
||||||
style="background: #6f42c1; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; width: 100%; margin-bottom: 8px;">
|
|
||||||
🎨 Run UI/UX Tests
|
|
||||||
</button>
|
|
||||||
<button id="run-e2e-tests" onclick="runE2ETests()"
|
|
||||||
style="background: #fd7e14; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; width: 100%;">
|
|
||||||
🎬 Run E2E Scenarios
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -151,14 +125,6 @@
|
|||||||
if (appContainer) appContainer.style.display = 'block';
|
if (appContainer) appContainer.style.display = 'block';
|
||||||
|
|
||||||
// Smart Preview Orchestrator is automatically initialized by Application.js
|
// Smart Preview Orchestrator is automatically initialized by Application.js
|
||||||
|
|
||||||
// Show debug panel if enabled
|
|
||||||
const status = app.getStatus();
|
|
||||||
if (status.config.enableDebug) {
|
|
||||||
const debugPanel = document.getElementById('debug-panel');
|
|
||||||
if (debugPanel) debugPanel.style.display = 'block';
|
|
||||||
updateDebugInfo();
|
|
||||||
}
|
|
||||||
}, 'Bootstrap');
|
}, 'Bootstrap');
|
||||||
|
|
||||||
// Handle navigation events
|
// Handle navigation events
|
||||||
@ -411,9 +377,11 @@
|
|||||||
<h3>${game.metadata.name}</h3>
|
<h3>${game.metadata.name}</h3>
|
||||||
<p class="game-description">${game.metadata.description}</p>
|
<p class="game-description">${game.metadata.description}</p>
|
||||||
<div class="game-meta">
|
<div class="game-meta">
|
||||||
<span class="difficulty-badge difficulty-${game.metadata.difficulty}">
|
${typeof game.metadata.difficulty === 'string' ? `
|
||||||
${game.metadata.difficulty}
|
<span class="difficulty-badge difficulty-${game.metadata.difficulty}">
|
||||||
</span>
|
${game.metadata.difficulty}
|
||||||
|
</span>
|
||||||
|
` : ''}
|
||||||
<span class="compatibility-score ${game.compatibility.score >= 0.7 ? 'high' : game.compatibility.score >= 0.3 ? 'medium' : 'low'}">
|
<span class="compatibility-score ${game.compatibility.score >= 0.7 ? 'high' : game.compatibility.score >= 0.3 ? 'medium' : 'low'}">
|
||||||
${Math.round(game.compatibility.score * 100)}% compatible
|
${Math.round(game.compatibility.score * 100)}% compatible
|
||||||
</span>
|
</span>
|
||||||
@ -585,9 +553,6 @@
|
|||||||
|
|
||||||
// Set up keyboard shortcuts after app is ready
|
// Set up keyboard shortcuts after app is ready
|
||||||
setupKeyboardShortcuts();
|
setupKeyboardShortcuts();
|
||||||
|
|
||||||
// Set up debug panel after app is ready
|
|
||||||
setupDebugPanel();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tooltip functions - Make them global
|
// Tooltip functions - Make them global
|
||||||
@ -644,32 +609,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDebugInfo() {
|
|
||||||
const debugStatus = document.getElementById('debug-status');
|
|
||||||
if (debugStatus && app) {
|
|
||||||
const status = app.getStatus();
|
|
||||||
debugStatus.innerHTML = `
|
|
||||||
<h4>System Status</h4>
|
|
||||||
<ul>
|
|
||||||
<li>Running: ${status.isRunning}</li>
|
|
||||||
<li>Uptime: ${Math.round(status.uptime / 1000)}s</li>
|
|
||||||
<li>Loaded Modules: ${status.modules.loaded.length}</li>
|
|
||||||
</ul>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupDebugPanel() {
|
|
||||||
const debugToggle = document.getElementById('debug-toggle');
|
|
||||||
const debugPanel = document.getElementById('debug-panel');
|
|
||||||
|
|
||||||
if (debugToggle && debugPanel) {
|
|
||||||
debugToggle.addEventListener('click', () => {
|
|
||||||
debugPanel.style.display = 'none';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupKeyboardShortcuts() {
|
function setupKeyboardShortcuts() {
|
||||||
document.addEventListener('keydown', (event) => {
|
document.addEventListener('keydown', (event) => {
|
||||||
try {
|
try {
|
||||||
@ -729,9 +668,24 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.navigateToChapters = function(bookId) {
|
window.navigateToChapters = function(chapterIdOrBookId) {
|
||||||
try {
|
try {
|
||||||
const { router } = app.getCore();
|
const { router } = app.getCore();
|
||||||
|
|
||||||
|
// If a chapterId is passed (e.g., "sbs-7-8"), extract the bookId from it
|
||||||
|
// If a bookId is passed (e.g., "sbs"), use it directly
|
||||||
|
let bookId = chapterIdOrBookId;
|
||||||
|
|
||||||
|
// Check if window.currentBookId is already set (most reliable)
|
||||||
|
if (window.currentBookId) {
|
||||||
|
bookId = window.currentBookId;
|
||||||
|
} else if (chapterIdOrBookId && chapterIdOrBookId.includes('-')) {
|
||||||
|
// Extract bookId from chapterId (e.g., "sbs-7-8" -> "sbs")
|
||||||
|
const parts = chapterIdOrBookId.split('-');
|
||||||
|
bookId = parts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📚 Navigating to chapters for book: ${bookId}`);
|
||||||
router.navigate(`/chapters/${bookId || ''}`);
|
router.navigate(`/chapters/${bookId || ''}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Navigation error:', error);
|
console.error('Navigation error:', error);
|
||||||
|
|||||||
@ -791,7 +791,7 @@ class VocabularyModule extends DRSExerciseInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_speakWord(text, options = {}) {
|
async _speakWord(text, options = {}) {
|
||||||
// Check if browser supports Speech Synthesis
|
// Check if browser supports Speech Synthesis
|
||||||
if ('speechSynthesis' in window) {
|
if ('speechSynthesis' in window) {
|
||||||
try {
|
try {
|
||||||
@ -807,8 +807,8 @@ class VocabularyModule extends DRSExerciseInterface {
|
|||||||
utterance.pitch = options.pitch || 1;
|
utterance.pitch = options.pitch || 1;
|
||||||
utterance.volume = options.volume || 1;
|
utterance.volume = options.volume || 1;
|
||||||
|
|
||||||
// Try to find a suitable voice for the language
|
// Wait for voices to be loaded before selecting one
|
||||||
const voices = window.speechSynthesis.getVoices();
|
const voices = await this._getVoices();
|
||||||
if (voices.length > 0) {
|
if (voices.length > 0) {
|
||||||
// Find voice matching the chapter language
|
// Find voice matching the chapter language
|
||||||
const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
|
const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
|
||||||
@ -819,6 +819,8 @@ class VocabularyModule extends DRSExerciseInterface {
|
|||||||
if (matchingVoice) {
|
if (matchingVoice) {
|
||||||
utterance.voice = matchingVoice;
|
utterance.voice = matchingVoice;
|
||||||
console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang);
|
console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang);
|
||||||
|
} else {
|
||||||
|
console.warn(`🔊 No voice found for language: ${chapterLanguage}, available:`, voices.map(v => v.lang));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -851,6 +853,40 @@ class VocabularyModule extends DRSExerciseInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available speech synthesis voices, waiting for them to load if necessary
|
||||||
|
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getVoices() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let voices = window.speechSynthesis.getVoices();
|
||||||
|
|
||||||
|
// If voices are already loaded, return them immediately
|
||||||
|
if (voices.length > 0) {
|
||||||
|
resolve(voices);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, wait for voiceschanged event
|
||||||
|
const voicesChangedHandler = () => {
|
||||||
|
voices = window.speechSynthesis.getVoices();
|
||||||
|
if (voices.length > 0) {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(voices);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
|
||||||
|
// Fallback timeout in case voices never load
|
||||||
|
setTimeout(() => {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(window.speechSynthesis.getVoices());
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
_updateTTSButton(isPlaying) {
|
_updateTTSButton(isPlaying) {
|
||||||
// Update main TTS button
|
// Update main TTS button
|
||||||
const ttsBtn = document.getElementById('tts-btn');
|
const ttsBtn = document.getElementById('tts-btn');
|
||||||
|
|||||||
@ -122,7 +122,7 @@ export class PhysicsEngine {
|
|||||||
static checkCollisions(mario, gameState, callbacks) {
|
static checkCollisions(mario, gameState, callbacks) {
|
||||||
const {
|
const {
|
||||||
platforms, questionBlocks, enemies, walls, catapults,
|
platforms, questionBlocks, enemies, walls, catapults,
|
||||||
piranhaPlants, boulders
|
piranhaPlants, boulders, flyingEyes
|
||||||
} = gameState;
|
} = gameState;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -165,41 +165,43 @@ export class PhysicsEngine {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Question block collisions
|
// Question block collisions (non-blocking - just trigger on contact)
|
||||||
questionBlocks.forEach(block => {
|
questionBlocks.forEach(block => {
|
||||||
if (!block.hit && this.isColliding(mario, block)) {
|
if (!block.hit && this.isColliding(mario, block)) {
|
||||||
// Check if Mario hit from below
|
// Trigger question block on any contact (pass-through)
|
||||||
if (mario.velocityY < 0 && mario.y < block.y + block.height) {
|
if (onQuestionBlock) onQuestionBlock(block);
|
||||||
if (onQuestionBlock) onQuestionBlock(block);
|
|
||||||
}
|
|
||||||
// Solid collision (treat as platform)
|
|
||||||
else if (mario.velocityY > 0) {
|
|
||||||
mario.y = block.y - mario.height;
|
|
||||||
mario.velocityY = 0;
|
|
||||||
mario.onGround = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wall collisions
|
// Wall collisions
|
||||||
walls.forEach(wall => {
|
walls.forEach(wall => {
|
||||||
if (this.isColliding(mario, wall)) {
|
if (this.isColliding(mario, wall)) {
|
||||||
// Side collision
|
// Determine collision direction based on previous position
|
||||||
if (mario.velocityX > 0) {
|
const overlapLeft = (mario.x + mario.width) - wall.x;
|
||||||
mario.x = wall.x - mario.width;
|
const overlapRight = (wall.x + wall.width) - mario.x;
|
||||||
} else if (mario.velocityX < 0) {
|
const overlapTop = (mario.y + mario.height) - wall.y;
|
||||||
mario.x = wall.x + wall.width;
|
const overlapBottom = (wall.y + wall.height) - mario.y;
|
||||||
}
|
|
||||||
mario.velocityX = 0;
|
|
||||||
|
|
||||||
// Top/bottom collision
|
// Find the smallest overlap to determine collision side
|
||||||
if (mario.velocityY > 0) {
|
const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom);
|
||||||
|
|
||||||
|
if (minOverlap === overlapTop && mario.velocityY > 0) {
|
||||||
|
// Landing on top of wall
|
||||||
mario.y = wall.y - mario.height;
|
mario.y = wall.y - mario.height;
|
||||||
mario.velocityY = 0;
|
mario.velocityY = 0;
|
||||||
mario.onGround = true;
|
mario.onGround = true;
|
||||||
} else if (mario.velocityY < 0) {
|
} else if (minOverlap === overlapBottom && mario.velocityY < 0) {
|
||||||
|
// Hit wall from below
|
||||||
mario.y = wall.y + wall.height;
|
mario.y = wall.y + wall.height;
|
||||||
mario.velocityY = 0;
|
mario.velocityY = 0;
|
||||||
|
} else if (minOverlap === overlapLeft && mario.velocityX > 0) {
|
||||||
|
// Hit wall from left side
|
||||||
|
mario.x = wall.x - mario.width;
|
||||||
|
mario.velocityX = 0;
|
||||||
|
} else if (minOverlap === overlapRight && mario.velocityX < 0) {
|
||||||
|
// Hit wall from right side
|
||||||
|
mario.x = wall.x + wall.width;
|
||||||
|
mario.velocityX = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -256,6 +258,36 @@ export class PhysicsEngine {
|
|||||||
mario.onGround = true;
|
mario.onGround = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Flying Eye collisions
|
||||||
|
if (flyingEyes) {
|
||||||
|
flyingEyes.forEach((eye, index) => {
|
||||||
|
// Eye position is CENTER, convert to top-left corner for collision
|
||||||
|
const eyeLeft = eye.x - eye.width / 2;
|
||||||
|
const eyeTop = eye.y - eye.height / 2;
|
||||||
|
const eyeRect = {
|
||||||
|
x: eyeLeft,
|
||||||
|
y: eyeTop,
|
||||||
|
width: eye.width,
|
||||||
|
height: eye.height
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.isColliding(mario, eyeRect)) {
|
||||||
|
// Check if Mario jumped on eye from above
|
||||||
|
if (mario.velocityY > 0 && mario.y < eyeTop + eye.height / 2) {
|
||||||
|
// Eye defeated
|
||||||
|
mario.velocityY = -8; // Bounce
|
||||||
|
flyingEyes.splice(index, 1); // Remove eye
|
||||||
|
if (onAddParticles) onAddParticles(eye.x, eye.y, '#DC143C');
|
||||||
|
console.log(`👁️ Mario defeated flying eye`);
|
||||||
|
} else {
|
||||||
|
// Mario hit by eye
|
||||||
|
console.log(`👁️ Mario hit by flying eye - restarting level`);
|
||||||
|
if (onMarioDeath) onMarioDeath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -47,8 +47,8 @@ export class Renderer {
|
|||||||
// Level 6 boss elements
|
// Level 6 boss elements
|
||||||
if (gameState.boss) this.renderBoss(ctx, gameState.boss);
|
if (gameState.boss) this.renderBoss(ctx, gameState.boss);
|
||||||
|
|
||||||
// Castle
|
// Castle structure (emoji-based)
|
||||||
if (gameState.castle) this.renderCastle(ctx, gameState.castle);
|
if (gameState.castleStructure) this.renderCastleStructure(ctx, gameState.castleStructure);
|
||||||
|
|
||||||
// Finish line
|
// Finish line
|
||||||
if (gameState.finishLine) this.renderFinishLine(ctx, gameState.finishLine, gameState.currentLevel);
|
if (gameState.finishLine) this.renderFinishLine(ctx, gameState.finishLine, gameState.currentLevel);
|
||||||
@ -447,52 +447,30 @@ export class Renderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render castle
|
* Render castle structure with emojis
|
||||||
*/
|
*/
|
||||||
renderCastle(ctx, castle) {
|
renderCastleStructure(ctx, castleStructure) {
|
||||||
if (!castle) return;
|
if (!castleStructure) return;
|
||||||
|
|
||||||
// Main castle body
|
// Draw castle emoji
|
||||||
ctx.fillStyle = '#888';
|
ctx.font = `${castleStructure.size}px Arial`;
|
||||||
ctx.fillRect(castle.x, castle.y, castle.width, castle.height);
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.fillText(
|
||||||
|
castleStructure.emoji,
|
||||||
|
castleStructure.x,
|
||||||
|
castleStructure.y
|
||||||
|
);
|
||||||
|
|
||||||
// Towers
|
// Draw princess emoji (if exists)
|
||||||
const towerWidth = 40;
|
if (castleStructure.princess) {
|
||||||
const towerHeight = 80;
|
ctx.font = `${castleStructure.princess.size}px Arial`;
|
||||||
|
ctx.fillText(
|
||||||
// Left tower
|
castleStructure.princess.emoji,
|
||||||
ctx.fillRect(castle.x - 20, castle.y - 30, towerWidth, towerHeight);
|
castleStructure.princess.x,
|
||||||
|
castleStructure.princess.y
|
||||||
// Right tower
|
);
|
||||||
ctx.fillRect(castle.x + castle.width - 20, castle.y - 30, towerWidth, towerHeight);
|
}
|
||||||
|
|
||||||
// Tower tops (triangular)
|
|
||||||
ctx.fillStyle = '#666';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(castle.x - 20, castle.y - 30);
|
|
||||||
ctx.lineTo(castle.x + 20, castle.y - 60);
|
|
||||||
ctx.lineTo(castle.x + 20, castle.y - 30);
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(castle.x + castle.width - 20, castle.y - 30);
|
|
||||||
ctx.lineTo(castle.x + castle.width + 20, castle.y - 60);
|
|
||||||
ctx.lineTo(castle.x + castle.width + 20, castle.y - 30);
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// Door
|
|
||||||
ctx.fillStyle = '#654321';
|
|
||||||
ctx.fillRect(castle.x + castle.width / 2 - 20, castle.y + castle.height - 50, 40, 50);
|
|
||||||
|
|
||||||
// Windows
|
|
||||||
ctx.fillStyle = '#FFFF00';
|
|
||||||
ctx.fillRect(castle.x + 20, castle.y + 20, 15, 20);
|
|
||||||
ctx.fillRect(castle.x + castle.width - 35, castle.y + 20, 15, 20);
|
|
||||||
|
|
||||||
// Borders
|
|
||||||
ctx.strokeStyle = '#000';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.strokeRect(castle.x, castle.y, castle.width, castle.height);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -599,25 +577,150 @@ export class Renderer {
|
|||||||
* Render debug hitboxes (for development)
|
* Render debug hitboxes (for development)
|
||||||
*/
|
*/
|
||||||
renderDebugHitboxes(ctx, gameState) {
|
renderDebugHitboxes(ctx, gameState) {
|
||||||
ctx.strokeStyle = '#FF00FF';
|
ctx.lineWidth = 2;
|
||||||
ctx.lineWidth = 1;
|
|
||||||
|
|
||||||
// Mario hitbox
|
// Mario hitbox (GREEN)
|
||||||
|
ctx.strokeStyle = '#00FF00';
|
||||||
ctx.strokeRect(gameState.mario.x, gameState.mario.y, gameState.mario.width, gameState.mario.height);
|
ctx.strokeRect(gameState.mario.x, gameState.mario.y, gameState.mario.width, gameState.mario.height);
|
||||||
|
|
||||||
// Enemy hitboxes
|
// Mario position info
|
||||||
|
ctx.fillStyle = '#00FF00';
|
||||||
|
ctx.font = 'bold 10px monospace';
|
||||||
|
ctx.fillText(`M: ${Math.floor(gameState.mario.x)},${Math.floor(gameState.mario.y)}`,
|
||||||
|
gameState.mario.x, gameState.mario.y - 5);
|
||||||
|
|
||||||
|
// Enemy hitboxes (RED)
|
||||||
if (gameState.enemies) {
|
if (gameState.enemies) {
|
||||||
gameState.enemies.forEach(enemy => {
|
ctx.strokeStyle = '#FF0000';
|
||||||
|
gameState.enemies.forEach((enemy, idx) => {
|
||||||
ctx.strokeRect(enemy.x, enemy.y, enemy.width, enemy.height);
|
ctx.strokeRect(enemy.x, enemy.y, enemy.width, enemy.height);
|
||||||
|
ctx.fillStyle = '#FF0000';
|
||||||
|
ctx.fillText(`E${idx}: ${enemy.type || 'goomba'}`, enemy.x, enemy.y - 5);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platform hitboxes
|
// Platform hitboxes (CYAN)
|
||||||
if (gameState.platforms) {
|
if (gameState.platforms) {
|
||||||
gameState.platforms.forEach(platform => {
|
ctx.strokeStyle = '#00FFFF';
|
||||||
|
gameState.platforms.forEach((platform, idx) => {
|
||||||
ctx.strokeRect(platform.x, platform.y, platform.width, platform.height);
|
ctx.strokeRect(platform.x, platform.y, platform.width, platform.height);
|
||||||
|
// Only show info for floating platforms (not ground)
|
||||||
|
if (platform.type !== 'ground') {
|
||||||
|
ctx.fillStyle = '#00FFFF';
|
||||||
|
ctx.fillText(`P${idx}: ${platform.type}`, platform.x, platform.y - 5);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Question block hitboxes (YELLOW)
|
||||||
|
if (gameState.questionBlocks) {
|
||||||
|
ctx.strokeStyle = '#FFFF00';
|
||||||
|
gameState.questionBlocks.forEach((block, idx) => {
|
||||||
|
ctx.strokeRect(block.x, block.y, block.width, block.height);
|
||||||
|
ctx.fillStyle = '#FFFF00';
|
||||||
|
ctx.fillText(`Q${idx}: ${block.hit ? 'HIT' : 'ACTIVE'}`, block.x, block.y - 5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wall hitboxes (ORANGE)
|
||||||
|
if (gameState.walls) {
|
||||||
|
ctx.strokeStyle = '#FF8800';
|
||||||
|
gameState.walls.forEach((wall, idx) => {
|
||||||
|
ctx.strokeRect(wall.x, wall.y, wall.width, wall.height);
|
||||||
|
ctx.fillStyle = '#FF8800';
|
||||||
|
ctx.fillText(`W${idx}: ${Math.floor(wall.height)}h`, wall.x, wall.y - 5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boss hitboxes (MAGENTA)
|
||||||
|
if (gameState.boss) {
|
||||||
|
ctx.strokeStyle = '#FF00FF';
|
||||||
|
ctx.strokeRect(gameState.boss.x, gameState.boss.y, gameState.boss.width, gameState.boss.height);
|
||||||
|
|
||||||
|
// Boss knee hitboxes (smaller boxes)
|
||||||
|
if (gameState.boss.leftKnee) {
|
||||||
|
ctx.strokeStyle = '#FF00FF';
|
||||||
|
ctx.strokeRect(
|
||||||
|
gameState.boss.leftKnee.x,
|
||||||
|
gameState.boss.leftKnee.y,
|
||||||
|
gameState.boss.leftKnee.width,
|
||||||
|
gameState.boss.leftKnee.height
|
||||||
|
);
|
||||||
|
ctx.fillStyle = '#FF00FF';
|
||||||
|
ctx.fillText('LEFT KNEE', gameState.boss.leftKnee.x, gameState.boss.leftKnee.y - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameState.boss.rightKnee) {
|
||||||
|
ctx.strokeStyle = '#FF00FF';
|
||||||
|
ctx.strokeRect(
|
||||||
|
gameState.boss.rightKnee.x,
|
||||||
|
gameState.boss.rightKnee.y,
|
||||||
|
gameState.boss.rightKnee.width,
|
||||||
|
gameState.boss.rightKnee.height
|
||||||
|
);
|
||||||
|
ctx.fillStyle = '#FF00FF';
|
||||||
|
ctx.fillText('RIGHT KNEE', gameState.boss.rightKnee.x, gameState.boss.rightKnee.y - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = '#FF00FF';
|
||||||
|
ctx.fillText(`BOSS HP: ${gameState.boss.health}/${gameState.boss.maxHealth}`,
|
||||||
|
gameState.boss.x, gameState.boss.y - 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flying eyes hitboxes (PINK)
|
||||||
|
if (gameState.flyingEyes) {
|
||||||
|
ctx.strokeStyle = '#FF69B4';
|
||||||
|
gameState.flyingEyes.forEach((eye, idx) => {
|
||||||
|
// Eye position is CENTER - draw rectangle hitbox as it's used in collision
|
||||||
|
const eyeLeft = eye.x - eye.width / 2;
|
||||||
|
const eyeTop = eye.y - eye.height / 2;
|
||||||
|
|
||||||
|
// Draw rectangle hitbox (actual collision box)
|
||||||
|
ctx.strokeRect(eyeLeft, eyeTop, eye.width, eye.height);
|
||||||
|
|
||||||
|
// Draw center point
|
||||||
|
ctx.fillStyle = '#FF69B4';
|
||||||
|
ctx.fillRect(eye.x - 2, eye.y - 2, 4, 4);
|
||||||
|
|
||||||
|
ctx.fillText(`EYE${idx}: ${eye.isChasing ? 'CHASE' : 'IDLE'}`, eyeLeft, eyeTop - 5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projectiles hitboxes (RED)
|
||||||
|
if (gameState.projectiles) {
|
||||||
|
ctx.strokeStyle = '#FF4444';
|
||||||
|
gameState.projectiles.forEach((proj, idx) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(proj.x, proj.y, proj.radius, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boulders hitboxes (GRAY)
|
||||||
|
if (gameState.boulders) {
|
||||||
|
ctx.strokeStyle = '#888888';
|
||||||
|
gameState.boulders.forEach((boulder, idx) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(boulder.x, boulder.y, boulder.radius, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = '#888888';
|
||||||
|
ctx.fillText(`B${idx}: ${boulder.hasLanded ? 'LANDED' : 'FLYING'}`,
|
||||||
|
boulder.x - 20, boulder.y - boulder.radius - 5);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug info overlay
|
||||||
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
||||||
|
ctx.fillRect(10, 50, 200, 80);
|
||||||
|
|
||||||
|
ctx.fillStyle = '#00FF00';
|
||||||
|
ctx.font = 'bold 12px monospace';
|
||||||
|
ctx.fillText('DEBUG MODE (D to toggle)', 15, 65);
|
||||||
|
ctx.fillText(`Mario: ${Math.floor(gameState.mario.x)}, ${Math.floor(gameState.mario.y)}`, 15, 80);
|
||||||
|
ctx.fillText(`Velocity: ${gameState.mario.velocityX.toFixed(1)}, ${gameState.mario.velocityY.toFixed(1)}`, 15, 95);
|
||||||
|
ctx.fillText(`Ground: ${gameState.mario.onGround ? 'YES' : 'NO'}`, 15, 110);
|
||||||
|
ctx.fillText(`Facing: ${gameState.mario.facing}`, 15, 125);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -68,8 +68,10 @@ export class FlyingEye {
|
|||||||
const currentTime = Date.now();
|
const currentTime = Date.now();
|
||||||
|
|
||||||
eyes.forEach(eye => {
|
eyes.forEach(eye => {
|
||||||
|
// Calculate distance from eye center to Mario center
|
||||||
|
const marioCenter = { x: mario.x + mario.width / 2, y: mario.y + mario.height / 2 };
|
||||||
const distanceToMario = Math.sqrt(
|
const distanceToMario = Math.sqrt(
|
||||||
Math.pow(eye.x - mario.x, 2) + Math.pow(eye.y - mario.y, 2)
|
Math.pow(eye.x - marioCenter.x, 2) + Math.pow(eye.y - marioCenter.y, 2)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Blinking animation
|
// Blinking animation
|
||||||
@ -100,9 +102,9 @@ export class FlyingEye {
|
|||||||
eye.dashDuration = 30; // 30 frames of dash
|
eye.dashDuration = 30; // 30 frames of dash
|
||||||
eye.lastDashTime = currentTime;
|
eye.lastDashTime = currentTime;
|
||||||
|
|
||||||
// Set dash velocity towards Mario
|
// Set dash velocity towards Mario center
|
||||||
const dx = mario.x - eye.x;
|
const dx = marioCenter.x - eye.x;
|
||||||
const dy = mario.y - eye.y;
|
const dy = marioCenter.y - eye.y;
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
eye.velocityX = (dx / distance) * eye.dashSpeed;
|
eye.velocityX = (dx / distance) * eye.dashSpeed;
|
||||||
eye.velocityY = (dy / distance) * eye.dashSpeed;
|
eye.velocityY = (dy / distance) * eye.dashSpeed;
|
||||||
@ -116,9 +118,9 @@ export class FlyingEye {
|
|||||||
eye.x += eye.velocityX;
|
eye.x += eye.velocityX;
|
||||||
eye.y += eye.velocityY;
|
eye.y += eye.velocityY;
|
||||||
} else if (eye.isChasing) {
|
} else if (eye.isChasing) {
|
||||||
// Chase Mario
|
// Chase Mario center
|
||||||
const dx = mario.x - eye.x;
|
const dx = marioCenter.x - eye.x;
|
||||||
const dy = mario.y - eye.y;
|
const dy = marioCenter.y - eye.y;
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
eye.velocityX = (dx / distance) * eye.chaseSpeed;
|
eye.velocityX = (dx / distance) * eye.chaseSpeed;
|
||||||
@ -139,6 +141,7 @@ export class FlyingEye {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Keep eyes within bounds (with some margin)
|
// Keep eyes within bounds (with some margin)
|
||||||
|
// Allow eyes to fly anywhere but not too close to edges
|
||||||
if (eye.x < 50) {
|
if (eye.x < 50) {
|
||||||
eye.x = 50;
|
eye.x = 50;
|
||||||
eye.velocityX = Math.abs(eye.velocityX);
|
eye.velocityX = Math.abs(eye.velocityX);
|
||||||
@ -147,8 +150,9 @@ export class FlyingEye {
|
|||||||
eye.y = 50;
|
eye.y = 50;
|
||||||
eye.velocityY = Math.abs(eye.velocityY);
|
eye.velocityY = Math.abs(eye.velocityY);
|
||||||
}
|
}
|
||||||
if (eye.y > 400) {
|
// Allow eyes to go much lower (near ground level) - 600px is just above ground (640)
|
||||||
eye.y = 400;
|
if (eye.y > 600) {
|
||||||
|
eye.y = 600;
|
||||||
eye.velocityY = -Math.abs(eye.velocityY);
|
eye.velocityY = -Math.abs(eye.velocityY);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -162,11 +166,15 @@ export class FlyingEye {
|
|||||||
*/
|
*/
|
||||||
static checkCollision(mario, eyes) {
|
static checkCollision(mario, eyes) {
|
||||||
for (const eye of eyes) {
|
for (const eye of eyes) {
|
||||||
// Simple rectangle collision
|
// Eye position is CENTER, convert to top-left corner for collision
|
||||||
if (mario.x < eye.x + eye.width &&
|
const eyeLeft = eye.x - eye.width / 2;
|
||||||
mario.x + mario.width > eye.x &&
|
const eyeTop = eye.y - eye.height / 2;
|
||||||
mario.y < eye.y + eye.height &&
|
|
||||||
mario.y + mario.height > eye.y) {
|
// Rectangle collision with centered eye position
|
||||||
|
if (mario.x < eyeLeft + eye.width &&
|
||||||
|
mario.x + mario.width > eyeLeft &&
|
||||||
|
mario.y < eyeTop + eye.height &&
|
||||||
|
mario.y + mario.height > eyeTop) {
|
||||||
return eye;
|
return eye;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,14 +72,47 @@ class AdventureReader extends Module {
|
|||||||
*/
|
*/
|
||||||
static getCompatibilityScore(content) {
|
static getCompatibilityScore(content) {
|
||||||
const vocab = content?.vocabulary || {};
|
const vocab = content?.vocabulary || {};
|
||||||
const sentences = content?.sentences || [];
|
|
||||||
const stories = content?.story?.chapters || content?.texts || [];
|
|
||||||
const dialogues = content?.dialogues || [];
|
const dialogues = content?.dialogues || [];
|
||||||
|
const stories = content?.story?.chapters || content?.texts || [];
|
||||||
|
|
||||||
const vocabCount = Object.keys(vocab).length;
|
const vocabCount = Object.keys(vocab).length;
|
||||||
const sentenceCount = sentences.length;
|
|
||||||
const storyCount = stories.length;
|
|
||||||
const dialogueCount = dialogues.length;
|
const dialogueCount = dialogues.length;
|
||||||
|
const storyCount = stories.length;
|
||||||
|
|
||||||
|
// Count sentences from ALL possible sources (matching _extractSentences logic)
|
||||||
|
let sentenceCount = 0;
|
||||||
|
|
||||||
|
// From story chapters
|
||||||
|
if (content?.story?.chapters) {
|
||||||
|
content.story.chapters.forEach(chapter => {
|
||||||
|
if (chapter.sentences) {
|
||||||
|
sentenceCount += chapter.sentences.filter(s => s.original && s.translation).length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// From direct sentences array
|
||||||
|
if (content?.sentences) {
|
||||||
|
sentenceCount += content.sentences.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// From phrases (array or object format)
|
||||||
|
if (content?.phrases) {
|
||||||
|
if (Array.isArray(content.phrases)) {
|
||||||
|
sentenceCount += content.phrases.filter(p => p.chinese && p.english).length;
|
||||||
|
} else if (typeof content.phrases === 'object') {
|
||||||
|
sentenceCount += Object.keys(content.phrases).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From lessons
|
||||||
|
if (content?.lessons) {
|
||||||
|
content.lessons.forEach(lesson => {
|
||||||
|
if (lesson.sentences) {
|
||||||
|
sentenceCount += lesson.sentences.filter(s => s.chinese && s.english).length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const totalContent = vocabCount + sentenceCount + storyCount + dialogueCount;
|
const totalContent = vocabCount + sentenceCount + storyCount + dialogueCount;
|
||||||
|
|
||||||
@ -93,18 +126,26 @@ class AdventureReader extends Module {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate weighted score based on content diversity
|
// Calculate weighted score based on content diversity and quantity
|
||||||
let score = 0;
|
let score = 0;
|
||||||
if (vocabCount > 0) score += Math.min(vocabCount / 10, 0.3);
|
|
||||||
if (sentenceCount > 0) score += Math.min(sentenceCount / 10, 0.3);
|
// Vocabulary: 0.3 points max (reach 100% at 8+ items)
|
||||||
if (storyCount > 0) score += Math.min(storyCount / 5, 0.2);
|
if (vocabCount > 0) score += Math.min(vocabCount / 8, 1) * 0.3;
|
||||||
if (dialogueCount > 0) score += Math.min(dialogueCount / 3, 0.2);
|
|
||||||
|
// Sentences: 0.4 points max (reach 100% at 8+ items) - most important for gameplay
|
||||||
|
if (sentenceCount > 0) score += Math.min(sentenceCount / 8, 1) * 0.4;
|
||||||
|
|
||||||
|
// Stories: 0.15 points max (reach 100% at 3+ items)
|
||||||
|
if (storyCount > 0) score += Math.min(storyCount / 3, 1) * 0.15;
|
||||||
|
|
||||||
|
// Dialogues: 0.15 points max (reach 100% at 3+ items)
|
||||||
|
if (dialogueCount > 0) score += Math.min(dialogueCount / 3, 1) * 0.15;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
score: Math.min(score, 1),
|
score: Math.min(score, 1),
|
||||||
reason: `Adventure content: ${vocabCount} vocab, ${sentenceCount} sentences, ${storyCount} stories, ${dialogueCount} dialogues`,
|
reason: `Adventure content: ${vocabCount} vocab, ${sentenceCount} sentences, ${storyCount} stories, ${dialogueCount} dialogues`,
|
||||||
requirements: ['vocabulary', 'sentences', 'stories', 'dialogues'],
|
requirements: ['vocabulary', 'sentences', 'stories', 'dialogues'],
|
||||||
optimalContent: { vocab: 10, sentences: 10, stories: 5, dialogues: 3 },
|
optimalContent: { vocab: 8, sentences: 8, stories: 3, dialogues: 3 },
|
||||||
details: `Rich adventure content with ${totalContent} total elements`
|
details: `Rich adventure content with ${totalContent} total elements`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -135,12 +176,16 @@ class AdventureReader extends Module {
|
|||||||
|
|
||||||
// Initialize game interface
|
// Initialize game interface
|
||||||
this._createGameInterface();
|
this._createGameInterface();
|
||||||
this._initializePlayer();
|
|
||||||
this._setupEventListeners();
|
// Wait for DOM to render before initializing player
|
||||||
this._updateContentInfo();
|
requestAnimationFrame(() => {
|
||||||
this._generateGameObjects();
|
this._initializePlayer();
|
||||||
this._generateDecorations();
|
this._setupEventListeners();
|
||||||
this._startGameLoop();
|
this._updateContentInfo();
|
||||||
|
this._generateGameObjects();
|
||||||
|
this._generateDecorations();
|
||||||
|
this._startGameLoop();
|
||||||
|
});
|
||||||
|
|
||||||
// Start the game
|
// Start the game
|
||||||
this._gameStartTime = Date.now();
|
this._gameStartTime = Date.now();
|
||||||
@ -246,6 +291,8 @@ class AdventureReader extends Module {
|
|||||||
_extractSentences() {
|
_extractSentences() {
|
||||||
let sentences = [];
|
let sentences = [];
|
||||||
|
|
||||||
|
console.log('AdventureReader: Extracting sentences from content', this._content);
|
||||||
|
|
||||||
// Support for Dragon's Pearl structure
|
// Support for Dragon's Pearl structure
|
||||||
if (this._content.story?.chapters) {
|
if (this._content.story?.chapters) {
|
||||||
this._content.story.chapters.forEach(chapter => {
|
this._content.story.chapters.forEach(chapter => {
|
||||||
@ -268,13 +315,61 @@ class AdventureReader extends Module {
|
|||||||
if (this._content.sentences) {
|
if (this._content.sentences) {
|
||||||
this._content.sentences.forEach(sentence => {
|
this._content.sentences.forEach(sentence => {
|
||||||
sentences.push({
|
sentences.push({
|
||||||
original_language: sentence.english || sentence.original_language,
|
original_language: sentence.english || sentence.original_language || sentence.target_language,
|
||||||
user_language: sentence.chinese || sentence.french || sentence.user_language || sentence.translation,
|
user_language: sentence.chinese || sentence.french || sentence.user_language || sentence.translation,
|
||||||
pronunciation: sentence.pronunciation || sentence.prononciation
|
pronunciation: sentence.pronunciation || sentence.prononciation
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Support for LEDU format with phrases/lessons
|
||||||
|
if (this._content.phrases) {
|
||||||
|
// Check if phrases is an array or object
|
||||||
|
if (Array.isArray(this._content.phrases)) {
|
||||||
|
this._content.phrases.forEach(phrase => {
|
||||||
|
if (phrase.chinese && phrase.english) {
|
||||||
|
sentences.push({
|
||||||
|
original_language: phrase.chinese,
|
||||||
|
user_language: phrase.english,
|
||||||
|
pronunciation: phrase.pinyin
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (typeof this._content.phrases === 'object') {
|
||||||
|
// Handle object format (key-value pairs)
|
||||||
|
Object.entries(this._content.phrases).forEach(([phraseText, phraseData]) => {
|
||||||
|
const translation = typeof phraseData === 'object' ? phraseData.user_language : phraseData;
|
||||||
|
const pronunciation = typeof phraseData === 'object' ? phraseData.pronunciation : undefined;
|
||||||
|
|
||||||
|
if (phraseText && translation) {
|
||||||
|
sentences.push({
|
||||||
|
original_language: phraseText,
|
||||||
|
user_language: translation,
|
||||||
|
pronunciation: pronunciation
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support for lessons with sentences
|
||||||
|
if (this._content.lessons) {
|
||||||
|
this._content.lessons.forEach(lesson => {
|
||||||
|
if (lesson.sentences) {
|
||||||
|
lesson.sentences.forEach(sentence => {
|
||||||
|
if (sentence.chinese && sentence.english) {
|
||||||
|
sentences.push({
|
||||||
|
original_language: sentence.chinese,
|
||||||
|
user_language: sentence.english,
|
||||||
|
pronunciation: sentence.pinyin
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('AdventureReader: Extracted sentences:', sentences.length);
|
||||||
return sentences.filter(s => s.original_language && s.user_language);
|
return sentences.filter(s => s.original_language && s.user_language);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -364,14 +459,14 @@ class AdventureReader extends Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-icon {
|
.stat-icon {
|
||||||
font-size: 1.2rem;
|
font-size: 0.72rem; /* 1.2 / 1.66 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-info {
|
.progress-info {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
padding: 8px 15px;
|
padding: 8px 15px;
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.54rem; /* 0.9 / 1.66 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-map {
|
.game-map {
|
||||||
@ -384,7 +479,7 @@ class AdventureReader extends Module {
|
|||||||
|
|
||||||
.player {
|
.player {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
font-size: 2.5rem;
|
font-size: 1.51rem; /* 2.5 / 1.66 */
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.3));
|
filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.3));
|
||||||
@ -393,7 +488,7 @@ class AdventureReader extends Module {
|
|||||||
|
|
||||||
.pot, .enemy {
|
.pot, .enemy {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
font-size: 2rem;
|
font-size: 1.2rem; /* 2 / 1.66 */
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
@ -443,12 +538,12 @@ class AdventureReader extends Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.instructions {
|
.instructions {
|
||||||
font-size: 0.9rem;
|
font-size: 0.54rem; /* 0.9 / 1.66 */
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-summary {
|
.content-summary {
|
||||||
font-size: 0.85rem;
|
font-size: 0.51rem; /* 0.85 / 1.66 */
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@ -459,7 +554,7 @@ class AdventureReader extends Module {
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.54rem; /* 0.9 / 1.66 */
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
@ -531,7 +626,7 @@ class AdventureReader extends Module {
|
|||||||
|
|
||||||
.modal-header h3 {
|
.modal-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.3rem;
|
font-size: 0.78rem; /* 1.3 / 1.66 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
@ -560,7 +655,7 @@ class AdventureReader extends Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.original-text {
|
.original-text {
|
||||||
font-size: 1.4rem;
|
font-size: 0.84rem; /* 1.4 / 1.66 */
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
@ -568,14 +663,14 @@ class AdventureReader extends Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.translation-text {
|
.translation-text {
|
||||||
font-size: 1.1rem;
|
font-size: 0.66rem; /* 1.1 / 1.66 */
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pronunciation-text {
|
.pronunciation-text {
|
||||||
font-size: 1rem;
|
font-size: 0.60rem; /* 1.0 / 1.66 */
|
||||||
color: #7c3aed;
|
color: #7c3aed;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
@ -601,19 +696,19 @@ class AdventureReader extends Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vocab-word {
|
.vocab-word {
|
||||||
font-size: 2rem;
|
font-size: 1.2rem; /* 2.0 / 1.66 */
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vocab-translation {
|
.vocab-translation {
|
||||||
font-size: 1.3rem;
|
font-size: 0.78rem; /* 1.3 / 1.66 */
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vocab-pronunciation {
|
.vocab-pronunciation {
|
||||||
font-size: 1rem;
|
font-size: 0.60rem; /* 1.0 / 1.66 */
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
@ -1042,9 +1137,6 @@ class AdventureReader extends Module {
|
|||||||
<!-- Sentence content -->
|
<!-- Sentence content -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
|
||||||
<button class="control-btn primary" id="continue-btn">Continue Adventure →</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1084,7 +1176,6 @@ class AdventureReader extends Module {
|
|||||||
_setupEventListeners() {
|
_setupEventListeners() {
|
||||||
// Control buttons
|
// Control buttons
|
||||||
document.getElementById('restart-btn').addEventListener('click', () => this._restart());
|
document.getElementById('restart-btn').addEventListener('click', () => this._restart());
|
||||||
document.getElementById('continue-btn').addEventListener('click', () => this._closeModal());
|
|
||||||
|
|
||||||
// Exit button
|
// Exit button
|
||||||
const exitButton = document.getElementById('exit-adventure');
|
const exitButton = document.getElementById('exit-adventure');
|
||||||
@ -1199,7 +1290,7 @@ class AdventureReader extends Module {
|
|||||||
y: position.y,
|
y: position.y,
|
||||||
defeated: false,
|
defeated: false,
|
||||||
moveDirection: Math.random() * Math.PI * 2,
|
moveDirection: Math.random() * Math.PI * 2,
|
||||||
speed: 0.6 + Math.random() * 0.6,
|
speed: 1.2 + Math.random() * 1.2, // 2x faster (was 0.6 + 0.6)
|
||||||
pattern: pattern,
|
pattern: pattern,
|
||||||
patrolStartX: position.x,
|
patrolStartX: position.x,
|
||||||
patrolStartY: position.y,
|
patrolStartY: position.y,
|
||||||
@ -1209,7 +1300,8 @@ class AdventureReader extends Module {
|
|||||||
circleAngle: Math.random() * Math.PI * 2,
|
circleAngle: Math.random() * Math.PI * 2,
|
||||||
changeDirectionTimer: 0,
|
changeDirectionTimer: 0,
|
||||||
dashCooldown: 0,
|
dashCooldown: 0,
|
||||||
isDashing: false
|
isDashing: false,
|
||||||
|
dashDuration: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1257,7 +1349,7 @@ class AdventureReader extends Module {
|
|||||||
const position = this._getDecorationPosition(mapWidth, mapHeight, 60);
|
const position = this._getDecorationPosition(mapWidth, mapHeight, 60);
|
||||||
tree.style.left = position.x + 'px';
|
tree.style.left = position.x + 'px';
|
||||||
tree.style.top = position.y + 'px';
|
tree.style.top = position.y + 'px';
|
||||||
tree.style.fontSize = (25 + Math.random() * 15) + 'px';
|
tree.style.fontSize = ((25 + Math.random() * 15) / 1.66) + 'px'; // Reduced by 1.66
|
||||||
|
|
||||||
gameMap.appendChild(tree);
|
gameMap.appendChild(tree);
|
||||||
}
|
}
|
||||||
@ -1273,7 +1365,7 @@ class AdventureReader extends Module {
|
|||||||
const position = this._getDecorationPosition(mapWidth, mapHeight, 30);
|
const position = this._getDecorationPosition(mapWidth, mapHeight, 30);
|
||||||
grass.style.left = position.x + 'px';
|
grass.style.left = position.x + 'px';
|
||||||
grass.style.top = position.y + 'px';
|
grass.style.top = position.y + 'px';
|
||||||
grass.style.fontSize = (15 + Math.random() * 8) + 'px';
|
grass.style.fontSize = ((15 + Math.random() * 8) / 1.66) + 'px'; // Reduced by 1.66
|
||||||
|
|
||||||
gameMap.appendChild(grass);
|
gameMap.appendChild(grass);
|
||||||
}
|
}
|
||||||
@ -1288,7 +1380,7 @@ class AdventureReader extends Module {
|
|||||||
const position = this._getDecorationPosition(mapWidth, mapHeight, 40);
|
const position = this._getDecorationPosition(mapWidth, mapHeight, 40);
|
||||||
rock.style.left = position.x + 'px';
|
rock.style.left = position.x + 'px';
|
||||||
rock.style.top = position.y + 'px';
|
rock.style.top = position.y + 'px';
|
||||||
rock.style.fontSize = (20 + Math.random() * 10) + 'px';
|
rock.style.fontSize = ((20 + Math.random() * 10) / 1.66) + 'px'; // Reduced by 1.66
|
||||||
|
|
||||||
gameMap.appendChild(rock);
|
gameMap.appendChild(rock);
|
||||||
}
|
}
|
||||||
@ -1334,6 +1426,8 @@ class AdventureReader extends Module {
|
|||||||
|
|
||||||
_startGameLoop() {
|
_startGameLoop() {
|
||||||
const animate = () => {
|
const animate = () => {
|
||||||
|
if (this._isDestroyed) return; // Stop animation if game is destroyed
|
||||||
|
|
||||||
if (!this._isGamePaused) {
|
if (!this._isGamePaused) {
|
||||||
this._moveEnemies();
|
this._moveEnemies();
|
||||||
}
|
}
|
||||||
@ -1344,6 +1438,8 @@ class AdventureReader extends Module {
|
|||||||
|
|
||||||
_moveEnemies() {
|
_moveEnemies() {
|
||||||
const gameMap = document.getElementById('game-map');
|
const gameMap = document.getElementById('game-map');
|
||||||
|
if (!gameMap) return; // Exit if game map doesn't exist
|
||||||
|
|
||||||
const mapRect = gameMap.getBoundingClientRect();
|
const mapRect = gameMap.getBoundingClientRect();
|
||||||
const mapWidth = mapRect.width;
|
const mapWidth = mapRect.width;
|
||||||
const mapHeight = mapRect.height;
|
const mapHeight = mapRect.height;
|
||||||
@ -1366,6 +1462,15 @@ class AdventureReader extends Module {
|
|||||||
enemy.element.style.left = enemy.x + 'px';
|
enemy.element.style.left = enemy.x + 'px';
|
||||||
enemy.element.style.top = enemy.y + 'px';
|
enemy.element.style.top = enemy.y + 'px';
|
||||||
|
|
||||||
|
// Add red shadow effect during dash
|
||||||
|
if (enemy.isDashing) {
|
||||||
|
enemy.element.style.filter = 'drop-shadow(0 0 10px rgba(255, 0, 0, 0.8)) drop-shadow(0 0 20px rgba(255, 0, 0, 0.5))';
|
||||||
|
enemy.element.style.transform = 'scale(1.1)'; // Slightly larger during dash
|
||||||
|
} else {
|
||||||
|
enemy.element.style.filter = '';
|
||||||
|
enemy.element.style.transform = '';
|
||||||
|
}
|
||||||
|
|
||||||
this._checkPlayerEnemyCollision(enemy);
|
this._checkPlayerEnemyCollision(enemy);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1401,10 +1506,43 @@ class AdventureReader extends Module {
|
|||||||
this._player.y - enemy.y,
|
this._player.y - enemy.y,
|
||||||
this._player.x - enemy.x
|
this._player.x - enemy.x
|
||||||
);
|
);
|
||||||
enemy.moveDirection = angleToPlayer + (Math.random() - 0.5) * 0.3;
|
const distanceToPlayer = Math.sqrt(
|
||||||
|
Math.pow(this._player.x - enemy.x, 2) + Math.pow(this._player.y - enemy.y, 2)
|
||||||
|
);
|
||||||
|
|
||||||
enemy.x += Math.cos(enemy.moveDirection) * (enemy.speed * 0.8);
|
// Decrease dash cooldown
|
||||||
enemy.y += Math.sin(enemy.moveDirection) * (enemy.speed * 0.8);
|
if (enemy.dashCooldown > 0) {
|
||||||
|
enemy.dashCooldown--;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger dash if close enough and cooldown is ready
|
||||||
|
if (!enemy.isDashing && enemy.dashCooldown <= 0 && distanceToPlayer < 300 && distanceToPlayer > 80) {
|
||||||
|
enemy.isDashing = true;
|
||||||
|
enemy.dashDuration = 30; // 30 frames of dash
|
||||||
|
enemy.dashCooldown = 120; // 120 frames cooldown (~2 seconds)
|
||||||
|
|
||||||
|
// Choose perpendicular direction (90° or -90° randomly)
|
||||||
|
const perpendicularOffset = Math.random() < 0.5 ? Math.PI / 2 : -Math.PI / 2;
|
||||||
|
enemy.dashAngle = angleToPlayer + perpendicularOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dashing (perpendicular to player direction - evasive maneuver)
|
||||||
|
if (enemy.isDashing) {
|
||||||
|
// Use stored dash angle (perpendicular to player at dash start)
|
||||||
|
enemy.moveDirection = enemy.dashAngle;
|
||||||
|
enemy.x += Math.cos(enemy.dashAngle) * (enemy.speed * 3.5); // 3.5x speed during dash
|
||||||
|
enemy.y += Math.sin(enemy.dashAngle) * (enemy.speed * 3.5);
|
||||||
|
|
||||||
|
enemy.dashDuration--;
|
||||||
|
if (enemy.dashDuration <= 0) {
|
||||||
|
enemy.isDashing = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal chase movement
|
||||||
|
enemy.moveDirection = angleToPlayer + (Math.random() - 0.5) * 0.3;
|
||||||
|
enemy.x += Math.cos(enemy.moveDirection) * (enemy.speed * 0.8);
|
||||||
|
enemy.y += Math.sin(enemy.moveDirection) * (enemy.speed * 0.8);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'wander':
|
case 'wander':
|
||||||
@ -1629,11 +1767,25 @@ class AdventureReader extends Module {
|
|||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
modal.classList.add('show');
|
modal.classList.add('show');
|
||||||
|
|
||||||
|
// Calculate reading time based on text length and TTS
|
||||||
|
const textLength = sentence.original_language.length;
|
||||||
|
// Average reading speed: ~5 chars/second at 0.8 rate
|
||||||
|
// Add base delay of 800ms (600ms initial + 200ms buffer)
|
||||||
|
const ttsDelay = 600; // Initial delay before TTS starts
|
||||||
|
const readingTime = (textLength / 5) * 1000; // Characters to milliseconds
|
||||||
|
const bufferTime = 500; // Extra buffer after TTS ends
|
||||||
|
const totalTime = ttsDelay + readingTime + bufferTime;
|
||||||
|
|
||||||
if (this._config.autoPlayTTS && this._config.ttsEnabled) {
|
if (this._config.autoPlayTTS && this._config.ttsEnabled) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this._speakText(sentence.original_language, { rate: 0.8 });
|
this._speakText(sentence.original_language, { rate: 0.8 });
|
||||||
}, 600);
|
}, ttsDelay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-close modal after TTS completes
|
||||||
|
setTimeout(() => {
|
||||||
|
this._closeModal();
|
||||||
|
}, totalTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
_closeModal() {
|
_closeModal() {
|
||||||
@ -1642,6 +1794,9 @@ class AdventureReader extends Module {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
modal.style.display = 'none';
|
modal.style.display = 'none';
|
||||||
this._isGamePaused = false;
|
this._isGamePaused = false;
|
||||||
|
|
||||||
|
// Grant 1 second invulnerability after closing reading modal
|
||||||
|
this._grantPostReadingInvulnerability();
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
this._checkGameComplete();
|
this._checkGameComplete();
|
||||||
@ -1705,7 +1860,8 @@ class AdventureReader extends Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_checkPlayerEnemyCollision(enemy) {
|
_checkPlayerEnemyCollision(enemy) {
|
||||||
if (this._isPlayerInvulnerable || enemy.defeated) return;
|
// Skip collision check during pause (reading), invulnerability, or defeated enemy
|
||||||
|
if (this._isGamePaused || this._isPlayerInvulnerable || enemy.defeated) return;
|
||||||
|
|
||||||
const distance = Math.sqrt(
|
const distance = Math.sqrt(
|
||||||
Math.pow(this._player.x - enemy.x, 2) + Math.pow(this._player.y - enemy.y, 2)
|
Math.pow(this._player.x - enemy.x, 2) + Math.pow(this._player.y - enemy.y, 2)
|
||||||
@ -1729,6 +1885,7 @@ class AdventureReader extends Module {
|
|||||||
this._isPlayerInvulnerable = true;
|
this._isPlayerInvulnerable = true;
|
||||||
const playerElement = document.getElementById('player');
|
const playerElement = document.getElementById('player');
|
||||||
|
|
||||||
|
// Blinking animation (visual only)
|
||||||
let blinkCount = 0;
|
let blinkCount = 0;
|
||||||
const blinkInterval = setInterval(() => {
|
const blinkInterval = setInterval(() => {
|
||||||
playerElement.style.opacity = playerElement.style.opacity === '0.3' ? '1' : '0.3';
|
playerElement.style.opacity = playerElement.style.opacity === '0.3' ? '1' : '0.3';
|
||||||
@ -1738,10 +1895,14 @@ class AdventureReader extends Module {
|
|||||||
clearInterval(blinkInterval);
|
clearInterval(blinkInterval);
|
||||||
playerElement.style.opacity = '1';
|
playerElement.style.opacity = '1';
|
||||||
playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))';
|
playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))';
|
||||||
this._isPlayerInvulnerable = false;
|
|
||||||
}
|
}
|
||||||
}, 250);
|
}, 250);
|
||||||
|
|
||||||
|
// Actual invulnerability duration (independent of blink animation)
|
||||||
|
this._invulnerabilityTimeout = setTimeout(() => {
|
||||||
|
this._isPlayerInvulnerable = false;
|
||||||
|
}, 2000); // 2 seconds of actual invulnerability
|
||||||
|
|
||||||
this._showDamagePopup();
|
this._showDamagePopup();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1763,6 +1924,23 @@ class AdventureReader extends Module {
|
|||||||
this._showInvulnerabilityPopup();
|
this._showInvulnerabilityPopup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_grantPostReadingInvulnerability() {
|
||||||
|
this._isPlayerInvulnerable = true;
|
||||||
|
const playerElement = document.getElementById('player');
|
||||||
|
|
||||||
|
if (this._invulnerabilityTimeout) {
|
||||||
|
clearTimeout(this._invulnerabilityTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brief blue glow to indicate post-reading protection
|
||||||
|
playerElement.style.filter = 'drop-shadow(0 0 10px rgba(100, 150, 255, 0.8)) brightness(1.2)';
|
||||||
|
|
||||||
|
this._invulnerabilityTimeout = setTimeout(() => {
|
||||||
|
playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))';
|
||||||
|
this._isPlayerInvulnerable = false;
|
||||||
|
}, 1000); // 1 second protection
|
||||||
|
}
|
||||||
|
|
||||||
_refreshAttackInvulnerability() {
|
_refreshAttackInvulnerability() {
|
||||||
if (this._invulnerabilityTimeout) {
|
if (this._invulnerabilityTimeout) {
|
||||||
clearTimeout(this._invulnerabilityTimeout);
|
clearTimeout(this._invulnerabilityTimeout);
|
||||||
|
|||||||
@ -521,12 +521,12 @@ class FillTheBlank extends Module {
|
|||||||
const points = 10 * blanks.length;
|
const points = 10 * blanks.length;
|
||||||
this._score += points;
|
this._score += points;
|
||||||
this._showFeedback(`🎉 Perfect! +${points} points`, 'success');
|
this._showFeedback(`🎉 Perfect! +${points} points`, 'success');
|
||||||
this._speakWord(blanks[0].answer); // Pronounce first blank word
|
|
||||||
|
|
||||||
setTimeout(() => {
|
// Read the complete sentence
|
||||||
|
this._speakSentence(this._currentExercise.original, () => {
|
||||||
this._currentIndex++;
|
this._currentIndex++;
|
||||||
this._loadNextExercise();
|
this._loadNextExercise();
|
||||||
}, 1500);
|
});
|
||||||
} else {
|
} else {
|
||||||
this._errors++;
|
this._errors++;
|
||||||
if (correctCount > 0) {
|
if (correctCount > 0) {
|
||||||
@ -574,42 +574,106 @@ class FillTheBlank extends Module {
|
|||||||
input.classList.add('revealed');
|
input.classList.add('revealed');
|
||||||
});
|
});
|
||||||
|
|
||||||
this._showFeedback('📖 Answers revealed! Next exercise...', 'info');
|
this._showFeedback('📖 Answers revealed!', 'info');
|
||||||
|
|
||||||
setTimeout(() => {
|
// Read the complete sentence before moving on
|
||||||
|
this._speakSentence(this._currentExercise.original, () => {
|
||||||
this._currentIndex++;
|
this._currentIndex++;
|
||||||
this._loadNextExercise();
|
this._loadNextExercise();
|
||||||
}, 2000);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_speakWord(word) {
|
async _speakSentence(sentence, callback) {
|
||||||
if (!window.speechSynthesis || !word) return;
|
if (!window.speechSynthesis || !sentence) {
|
||||||
|
if (callback) setTimeout(callback, 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
window.speechSynthesis.cancel();
|
window.speechSynthesis.cancel();
|
||||||
|
|
||||||
const utterance = new SpeechSynthesisUtterance(word);
|
// For predefined exercises, replace underscores with actual answers
|
||||||
utterance.lang = 'en-US';
|
let textToSpeak = sentence;
|
||||||
utterance.rate = 0.9;
|
if (this._currentExercise && this._currentExercise.type === 'predefined') {
|
||||||
|
// Replace each _______ with the correct answer
|
||||||
|
this._currentExercise.blanks.forEach(blank => {
|
||||||
|
textToSpeak = textToSpeak.replace('_______', blank.answer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const utterance = new SpeechSynthesisUtterance(textToSpeak);
|
||||||
|
const targetLanguage = this._content?.language || 'en-US';
|
||||||
|
utterance.lang = targetLanguage;
|
||||||
|
utterance.rate = 0.8;
|
||||||
utterance.pitch = 1.0;
|
utterance.pitch = 1.0;
|
||||||
utterance.volume = 1.0;
|
utterance.volume = 1.0;
|
||||||
|
|
||||||
const voices = window.speechSynthesis.getVoices();
|
const voices = await this._getVoices();
|
||||||
|
const langPrefix = targetLanguage.split('-')[0];
|
||||||
const preferredVoice = voices.find(v =>
|
const preferredVoice = voices.find(v =>
|
||||||
v.lang.startsWith('en') &&
|
v.lang.startsWith(langPrefix) && v.default
|
||||||
(v.name.includes('Google') || v.name.includes('Neural') || v.name.includes('Microsoft'))
|
) || voices.find(v => v.lang.startsWith(langPrefix));
|
||||||
);
|
|
||||||
|
|
||||||
if (preferredVoice) {
|
if (preferredVoice) {
|
||||||
utterance.voice = preferredVoice;
|
utterance.voice = preferredVoice;
|
||||||
|
console.log(`🔊 Using voice: ${preferredVoice.name} (${preferredVoice.lang})`);
|
||||||
|
} else {
|
||||||
|
console.warn(`🔊 No voice found for: ${targetLanguage}, available:`, voices.map(v => v.lang));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Call callback when speech ends
|
||||||
|
utterance.onend = () => {
|
||||||
|
if (callback) {
|
||||||
|
setTimeout(callback, 500); // Small delay after speech
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
utterance.onerror = () => {
|
||||||
|
console.warn('Speech synthesis error');
|
||||||
|
if (callback) setTimeout(callback, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
window.speechSynthesis.speak(utterance);
|
window.speechSynthesis.speak(utterance);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Speech synthesis failed:', error);
|
console.warn('Speech synthesis failed:', error);
|
||||||
|
if (callback) setTimeout(callback, 1500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available speech synthesis voices, waiting for them to load if necessary
|
||||||
|
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getVoices() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let voices = window.speechSynthesis.getVoices();
|
||||||
|
|
||||||
|
// If voices are already loaded, return them immediately
|
||||||
|
if (voices.length > 0) {
|
||||||
|
resolve(voices);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, wait for voiceschanged event
|
||||||
|
const voicesChangedHandler = () => {
|
||||||
|
voices = window.speechSynthesis.getVoices();
|
||||||
|
if (voices.length > 0) {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(voices);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
|
||||||
|
// Fallback timeout in case voices never load
|
||||||
|
setTimeout(() => {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(window.speechSynthesis.getVoices());
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
_showFeedback(message, type = 'info') {
|
_showFeedback(message, type = 'info') {
|
||||||
const feedbackArea = document.getElementById('feedback-area');
|
const feedbackArea = document.getElementById('feedback-area');
|
||||||
if (feedbackArea) {
|
if (feedbackArea) {
|
||||||
|
|||||||
@ -1727,7 +1727,7 @@ class FlashcardLearning extends Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Audio System
|
// Audio System
|
||||||
_playAudio(text) {
|
async _playAudio(text) {
|
||||||
if ('speechSynthesis' in window) {
|
if ('speechSynthesis' in window) {
|
||||||
// Cancel any ongoing speech
|
// Cancel any ongoing speech
|
||||||
speechSynthesis.cancel();
|
speechSynthesis.cancel();
|
||||||
@ -1742,7 +1742,7 @@ class FlashcardLearning extends Module {
|
|||||||
utterance.volume = 1.0;
|
utterance.volume = 1.0;
|
||||||
|
|
||||||
// Try to find a suitable voice for the language
|
// Try to find a suitable voice for the language
|
||||||
const voices = speechSynthesis.getVoices();
|
const voices = await this._getVoices();
|
||||||
if (voices.length > 0) {
|
if (voices.length > 0) {
|
||||||
// Find voice matching the chapter language
|
// Find voice matching the chapter language
|
||||||
const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
|
const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
|
||||||
@ -1753,6 +1753,8 @@ class FlashcardLearning extends Module {
|
|||||||
if (matchingVoice) {
|
if (matchingVoice) {
|
||||||
utterance.voice = matchingVoice;
|
utterance.voice = matchingVoice;
|
||||||
console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang);
|
console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang);
|
||||||
|
} else {
|
||||||
|
console.warn(`🔊 No voice found for: ${chapterLanguage}, available:`, voices.map(v => v.lang));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1760,6 +1762,40 @@ class FlashcardLearning extends Module {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available speech synthesis voices, waiting for them to load if necessary
|
||||||
|
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getVoices() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let voices = window.speechSynthesis.getVoices();
|
||||||
|
|
||||||
|
// If voices are already loaded, return them immediately
|
||||||
|
if (voices.length > 0) {
|
||||||
|
resolve(voices);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, wait for voiceschanged event
|
||||||
|
const voicesChangedHandler = () => {
|
||||||
|
voices = window.speechSynthesis.getVoices();
|
||||||
|
if (voices.length > 0) {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(voices);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
|
||||||
|
// Fallback timeout in case voices never load
|
||||||
|
setTimeout(() => {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(window.speechSynthesis.getVoices());
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
_highlightPronunciation() {
|
_highlightPronunciation() {
|
||||||
// Highlight pronunciation when TTS is played
|
// Highlight pronunciation when TTS is played
|
||||||
const pronunciation = document.getElementById('pronunciation-display');
|
const pronunciation = document.getElementById('pronunciation-display');
|
||||||
|
|||||||
@ -333,19 +333,39 @@ class GrammarDiscovery extends Module {
|
|||||||
if (Array.isArray(content.vocabulary)) {
|
if (Array.isArray(content.vocabulary)) {
|
||||||
vocabulary = content.vocabulary.slice(0, 10); // Limit to first 10
|
vocabulary = content.vocabulary.slice(0, 10); // Limit to first 10
|
||||||
} else if (content.vocabulary && typeof content.vocabulary === 'object') {
|
} else if (content.vocabulary && typeof content.vocabulary === 'object') {
|
||||||
vocabulary = Object.entries(content.vocabulary).slice(0, 10).map(([word, data]) => ({
|
vocabulary = Object.entries(content.vocabulary).slice(0, 10).map(([word, data]) => {
|
||||||
chinese: word,
|
// Extract translation properly from different formats
|
||||||
english: data.english || data.translation || data,
|
let translation;
|
||||||
pronunciation: data.pronunciation || data.prononciation || '',
|
if (typeof data === 'string') {
|
||||||
text: word
|
translation = data;
|
||||||
}));
|
} else if (typeof data === 'object') {
|
||||||
|
translation = data.english || data.translation || data.user_language;
|
||||||
|
// If still an object, extract the user_language property
|
||||||
|
if (typeof translation === 'object' && translation.user_language) {
|
||||||
|
translation = translation.user_language;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chinese: word,
|
||||||
|
english: translation || word,
|
||||||
|
pronunciation: (typeof data === 'object' ? (data.pronunciation || data.prononciation) : '') || '',
|
||||||
|
text: word
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
vocabulary.forEach(item => {
|
vocabulary.forEach(item => {
|
||||||
if (typeof item === 'object') {
|
if (typeof item === 'object') {
|
||||||
|
// Extract translation - handle both string and object formats
|
||||||
|
let translation = item.english || item.translation;
|
||||||
|
if (typeof translation === 'object') {
|
||||||
|
translation = translation.english || translation.translation || JSON.stringify(translation);
|
||||||
|
}
|
||||||
|
|
||||||
examples.push({
|
examples.push({
|
||||||
chinese: item.chinese || item.text || item.word,
|
chinese: item.chinese || item.text || item.word,
|
||||||
english: item.english || item.translation,
|
english: translation || `Translation for: ${item.chinese || item.text || item.word}`,
|
||||||
pronunciation: item.pronunciation || item.prononciation || '',
|
pronunciation: item.pronunciation || item.prononciation || '',
|
||||||
explanation: `Practice word: ${item.chinese || item.text || item.word}`
|
explanation: `Practice word: ${item.chinese || item.text || item.word}`
|
||||||
});
|
});
|
||||||
@ -370,19 +390,41 @@ class GrammarDiscovery extends Module {
|
|||||||
if (Array.isArray(content.vocabulary)) {
|
if (Array.isArray(content.vocabulary)) {
|
||||||
vocabulary = content.vocabulary.slice(0, 5);
|
vocabulary = content.vocabulary.slice(0, 5);
|
||||||
} else if (content.vocabulary && typeof content.vocabulary === 'object') {
|
} else if (content.vocabulary && typeof content.vocabulary === 'object') {
|
||||||
vocabulary = Object.entries(content.vocabulary).slice(0, 5).map(([word, data]) => ({
|
vocabulary = Object.entries(content.vocabulary).slice(0, 5).map(([word, data]) => {
|
||||||
chinese: word,
|
// Extract translation properly from different formats
|
||||||
english: data.english || data.translation || data,
|
let translation;
|
||||||
text: word
|
if (typeof data === 'string') {
|
||||||
}));
|
translation = data;
|
||||||
|
} else if (typeof data === 'object') {
|
||||||
|
translation = data.english || data.translation || data.user_language;
|
||||||
|
// If still an object, extract the user_language property
|
||||||
|
if (typeof translation === 'object' && translation.user_language) {
|
||||||
|
translation = translation.user_language;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chinese: word,
|
||||||
|
english: translation || word,
|
||||||
|
pronunciation: (typeof data === 'object' ? (data.pronunciation || data.prononciation) : '') || '',
|
||||||
|
text: word
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
vocabulary.forEach(item => {
|
vocabulary.forEach(item => {
|
||||||
if (typeof item === 'object') {
|
if (typeof item === 'object') {
|
||||||
const word = item.chinese || item.text || item.word;
|
const word = item.chinese || item.text || item.word;
|
||||||
|
|
||||||
|
// Extract translation - handle both string and object formats
|
||||||
|
let translation = item.english || item.translation;
|
||||||
|
if (typeof translation === 'object') {
|
||||||
|
translation = translation.english || translation.translation || JSON.stringify(translation);
|
||||||
|
}
|
||||||
|
|
||||||
examples.push({
|
examples.push({
|
||||||
chinese: word,
|
chinese: word,
|
||||||
english: item.english || item.translation || `Formation example: ${word}`,
|
english: translation || `Formation example: ${word}`,
|
||||||
pronunciation: item.pronunciation || item.prononciation || '',
|
pronunciation: item.pronunciation || item.prononciation || '',
|
||||||
explanation: `Analyze the structure and formation of: ${word}`
|
explanation: `Analyze the structure and formation of: ${word}`
|
||||||
});
|
});
|
||||||
|
|||||||
@ -69,6 +69,7 @@ class MarioEducational extends Module {
|
|||||||
this._particles = [];
|
this._particles = [];
|
||||||
this._walls = [];
|
this._walls = [];
|
||||||
this._castleStructure = null;
|
this._castleStructure = null;
|
||||||
|
this._finishLine = null;
|
||||||
|
|
||||||
// Advanced level elements
|
// Advanced level elements
|
||||||
this._piranhaPlants = [];
|
this._piranhaPlants = [];
|
||||||
@ -115,6 +116,9 @@ class MarioEducational extends Module {
|
|||||||
// UI elements (need to be declared before seal)
|
// UI elements (need to be declared before seal)
|
||||||
this._uiOverlay = null;
|
this._uiOverlay = null;
|
||||||
|
|
||||||
|
// Debug mode (toggle with 'D' key)
|
||||||
|
this._debugMode = false;
|
||||||
|
|
||||||
Object.seal(this);
|
Object.seal(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,63 +144,60 @@ class MarioEducational extends Module {
|
|||||||
*/
|
*/
|
||||||
static getCompatibilityScore(content) {
|
static getCompatibilityScore(content) {
|
||||||
const sentences = content?.sentences || [];
|
const sentences = content?.sentences || [];
|
||||||
const vocab = content?.vocabulary || {};
|
const phrases = content?.phrases || {};
|
||||||
|
const dialogs = content?.dialogs || {};
|
||||||
const texts = content?.texts || [];
|
const texts = content?.texts || [];
|
||||||
const story = content?.story || '';
|
const story = content?.story || '';
|
||||||
|
|
||||||
const vocabCount = Object.keys(vocab).length;
|
let totalSentences = sentences.length;
|
||||||
const sentenceCount = sentences.length;
|
|
||||||
|
|
||||||
// Count sentences from texts and story
|
// Count phrases (SBS format)
|
||||||
let extraSentenceCount = 0;
|
totalSentences += Object.keys(phrases).length;
|
||||||
|
|
||||||
if (story && typeof story === 'string') {
|
// Count dialog lines (SBS format)
|
||||||
extraSentenceCount += story.split(/[.!?]+/).filter(s => s.trim().length > 0).length;
|
Object.values(dialogs).forEach(dialog => {
|
||||||
}
|
if (dialog.lines && Array.isArray(dialog.lines)) {
|
||||||
|
totalSentences += dialog.lines.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count sentences from texts
|
||||||
if (Array.isArray(texts)) {
|
if (Array.isArray(texts)) {
|
||||||
texts.forEach(text => {
|
texts.forEach(text => {
|
||||||
if (typeof text === 'string') {
|
if (typeof text === 'string') {
|
||||||
extraSentenceCount += text.split(/[.!?]+/).filter(s => s.trim().length > 0).length;
|
totalSentences += text.split(/[.!?]+/).filter(s => s.trim().length > 10).length;
|
||||||
} else if (text.content) {
|
} else if (text.content) {
|
||||||
extraSentenceCount += text.content.split(/[.!?]+/).filter(s => s.trim().length > 0).length;
|
totalSentences += text.content.split(/[.!?]+/).filter(s => s.trim().length > 10).length;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalSentences = sentenceCount + extraSentenceCount;
|
// Count sentences from story
|
||||||
|
if (story && typeof story === 'string') {
|
||||||
|
totalSentences += story.split(/[.!?]+/).filter(s => s.trim().length > 10).length;
|
||||||
|
}
|
||||||
|
|
||||||
if (totalSentences < 3 && vocabCount < 10) {
|
// Mario Educational requires at least 5 sentences
|
||||||
|
if (totalSentences < 5) {
|
||||||
return {
|
return {
|
||||||
score: 0,
|
score: 0,
|
||||||
reason: `Insufficient content (${totalSentences} sentences, ${vocabCount} vocabulary words)`,
|
reason: `Insufficient sentences (${totalSentences}/5 minimum)`,
|
||||||
requirements: ['sentences', 'vocabulary', 'texts', 'story'],
|
requirements: ['sentences', 'phrases', 'dialogs', 'texts', 'story'],
|
||||||
minSentences: 3,
|
minSentences: 5,
|
||||||
minVocabulary: 10,
|
details: 'Mario Educational needs at least 5 complete sentences from any source'
|
||||||
details: 'Mario Educational needs at least 3 sentences OR 10+ vocabulary words'
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vocabCount < 5) {
|
// Good score for 10+ sentences, perfect at 30+
|
||||||
return {
|
const score = Math.min(totalSentences / 30, 1);
|
||||||
score: 0.3,
|
|
||||||
reason: `Limited vocabulary (${vocabCount}/5 minimum)`,
|
|
||||||
requirements: ['sentences', 'vocabulary'],
|
|
||||||
minWords: 5,
|
|
||||||
details: 'Game can work with sentences but vocabulary enhances learning'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perfect score at 30+ sentences, good score for 10+
|
|
||||||
const score = Math.min((totalSentences + vocabCount) / 50, 1);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
score,
|
score,
|
||||||
reason: `${totalSentences} sentences and ${vocabCount} vocabulary words available`,
|
reason: `${totalSentences} sentences available`,
|
||||||
requirements: ['sentences', 'vocabulary', 'texts', 'story'],
|
requirements: ['sentences', 'phrases', 'dialogs', 'texts', 'story'],
|
||||||
minSentences: 3,
|
minSentences: 5,
|
||||||
optimalSentences: 30,
|
optimalSentences: 30,
|
||||||
details: `Can create ${Math.min(totalSentences + vocabCount, 50)} question blocks from all content sources`
|
details: `Can create ${Math.min(totalSentences, 50)} question blocks from sentence sources`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,14 +280,15 @@ class MarioEducational extends Module {
|
|||||||
// Private methods
|
// Private methods
|
||||||
_extractSentences() {
|
_extractSentences() {
|
||||||
const sentences = this._content.sentences || [];
|
const sentences = this._content.sentences || [];
|
||||||
const vocab = this._content.vocabulary || {};
|
const phrases = this._content.phrases || {};
|
||||||
|
const dialogs = this._content.dialogs || {};
|
||||||
const texts = this._content.texts || [];
|
const texts = this._content.texts || [];
|
||||||
const story = this._content.story || '';
|
const story = this._content.story || '';
|
||||||
|
|
||||||
// Combine sentences and vocabulary for questions
|
// Combine all sentence sources
|
||||||
this._sentences = [];
|
this._sentences = [];
|
||||||
|
|
||||||
// Add actual sentences - handle both formats
|
// Add sentences from 'sentences' array
|
||||||
sentences.forEach(sentence => {
|
sentences.forEach(sentence => {
|
||||||
// Format 1: Modern format with english/user_language
|
// Format 1: Modern format with english/user_language
|
||||||
if (sentence.english && sentence.user_language) {
|
if (sentence.english && sentence.user_language) {
|
||||||
@ -308,6 +310,34 @@ class MarioEducational extends Module {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add phrases from 'phrases' object (SBS format)
|
||||||
|
Object.entries(phrases).forEach(([english, data]) => {
|
||||||
|
if (data.user_language) {
|
||||||
|
this._sentences.push({
|
||||||
|
type: 'phrase',
|
||||||
|
english: english,
|
||||||
|
translation: data.user_language,
|
||||||
|
context: data.context || 'phrase'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add dialog lines from 'dialogs' object (SBS format)
|
||||||
|
Object.values(dialogs).forEach(dialog => {
|
||||||
|
if (dialog.lines && Array.isArray(dialog.lines)) {
|
||||||
|
dialog.lines.forEach(line => {
|
||||||
|
if (line.text && line.user_language) {
|
||||||
|
this._sentences.push({
|
||||||
|
type: 'dialog',
|
||||||
|
english: line.text,
|
||||||
|
translation: line.user_language,
|
||||||
|
context: dialog.title || 'dialog'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Extract sentences from story text
|
// Extract sentences from story text
|
||||||
if (story && typeof story === 'string') {
|
if (story && typeof story === 'string') {
|
||||||
const storySentences = sentenceGenerator.splitTextIntoSentences(story);
|
const storySentences = sentenceGenerator.splitTextIntoSentences(story);
|
||||||
@ -348,25 +378,10 @@ class MarioEducational extends Module {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add vocabulary as contextual sentences
|
|
||||||
Object.entries(vocab).forEach(([word, data]) => {
|
|
||||||
if (data.user_language) {
|
|
||||||
const generatedSentence = sentenceGenerator.generateSentence(word, data);
|
|
||||||
this._sentences.push({
|
|
||||||
type: 'vocabulary',
|
|
||||||
english: generatedSentence.english,
|
|
||||||
translation: generatedSentence.translation,
|
|
||||||
context: data.type || 'vocabulary',
|
|
||||||
difficulty: generatedSentence.difficulty,
|
|
||||||
wordType: generatedSentence.wordType
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Shuffle sentences for variety
|
// Shuffle sentences for variety
|
||||||
this._sentences = this._shuffleArray(this._sentences);
|
this._sentences = this._shuffleArray(this._sentences);
|
||||||
|
|
||||||
console.log(`📝 Extracted ${this._sentences.length} sentences/vocabulary for questions`);
|
console.log(`📝 Extracted ${this._sentences.length} sentences for questions`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -388,6 +403,12 @@ class MarioEducational extends Module {
|
|||||||
this._handleKeyDown = (e) => {
|
this._handleKeyDown = (e) => {
|
||||||
this._keys[e.code] = true;
|
this._keys[e.code] = true;
|
||||||
|
|
||||||
|
// Toggle debug mode with 'D' key
|
||||||
|
if (e.code === 'KeyD') {
|
||||||
|
this._debugMode = !this._debugMode;
|
||||||
|
console.log(`🐛 Debug mode: ${this._debugMode ? 'ON' : 'OFF'}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Prevent default for game controls
|
// Prevent default for game controls
|
||||||
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'Space'].includes(e.code)) {
|
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'Space'].includes(e.code)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -456,6 +477,17 @@ class MarioEducational extends Module {
|
|||||||
// Generate floating platforms with intelligent placement
|
// Generate floating platforms with intelligent placement
|
||||||
this._generateIntelligentPlatforms(level, difficulty);
|
this._generateIntelligentPlatforms(level, difficulty);
|
||||||
|
|
||||||
|
// Level 3+ gets advanced features BEFORE enemies (so enemy spawning can avoid them)
|
||||||
|
if (index >= 2 && index <= 4) {
|
||||||
|
this._generateHoles(level, difficulty);
|
||||||
|
this._generateStairs(level, difficulty);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level 6 boss level: only stairs (no holes)
|
||||||
|
if (index === 5) {
|
||||||
|
this._generateBossStairs(level, difficulty);
|
||||||
|
}
|
||||||
|
|
||||||
// Generate question blocks on reachable platforms
|
// Generate question blocks on reachable platforms
|
||||||
const questionCount = 3 + difficulty;
|
const questionCount = 3 + difficulty;
|
||||||
const availablePlatforms = level.platforms.filter(p => p.y < this._config.canvasHeight - 100);
|
const availablePlatforms = level.platforms.filter(p => p.y < this._config.canvasHeight - 100);
|
||||||
@ -533,10 +565,10 @@ class MarioEducational extends Module {
|
|||||||
return enemyX + 10 >= hole.x && enemyX + 10 <= hole.x + hole.width; // Check center of enemy
|
return enemyX + 10 >= hole.x && enemyX + 10 <= hole.x + hole.width; // Check center of enemy
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if enemy has solid ground/platform/stair support below
|
// Enemy is spawned on a platform, so it has support by definition
|
||||||
const hasSolidSupport = this._hasSolidSupportBelow(enemyX, enemyY + 20, level); // Check below enemy position
|
// No need to check hasSolidSupport - the platform we selected IS the support
|
||||||
|
|
||||||
if (!wouldOverlapWall && !wouldOverlapHole && hasSolidSupport) {
|
if (!wouldOverlapWall && !wouldOverlapHole) {
|
||||||
// Level 2+ gets exactly ONE helmet enemy per level
|
// Level 2+ gets exactly ONE helmet enemy per level
|
||||||
const isHelmetEnemy = index >= 1 && !helmetEnemyPlaced && i === 0; // Only first enemy can be helmet
|
const isHelmetEnemy = index >= 1 && !helmetEnemyPlaced && i === 0; // Only first enemy can be helmet
|
||||||
|
|
||||||
@ -555,8 +587,10 @@ class MarioEducational extends Module {
|
|||||||
hasHelmet: isHelmetEnemy
|
hasHelmet: isHelmetEnemy
|
||||||
});
|
});
|
||||||
enemyPlaced = true;
|
enemyPlaced = true;
|
||||||
|
console.log(`✅ Enemy ${i} placed at x=${enemyX.toFixed(0)}, y=${enemyY.toFixed(0)} on platform`);
|
||||||
} else {
|
} else {
|
||||||
console.log(`🚫 Enemy ${i} attempt ${attempts} would overlap wall/hole/unsupported ground, retrying...`);
|
const reason = wouldOverlapWall ? 'wall' : 'hole';
|
||||||
|
console.log(`🚫 Enemy ${i} attempt ${attempts} failed: ${reason}`);
|
||||||
}
|
}
|
||||||
attempts++;
|
attempts++;
|
||||||
}
|
}
|
||||||
@ -566,17 +600,13 @@ class MarioEducational extends Module {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Level 3+ gets advanced features (except level 6 boss level)
|
// Generate piranha plants AFTER enemies (visual decoration)
|
||||||
if (index >= 2 && index <= 4) {
|
if (index >= 2 && index <= 4) {
|
||||||
this._generateHoles(level, difficulty);
|
|
||||||
this._generateStairs(level, difficulty);
|
|
||||||
this._generatePiranhaPlants(level, difficulty);
|
this._generatePiranhaPlants(level, difficulty);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Level 6 boss level: only stairs (no holes, limited piranha plants)
|
// Level 6 boss level: limited piranha plants
|
||||||
if (index === 5) {
|
if (index === 5) {
|
||||||
this._generateBossStairs(level, difficulty);
|
|
||||||
// Reduced piranha plants for boss level
|
|
||||||
if (difficulty > 3) {
|
if (difficulty > 3) {
|
||||||
this._generatePiranhaPlants(level, Math.min(difficulty - 2, 2));
|
this._generatePiranhaPlants(level, Math.min(difficulty - 2, 2));
|
||||||
}
|
}
|
||||||
@ -592,10 +622,10 @@ class MarioEducational extends Module {
|
|||||||
this._generateFlyingEyes(level, difficulty);
|
this._generateFlyingEyes(level, difficulty);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Level 6 gets colossal boss
|
// Level 6 gets colossal boss (DISABLED)
|
||||||
if (index === 5) {
|
// if (index === 5) {
|
||||||
this._generateColossalBoss(level, difficulty);
|
// this._generateColossalBoss(level, difficulty);
|
||||||
}
|
// }
|
||||||
|
|
||||||
return level;
|
return level;
|
||||||
}
|
}
|
||||||
@ -1527,6 +1557,13 @@ class MarioEducational extends Module {
|
|||||||
this._walls = [...(level.walls || [])];
|
this._walls = [...(level.walls || [])];
|
||||||
this._castleStructure = level.castleStructure || null;
|
this._castleStructure = level.castleStructure || null;
|
||||||
|
|
||||||
|
// Create finish line at level end
|
||||||
|
this._finishLine = {
|
||||||
|
x: level.endX,
|
||||||
|
y: this._config.canvasHeight - 150,
|
||||||
|
height: 150
|
||||||
|
};
|
||||||
|
|
||||||
// Advanced level elements
|
// Advanced level elements
|
||||||
this._piranhaPlants = [...(level.piranhaPlants || [])];
|
this._piranhaPlants = [...(level.piranhaPlants || [])];
|
||||||
this._projectiles = []; // Reset projectiles each level
|
this._projectiles = []; // Reset projectiles each level
|
||||||
@ -1649,7 +1686,8 @@ class MarioEducational extends Module {
|
|||||||
walls: this._walls,
|
walls: this._walls,
|
||||||
catapults: this._catapults,
|
catapults: this._catapults,
|
||||||
piranhaPlants: this._piranhaPlants,
|
piranhaPlants: this._piranhaPlants,
|
||||||
boulders: this._boulders
|
boulders: this._boulders,
|
||||||
|
flyingEyes: this._flyingEyes
|
||||||
};
|
};
|
||||||
|
|
||||||
const callbacks = {
|
const callbacks = {
|
||||||
@ -1749,7 +1787,7 @@ class MarioEducational extends Module {
|
|||||||
this._playTTSAndAutoClose(sentence.english, overlay, progressFill);
|
this._playTTSAndAutoClose(sentence.english, overlay, progressFill);
|
||||||
}
|
}
|
||||||
|
|
||||||
_playTTSAndAutoClose(text, overlay, progressBar) {
|
async _playTTSAndAutoClose(text, overlay, progressBar) {
|
||||||
// Calculate duration based on text length (words per minute estimation)
|
// Calculate duration based on text length (words per minute estimation)
|
||||||
const words = text.split(' ').length;
|
const words = text.split(' ').length;
|
||||||
const wordsPerMinute = 150; // Average speaking speed
|
const wordsPerMinute = 150; // Average speaking speed
|
||||||
@ -1765,15 +1803,23 @@ class MarioEducational extends Module {
|
|||||||
utterance.pitch = 1.0;
|
utterance.pitch = 1.0;
|
||||||
utterance.volume = 0.8;
|
utterance.volume = 0.8;
|
||||||
|
|
||||||
// Try to use a nice English voice
|
// Get target language from content
|
||||||
const voices = speechSynthesis.getVoices();
|
const targetLanguage = this._content?.language || 'en-US';
|
||||||
const englishVoice = voices.find(voice =>
|
utterance.lang = targetLanguage;
|
||||||
voice.lang.startsWith('en') && (voice.name.includes('Female') || voice.name.includes('Google'))
|
|
||||||
) || voices.find(voice => voice.lang.startsWith('en'));
|
|
||||||
|
|
||||||
if (englishVoice) {
|
// Wait for voices to be loaded before selecting one
|
||||||
utterance.voice = englishVoice;
|
const voices = await this._getVoices();
|
||||||
console.log(`🎤 Using voice: ${englishVoice.name}`);
|
const langPrefix = targetLanguage.split('-')[0];
|
||||||
|
|
||||||
|
const matchingVoice = voices.find(voice =>
|
||||||
|
voice.lang.startsWith(langPrefix) && voice.default
|
||||||
|
) || voices.find(voice => voice.lang.startsWith(langPrefix));
|
||||||
|
|
||||||
|
if (matchingVoice) {
|
||||||
|
utterance.voice = matchingVoice;
|
||||||
|
console.log(`🎤 Using voice: ${matchingVoice.name} (${matchingVoice.lang})`);
|
||||||
|
} else {
|
||||||
|
console.warn(`🔊 No voice found for language: ${targetLanguage}, available:`, voices.map(v => v.lang));
|
||||||
}
|
}
|
||||||
|
|
||||||
speechSynthesis.speak(utterance);
|
speechSynthesis.speak(utterance);
|
||||||
@ -2252,6 +2298,44 @@ class MarioEducational extends Module {
|
|||||||
console.log(`💀 Mario died! Score penalty: -${penalty}. New score: ${this._score}`);
|
console.log(`💀 Mario died! Score penalty: -${penalty}. New score: ${this._score}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_shuffleArray(array) {
|
||||||
|
const shuffled = [...array];
|
||||||
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||||
|
}
|
||||||
|
return shuffled;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getRandomSentence() {
|
||||||
|
if (this._sentences.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a sentence that hasn't been used yet, or recycle if all used
|
||||||
|
const availableSentences = this._sentences.filter(
|
||||||
|
s => !this._usedSentences.includes(s)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (availableSentences.length === 0) {
|
||||||
|
// All sentences used, reset the used list
|
||||||
|
this._usedSentences = [];
|
||||||
|
return this._sentences[Math.floor(Math.random() * this._sentences.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomSentence = availableSentences[Math.floor(Math.random() * availableSentences.length)];
|
||||||
|
this._usedSentences.push(randomSentence);
|
||||||
|
return randomSentence;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateFlyingEyes() {
|
||||||
|
FlyingEye.update(this._flyingEyes, this._mario, () => this._restartLevel());
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateBoss() {
|
||||||
|
Boss.update(this._boss, this._bossTurrets, this._bossMinions, this._mario, this._projectiles, (sound) => soundSystem.play(sound));
|
||||||
|
}
|
||||||
|
|
||||||
_render() {
|
_render() {
|
||||||
// Build game state object for renderer
|
// Build game state object for renderer
|
||||||
const gameState = {
|
const gameState = {
|
||||||
@ -2268,19 +2352,53 @@ class MarioEducational extends Module {
|
|||||||
stones: this._stones,
|
stones: this._stones,
|
||||||
flyingEyes: this._flyingEyes,
|
flyingEyes: this._flyingEyes,
|
||||||
boss: this._boss,
|
boss: this._boss,
|
||||||
castle: this._castle,
|
castleStructure: this._castleStructure,
|
||||||
finishLine: this._finishLine,
|
finishLine: this._finishLine,
|
||||||
particles: this._particles,
|
particles: this._particles,
|
||||||
currentLevel: this._currentLevel,
|
currentLevel: this._currentLevel,
|
||||||
lives: this._lives,
|
lives: this._lives,
|
||||||
score: this._score,
|
score: this._score,
|
||||||
debugMode: false // Can be toggled
|
debugMode: this._debugMode // Toggle with 'D' key during gameplay
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delegate rendering to helper
|
// Delegate rendering to helper
|
||||||
renderer.render(this._ctx, gameState, this._config);
|
renderer.render(this._ctx, gameState, this._config);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available speech synthesis voices, waiting for them to load if necessary
|
||||||
|
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getVoices() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let voices = window.speechSynthesis.getVoices();
|
||||||
|
|
||||||
|
// If voices are already loaded, return them immediately
|
||||||
|
if (voices.length > 0) {
|
||||||
|
resolve(voices);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, wait for voiceschanged event
|
||||||
|
const voicesChangedHandler = () => {
|
||||||
|
voices = window.speechSynthesis.getVoices();
|
||||||
|
if (voices.length > 0) {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(voices);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
|
||||||
|
// Fallback timeout in case voices never load
|
||||||
|
setTimeout(() => {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(window.speechSynthesis.getVoices());
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MarioEducational;
|
export default MarioEducational;
|
||||||
@ -713,7 +713,7 @@ class QuizGame extends Module {
|
|||||||
data-pronunciation="${pronunciation}"
|
data-pronunciation="${pronunciation}"
|
||||||
title="Click to hear pronunciation">
|
title="Click to hear pronunciation">
|
||||||
${optionText}
|
${optionText}
|
||||||
${pronunciation ? `<div class="option-pronunciation">[${pronunciation}]</div>` : ''}
|
${pronunciation ? `<div class="option-pronunciation" style="display: none;">[${pronunciation}]</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@ -784,16 +784,6 @@ class QuizGame extends Module {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Play TTS when clicking on an option
|
|
||||||
const word = optionElement.dataset.word;
|
|
||||||
const pronunciation = optionElement.dataset.pronunciation;
|
|
||||||
if (word) {
|
|
||||||
this._playAudio(word);
|
|
||||||
if (pronunciation) {
|
|
||||||
this._highlightPronunciation(optionElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this._isAnswering = true;
|
this._isAnswering = true;
|
||||||
const question = this._questions[this._currentQuestion];
|
const question = this._questions[this._currentQuestion];
|
||||||
const selectedAnswer = optionElement.dataset.value;
|
const selectedAnswer = optionElement.dataset.value;
|
||||||
@ -816,6 +806,40 @@ class QuizGame extends Module {
|
|||||||
this._score += 100 + timeBonus;
|
this._score += 100 + timeBonus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Play TTS and show pronunciation
|
||||||
|
if (isCorrect) {
|
||||||
|
// For correct answer, play the clicked option
|
||||||
|
const word = optionElement.dataset.word;
|
||||||
|
const pronunciation = optionElement.dataset.pronunciation;
|
||||||
|
if (word) {
|
||||||
|
this._playAudio(word);
|
||||||
|
if (pronunciation) {
|
||||||
|
const pronunciationElement = optionElement.querySelector('.option-pronunciation');
|
||||||
|
if (pronunciationElement) {
|
||||||
|
pronunciationElement.style.display = 'block';
|
||||||
|
this._highlightPronunciation(optionElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For incorrect answer, find and play the correct option's TTS
|
||||||
|
const correctOption = this._findCorrectOption(question.correctAnswer);
|
||||||
|
if (correctOption) {
|
||||||
|
const word = correctOption.dataset.word;
|
||||||
|
const pronunciation = correctOption.dataset.pronunciation;
|
||||||
|
if (word) {
|
||||||
|
this._playAudio(word);
|
||||||
|
if (pronunciation) {
|
||||||
|
const pronunciationElement = correctOption.querySelector('.option-pronunciation');
|
||||||
|
if (pronunciationElement) {
|
||||||
|
pronunciationElement.style.display = 'block';
|
||||||
|
this._highlightPronunciation(correctOption);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show feedback
|
// Show feedback
|
||||||
this._showAnswerFeedback(isCorrect, question);
|
this._showAnswerFeedback(isCorrect, question);
|
||||||
this._updateStats();
|
this._updateStats();
|
||||||
@ -831,6 +855,19 @@ class QuizGame extends Module {
|
|||||||
}, this.name);
|
}, this.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_findCorrectOption(correctAnswer) {
|
||||||
|
const optionsContainer = document.getElementById('quiz-options');
|
||||||
|
if (!optionsContainer) return null;
|
||||||
|
|
||||||
|
const options = optionsContainer.querySelectorAll('.quiz-option');
|
||||||
|
for (const option of options) {
|
||||||
|
if (option.dataset.value === correctAnswer) {
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
_handleTimeout() {
|
_handleTimeout() {
|
||||||
if (this._timer) {
|
if (this._timer) {
|
||||||
clearInterval(this._timer);
|
clearInterval(this._timer);
|
||||||
@ -991,7 +1028,7 @@ class QuizGame extends Module {
|
|||||||
this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name);
|
this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
_playAudio(text) {
|
async _playAudio(text) {
|
||||||
if ('speechSynthesis' in window) {
|
if ('speechSynthesis' in window) {
|
||||||
// Cancel any ongoing speech
|
// Cancel any ongoing speech
|
||||||
speechSynthesis.cancel();
|
speechSynthesis.cancel();
|
||||||
@ -1006,7 +1043,7 @@ class QuizGame extends Module {
|
|||||||
utterance.volume = 1.0;
|
utterance.volume = 1.0;
|
||||||
|
|
||||||
// Try to find a suitable voice for the language
|
// Try to find a suitable voice for the language
|
||||||
const voices = speechSynthesis.getVoices();
|
const voices = await this._getVoices();
|
||||||
if (voices.length > 0) {
|
if (voices.length > 0) {
|
||||||
// Find voice matching the chapter language
|
// Find voice matching the chapter language
|
||||||
const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
|
const langPrefix = chapterLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
|
||||||
@ -1017,6 +1054,8 @@ class QuizGame extends Module {
|
|||||||
if (matchingVoice) {
|
if (matchingVoice) {
|
||||||
utterance.voice = matchingVoice;
|
utterance.voice = matchingVoice;
|
||||||
console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang);
|
console.log('🔊 Using voice:', matchingVoice.name, matchingVoice.lang);
|
||||||
|
} else {
|
||||||
|
console.warn(`🔊 No voice found for: ${chapterLanguage}, available:`, voices.map(v => v.lang));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1024,6 +1063,40 @@ class QuizGame extends Module {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available speech synthesis voices, waiting for them to load if necessary
|
||||||
|
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getVoices() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let voices = window.speechSynthesis.getVoices();
|
||||||
|
|
||||||
|
// If voices are already loaded, return them immediately
|
||||||
|
if (voices.length > 0) {
|
||||||
|
resolve(voices);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, wait for voiceschanged event
|
||||||
|
const voicesChangedHandler = () => {
|
||||||
|
voices = window.speechSynthesis.getVoices();
|
||||||
|
if (voices.length > 0) {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(voices);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
|
||||||
|
// Fallback timeout in case voices never load
|
||||||
|
setTimeout(() => {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(window.speechSynthesis.getVoices());
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
_highlightPronunciation(optionElement) {
|
_highlightPronunciation(optionElement) {
|
||||||
const pronunciation = optionElement.querySelector('.option-pronunciation');
|
const pronunciation = optionElement.querySelector('.option-pronunciation');
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import Module from '../core/Module.js';
|
import Module from '../core/Module.js';
|
||||||
|
import { soundSystem } from '../gameHelpers/MarioEducational/SoundSystem.js';
|
||||||
|
|
||||||
class RiverRun extends Module {
|
class RiverRun extends Module {
|
||||||
constructor(name, dependencies, config = {}) {
|
constructor(name, dependencies, config = {}) {
|
||||||
@ -55,6 +56,12 @@ class RiverRun extends Module {
|
|||||||
this._gameContainer = null;
|
this._gameContainer = null;
|
||||||
this._animationFrame = null;
|
this._animationFrame = null;
|
||||||
|
|
||||||
|
// Background music
|
||||||
|
this._audioContext = null;
|
||||||
|
this._backgroundMusicNodes = [];
|
||||||
|
this._isMusicPlaying = false;
|
||||||
|
this._musicLoopTimeout = null;
|
||||||
|
|
||||||
Object.seal(this);
|
Object.seal(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,6 +115,8 @@ class RiverRun extends Module {
|
|||||||
this._eventBus.on('game:stop', this._handleGameStop.bind(this), this.name);
|
this._eventBus.on('game:stop', this._handleGameStop.bind(this), this.name);
|
||||||
this._eventBus.on('navigation:change', this._handleNavigationChange.bind(this), this.name);
|
this._eventBus.on('navigation:change', this._handleNavigationChange.bind(this), this.name);
|
||||||
|
|
||||||
|
soundSystem.initialize();
|
||||||
|
|
||||||
this._injectCSS();
|
this._injectCSS();
|
||||||
|
|
||||||
// Start game immediately
|
// Start game immediately
|
||||||
@ -219,6 +228,8 @@ class RiverRun extends Module {
|
|||||||
this._animationFrame = null;
|
this._animationFrame = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._stopBackgroundMusic();
|
||||||
|
|
||||||
if (this._gameContainer) {
|
if (this._gameContainer) {
|
||||||
this._gameContainer.innerHTML = '';
|
this._gameContainer.innerHTML = '';
|
||||||
}
|
}
|
||||||
@ -332,6 +343,7 @@ class RiverRun extends Module {
|
|||||||
this._isRunning = true;
|
this._isRunning = true;
|
||||||
this._gameStartTime = Date.now();
|
this._gameStartTime = Date.now();
|
||||||
this._setNextTarget();
|
this._setNextTarget();
|
||||||
|
this._startBackgroundMusic();
|
||||||
|
|
||||||
this._gameLoop();
|
this._gameLoop();
|
||||||
console.log('River Run started!');
|
console.log('River Run started!');
|
||||||
@ -343,7 +355,29 @@ class RiverRun extends Module {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (now - this._lastSpawn > this._config.spawnInterval) {
|
if (now - this._lastSpawn > this._config.spawnInterval) {
|
||||||
this._spawnFloatingWord();
|
// Spawn multiple words based on speed: sqrt(speed) words per spawn cycle
|
||||||
|
// The decimal part is treated as probability for an additional word
|
||||||
|
const speedSqrt = Math.sqrt(this._speed);
|
||||||
|
const baseWords = Math.floor(speedSqrt);
|
||||||
|
const probability = speedSqrt - baseWords; // Decimal part (e.g., 2.7 -> 0.7)
|
||||||
|
|
||||||
|
// Always spawn at least the base number of words
|
||||||
|
let wordsToSpawn = Math.max(1, baseWords);
|
||||||
|
|
||||||
|
// Add one more word based on probability
|
||||||
|
if (Math.random() < probability) {
|
||||||
|
wordsToSpawn++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < wordsToSpawn; i++) {
|
||||||
|
// Add slight delay between each word spawn for visual variety
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this._isRunning) {
|
||||||
|
this._spawnFloatingWord();
|
||||||
|
}
|
||||||
|
}, i * 100); // 100ms delay between each word
|
||||||
|
}
|
||||||
|
|
||||||
this._lastSpawn = now;
|
this._lastSpawn = now;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -392,16 +426,37 @@ class RiverRun extends Module {
|
|||||||
const spacePadding = ' '.repeat(this._level * 2);
|
const spacePadding = ' '.repeat(this._level * 2);
|
||||||
wordElement.textContent = spacePadding + word.french + spacePadding;
|
wordElement.textContent = spacePadding + word.french + spacePadding;
|
||||||
|
|
||||||
wordElement.style.left = `${Math.random() * 80 + 10}%`;
|
// More random positioning with different strategies
|
||||||
wordElement.style.top = '-60px';
|
let xPosition;
|
||||||
|
const strategy = Math.random();
|
||||||
|
|
||||||
|
if (strategy < 0.4) {
|
||||||
|
// Random across full width (with margins)
|
||||||
|
xPosition = Math.random() * 80 + 10;
|
||||||
|
} else if (strategy < 0.6) {
|
||||||
|
// Prefer left side
|
||||||
|
xPosition = Math.random() * 40 + 10;
|
||||||
|
} else if (strategy < 0.8) {
|
||||||
|
// Prefer right side
|
||||||
|
xPosition = Math.random() * 40 + 50;
|
||||||
|
} else {
|
||||||
|
// Prefer center
|
||||||
|
xPosition = Math.random() * 30 + 35;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add slight random variation to starting Y position for staggered effect
|
||||||
|
const yStart = -60 - Math.random() * 40;
|
||||||
|
|
||||||
|
wordElement.style.left = `${xPosition}%`;
|
||||||
|
wordElement.style.top = `${yStart}px`;
|
||||||
|
|
||||||
wordElement.wordData = word;
|
wordElement.wordData = word;
|
||||||
|
|
||||||
riverCanvas.appendChild(wordElement);
|
riverCanvas.appendChild(wordElement);
|
||||||
this._floatingWords.push({
|
this._floatingWords.push({
|
||||||
element: wordElement,
|
element: wordElement,
|
||||||
y: -60,
|
y: yStart,
|
||||||
x: parseFloat(wordElement.style.left),
|
x: xPosition,
|
||||||
wordData: word
|
wordData: word
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -421,27 +476,35 @@ class RiverRun extends Module {
|
|||||||
const powerUpElement = document.createElement('div');
|
const powerUpElement = document.createElement('div');
|
||||||
powerUpElement.className = 'power-up';
|
powerUpElement.className = 'power-up';
|
||||||
powerUpElement.innerHTML = '⚡';
|
powerUpElement.innerHTML = '⚡';
|
||||||
powerUpElement.style.left = `${Math.random() * 80 + 10}%`;
|
|
||||||
powerUpElement.style.top = '-40px';
|
// Random positioning similar to words
|
||||||
|
const xPosition = Math.random() * 80 + 10;
|
||||||
|
const yStart = -40 - Math.random() * 30;
|
||||||
|
|
||||||
|
powerUpElement.style.left = `${xPosition}%`;
|
||||||
|
powerUpElement.style.top = `${yStart}px`;
|
||||||
|
|
||||||
riverCanvas.appendChild(powerUpElement);
|
riverCanvas.appendChild(powerUpElement);
|
||||||
this._powerUps.push({
|
this._powerUps.push({
|
||||||
element: powerUpElement,
|
element: powerUpElement,
|
||||||
y: -40,
|
y: yStart,
|
||||||
x: parseFloat(powerUpElement.style.left),
|
x: xPosition,
|
||||||
type: 'slowTime'
|
type: 'slowTime'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateFloatingWords() {
|
_updateFloatingWords() {
|
||||||
|
const riverCanvas = document.getElementById('river-canvas');
|
||||||
|
const canvasHeight = riverCanvas ? riverCanvas.offsetHeight : window.innerHeight;
|
||||||
|
|
||||||
this._floatingWords = this._floatingWords.filter(word => {
|
this._floatingWords = this._floatingWords.filter(word => {
|
||||||
word.y += this._speed;
|
word.y += this._speed;
|
||||||
word.element.style.top = `${word.y}px`;
|
word.element.style.top = `${word.y}px`;
|
||||||
|
|
||||||
if (word.y > window.innerHeight + 60) {
|
// Check if word has gone below the visible game area
|
||||||
if (word.wordData.french === this._currentTarget.french) {
|
if (word.y > canvasHeight - 50) {
|
||||||
this._loseLife();
|
// Note: No longer losing life when target word escapes
|
||||||
}
|
// Life loss only happens on collision with wrong words
|
||||||
word.element.remove();
|
word.element.remove();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -453,7 +516,8 @@ class RiverRun extends Module {
|
|||||||
powerUp.y += this._speed;
|
powerUp.y += this._speed;
|
||||||
powerUp.element.style.top = `${powerUp.y}px`;
|
powerUp.element.style.top = `${powerUp.y}px`;
|
||||||
|
|
||||||
if (powerUp.y > window.innerHeight + 40) {
|
// Check if power-up has gone below the visible game area
|
||||||
|
if (powerUp.y > canvasHeight - 50) {
|
||||||
powerUp.element.remove();
|
powerUp.element.remove();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -532,45 +596,61 @@ class RiverRun extends Module {
|
|||||||
_checkCollisions() {
|
_checkCollisions() {
|
||||||
const playerRect = this._getPlayerRect();
|
const playerRect = this._getPlayerRect();
|
||||||
|
|
||||||
this._floatingWords.forEach((word, index) => {
|
// Check word collisions (iterate backwards to safely splice)
|
||||||
|
for (let i = this._floatingWords.length - 1; i >= 0; i--) {
|
||||||
|
const word = this._floatingWords[i];
|
||||||
|
|
||||||
|
// Skip if word was already collected
|
||||||
|
if (word.element.dataset.collected === 'true') continue;
|
||||||
|
|
||||||
const wordRect = this._getElementRect(word.element);
|
const wordRect = this._getElementRect(word.element);
|
||||||
|
|
||||||
if (this._isColliding(playerRect, wordRect)) {
|
if (this._isColliding(playerRect, wordRect)) {
|
||||||
this._handleWordCollision(word, index);
|
this._handleWordCollision(word, i);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
this._powerUps.forEach((powerUp, index) => {
|
// Check power-up collisions (iterate backwards to safely splice)
|
||||||
|
for (let i = this._powerUps.length - 1; i >= 0; i--) {
|
||||||
|
const powerUp = this._powerUps[i];
|
||||||
const powerUpRect = this._getElementRect(powerUp.element);
|
const powerUpRect = this._getElementRect(powerUp.element);
|
||||||
|
|
||||||
if (this._isColliding(playerRect, powerUpRect)) {
|
if (this._isColliding(playerRect, powerUpRect)) {
|
||||||
this._handlePowerUpCollision(powerUp, index);
|
this._handlePowerUpCollision(powerUp, i);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_getPlayerRect() {
|
_getPlayerRect() {
|
||||||
const playerElement = document.getElementById('player');
|
const playerElement = document.getElementById('player');
|
||||||
if (!playerElement) return { x: 0, y: 0, width: 0, height: 0 };
|
if (!playerElement) return { x: 0, y: 0, width: 0, height: 0 };
|
||||||
|
|
||||||
|
const canvas = document.getElementById('river-canvas');
|
||||||
|
if (!canvas) return { x: 0, y: 0, width: 0, height: 0 };
|
||||||
|
|
||||||
const rect = playerElement.getBoundingClientRect();
|
const rect = playerElement.getBoundingClientRect();
|
||||||
const canvas = document.getElementById('river-canvas').getBoundingClientRect();
|
const canvasRect = canvas.getBoundingClientRect();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: rect.left - canvas.left,
|
x: rect.left - canvasRect.left,
|
||||||
y: rect.top - canvas.top,
|
y: rect.top - canvasRect.top,
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
height: rect.height
|
height: rect.height
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
_getElementRect(element) {
|
_getElementRect(element) {
|
||||||
|
if (!element) return { x: 0, y: 0, width: 0, height: 0 };
|
||||||
|
|
||||||
|
const canvas = document.getElementById('river-canvas');
|
||||||
|
if (!canvas) return { x: 0, y: 0, width: 0, height: 0 };
|
||||||
|
|
||||||
const rect = element.getBoundingClientRect();
|
const rect = element.getBoundingClientRect();
|
||||||
const canvas = document.getElementById('river-canvas').getBoundingClientRect();
|
const canvasRect = canvas.getBoundingClientRect();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: rect.left - canvas.left,
|
x: rect.left - canvasRect.left,
|
||||||
y: rect.top - canvas.top,
|
y: rect.top - canvasRect.top,
|
||||||
width: rect.width,
|
width: rect.width,
|
||||||
height: rect.height
|
height: rect.height
|
||||||
};
|
};
|
||||||
@ -594,22 +674,43 @@ class RiverRun extends Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_handleWordCollision(word, index) {
|
_handleWordCollision(word, index) {
|
||||||
if (word.wordData.french === this._currentTarget.french) {
|
// Handle collision for ALL words:
|
||||||
this._collectWord(word.element, true);
|
// - TARGET word: auto-collect (points)
|
||||||
} else {
|
// - WRONG word: lose life
|
||||||
this._missWord(word.element);
|
if (word.wordData && this._currentTarget) {
|
||||||
}
|
if (word.wordData.french === this._currentTarget.french) {
|
||||||
|
// Correct word - collect it
|
||||||
|
this._collectWord(word.element, true);
|
||||||
|
} else {
|
||||||
|
// Wrong word - lose life
|
||||||
|
this._missWord(word.element);
|
||||||
|
}
|
||||||
|
|
||||||
this._floatingWords.splice(index, 1);
|
// Remove the word from the array to prevent multiple collisions
|
||||||
|
this._floatingWords.splice(index, 1);
|
||||||
|
|
||||||
|
// Also mark as collected to prevent further processing
|
||||||
|
word.element.dataset.collected = 'true';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_collectWord(wordElement, isCorrect) {
|
_collectWord(wordElement, isCorrect) {
|
||||||
wordElement.classList.add('collected');
|
wordElement.classList.add('collected');
|
||||||
|
|
||||||
if (isCorrect) {
|
if (isCorrect) {
|
||||||
this._score += 10 + (this._level * 2);
|
soundSystem.play('coin');
|
||||||
|
|
||||||
|
// Base points increased with level, multiplied by sqrt of speed
|
||||||
|
const basePoints = 10 + (this._level * 2);
|
||||||
|
const speedMultiplier = Math.sqrt(this._speed);
|
||||||
|
const pointsEarned = Math.round(basePoints * speedMultiplier);
|
||||||
|
|
||||||
|
this._score += pointsEarned;
|
||||||
this._wordsCollected++;
|
this._wordsCollected++;
|
||||||
|
|
||||||
|
// Show points earned (visual feedback)
|
||||||
|
this._showPointsPopup(wordElement, pointsEarned);
|
||||||
|
|
||||||
this._eventBus.emit('game:score-update', {
|
this._eventBus.emit('game:score-update', {
|
||||||
gameId: 'river-run',
|
gameId: 'river-run',
|
||||||
score: this._score,
|
score: this._score,
|
||||||
@ -626,6 +727,7 @@ class RiverRun extends Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_missWord(wordElement) {
|
_missWord(wordElement) {
|
||||||
|
soundSystem.play('enemy_defeat');
|
||||||
wordElement.classList.add('missed');
|
wordElement.classList.add('missed');
|
||||||
this._loseLife();
|
this._loseLife();
|
||||||
|
|
||||||
@ -653,24 +755,103 @@ class RiverRun extends Module {
|
|||||||
|
|
||||||
_updateDifficulty() {
|
_updateDifficulty() {
|
||||||
const timeElapsed = Date.now() - this._gameStartTime;
|
const timeElapsed = Date.now() - this._gameStartTime;
|
||||||
const newLevel = Math.floor(timeElapsed / 30000) + 1;
|
const secondsElapsed = timeElapsed / 1000;
|
||||||
|
|
||||||
|
// Progressive speed increase: starts at initialSpeed, increases gradually
|
||||||
|
// Speed increases by 0.1 every 5 seconds (0.02 per second)
|
||||||
|
const speedIncrease = secondsElapsed * 0.02;
|
||||||
|
this._speed = this._config.initialSpeed + speedIncrease;
|
||||||
|
|
||||||
|
// Update level every 30 seconds
|
||||||
|
const newLevel = Math.floor(timeElapsed / 30000) + 1;
|
||||||
if (newLevel > this._level) {
|
if (newLevel > this._level) {
|
||||||
this._level = newLevel;
|
this._level = newLevel;
|
||||||
this._speed += 0.5;
|
// Decrease spawn interval with each level (spawn words more frequently)
|
||||||
this._config.spawnInterval = Math.max(500, this._config.spawnInterval - 100);
|
this._config.spawnInterval = Math.max(500, 1000 - (this._level - 1) * 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_playSuccessSound(word) {
|
_showPointsPopup(wordElement, points) {
|
||||||
|
const rect = wordElement.getBoundingClientRect();
|
||||||
|
const riverCanvas = document.getElementById('river-canvas');
|
||||||
|
if (!riverCanvas) return;
|
||||||
|
|
||||||
|
const canvasRect = riverCanvas.getBoundingClientRect();
|
||||||
|
const popup = document.createElement('div');
|
||||||
|
popup.className = 'points-popup';
|
||||||
|
popup.textContent = `+${points}`;
|
||||||
|
popup.style.left = `${rect.left - canvasRect.left + rect.width / 2}px`;
|
||||||
|
popup.style.top = `${rect.top - canvasRect.top}px`;
|
||||||
|
|
||||||
|
riverCanvas.appendChild(popup);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
popup.remove();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _playSuccessSound(word) {
|
||||||
if ('speechSynthesis' in window) {
|
if ('speechSynthesis' in window) {
|
||||||
|
// Cancel any ongoing speech
|
||||||
|
speechSynthesis.cancel();
|
||||||
|
|
||||||
const utterance = new SpeechSynthesisUtterance(word.trim());
|
const utterance = new SpeechSynthesisUtterance(word.trim());
|
||||||
utterance.lang = 'fr-FR';
|
|
||||||
utterance.rate = 1.0;
|
// Get language from content, fallback to zh-CN (Chinese) for vocabulary
|
||||||
|
const contentLanguage = this._content?.language || 'zh-CN';
|
||||||
|
utterance.lang = contentLanguage;
|
||||||
|
utterance.rate = 0.8;
|
||||||
|
utterance.pitch = 1.0;
|
||||||
|
utterance.volume = 1.0;
|
||||||
|
|
||||||
|
// Wait for voices to be loaded and select the best one
|
||||||
|
const voices = await this._getVoices();
|
||||||
|
if (voices.length > 0) {
|
||||||
|
const langPrefix = contentLanguage.split('-')[0];
|
||||||
|
const matchingVoice = voices.find(voice =>
|
||||||
|
voice.lang === contentLanguage
|
||||||
|
) || voices.find(voice =>
|
||||||
|
voice.lang.startsWith(langPrefix)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingVoice) {
|
||||||
|
utterance.voice = matchingVoice;
|
||||||
|
console.log(`🔊 RiverRun using voice: ${matchingVoice.name} (${matchingVoice.lang})`);
|
||||||
|
} else {
|
||||||
|
console.warn(`🔊 No voice found for: ${contentLanguage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
speechSynthesis.speak(utterance);
|
speechSynthesis.speak(utterance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getVoices() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let voices = window.speechSynthesis.getVoices();
|
||||||
|
|
||||||
|
if (voices.length > 0) {
|
||||||
|
resolve(voices);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const voicesChangedHandler = () => {
|
||||||
|
voices = window.speechSynthesis.getVoices();
|
||||||
|
if (voices.length > 0) {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(voices);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(window.speechSynthesis.getVoices());
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
_loseLife() {
|
_loseLife() {
|
||||||
this._lives--;
|
this._lives--;
|
||||||
|
|
||||||
@ -681,6 +862,7 @@ class RiverRun extends Module {
|
|||||||
|
|
||||||
_gameOver() {
|
_gameOver() {
|
||||||
this._isRunning = false;
|
this._isRunning = false;
|
||||||
|
this._stopBackgroundMusic();
|
||||||
|
|
||||||
const accuracy = this._wordsCollected > 0 ? Math.round((this._wordsCollected / (this._wordsCollected + (3 - this._lives))) * 100) : 0;
|
const accuracy = this._wordsCollected > 0 ? Math.round((this._wordsCollected / (this._wordsCollected + (3 - this._lives))) * 100) : 0;
|
||||||
|
|
||||||
@ -734,6 +916,9 @@ class RiverRun extends Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_restart() {
|
_restart() {
|
||||||
|
// Stop any playing music before restarting
|
||||||
|
this._stopBackgroundMusic();
|
||||||
|
|
||||||
this._isRunning = false;
|
this._isRunning = false;
|
||||||
this._score = 0;
|
this._score = 0;
|
||||||
this._lives = this._config.initialLives;
|
this._lives = this._config.initialLives;
|
||||||
@ -783,6 +968,123 @@ class RiverRun extends Module {
|
|||||||
console.log('River Run restarted');
|
console.log('River Run restarted');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_startBackgroundMusic() {
|
||||||
|
if (this._isMusicPlaying) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create audio context
|
||||||
|
this._audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
|
||||||
|
// Create master gain for volume control (quiet background music)
|
||||||
|
const masterGain = this._audioContext.createGain();
|
||||||
|
masterGain.gain.value = 0.15; // Very quiet, 15% volume
|
||||||
|
masterGain.connect(this._audioContext.destination);
|
||||||
|
|
||||||
|
// River-like pentatonic scale (C D E G A) - peaceful and flowing
|
||||||
|
const frequencies = [261.63, 293.66, 329.63, 392.00, 440.00]; // C4, D4, E4, G4, A4
|
||||||
|
|
||||||
|
// Create multiple oscillators for a richer sound
|
||||||
|
const createNote = (freq, startTime, duration, gainValue) => {
|
||||||
|
const oscillator = this._audioContext.createOscillator();
|
||||||
|
const gainNode = this._audioContext.createGain();
|
||||||
|
|
||||||
|
oscillator.type = 'sine'; // Soft sine wave
|
||||||
|
oscillator.frequency.setValueAtTime(freq, this._audioContext.currentTime);
|
||||||
|
|
||||||
|
// Envelope: fade in and fade out
|
||||||
|
gainNode.gain.setValueAtTime(0, startTime);
|
||||||
|
gainNode.gain.linearRampToValueAtTime(gainValue, startTime + 0.1);
|
||||||
|
gainNode.gain.linearRampToValueAtTime(gainValue * 0.7, startTime + duration - 0.3);
|
||||||
|
gainNode.gain.linearRampToValueAtTime(0, startTime + duration);
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(masterGain);
|
||||||
|
|
||||||
|
oscillator.start(startTime);
|
||||||
|
oscillator.stop(startTime + duration);
|
||||||
|
|
||||||
|
return { oscillator, gainNode };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a flowing melodic pattern
|
||||||
|
const playMelody = () => {
|
||||||
|
if (!this._isMusicPlaying || !this._audioContext) return;
|
||||||
|
|
||||||
|
const now = this._audioContext.currentTime;
|
||||||
|
const noteDuration = 1.5; // Longer notes for a relaxed feel
|
||||||
|
|
||||||
|
// Play a sequence of notes with random variation (like water flowing)
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const randomIndex = Math.floor(Math.random() * frequencies.length);
|
||||||
|
const freq = frequencies[randomIndex];
|
||||||
|
const startTime = now + (i * noteDuration);
|
||||||
|
const gainValue = 0.3 + Math.random() * 0.2; // Vary volume slightly
|
||||||
|
|
||||||
|
createNote(freq, startTime, noteDuration * 1.2, gainValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule next melody and store timeout ID
|
||||||
|
this._musicLoopTimeout = setTimeout(() => playMelody(), noteDuration * 4 * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add subtle low drone (like distant river sound)
|
||||||
|
const bassDrone = this._audioContext.createOscillator();
|
||||||
|
const bassGain = this._audioContext.createGain();
|
||||||
|
bassDrone.type = 'sine';
|
||||||
|
bassDrone.frequency.value = 65.41; // C2 - very low
|
||||||
|
bassGain.gain.value = 0.08; // Very subtle
|
||||||
|
bassDrone.connect(bassGain);
|
||||||
|
bassGain.connect(masterGain);
|
||||||
|
bassDrone.start();
|
||||||
|
|
||||||
|
this._backgroundMusicNodes.push({ oscillator: bassDrone, gainNode: bassGain });
|
||||||
|
this._isMusicPlaying = true;
|
||||||
|
|
||||||
|
// Start the melody
|
||||||
|
playMelody();
|
||||||
|
|
||||||
|
console.log('🎵 River background music started');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to start background music:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_stopBackgroundMusic() {
|
||||||
|
if (!this._isMusicPlaying) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Clear the melody loop timeout
|
||||||
|
if (this._musicLoopTimeout) {
|
||||||
|
clearTimeout(this._musicLoopTimeout);
|
||||||
|
this._musicLoopTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop all oscillators
|
||||||
|
this._backgroundMusicNodes.forEach(node => {
|
||||||
|
if (node.oscillator) {
|
||||||
|
try {
|
||||||
|
node.oscillator.stop();
|
||||||
|
} catch (e) {
|
||||||
|
// Oscillator might already be stopped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close audio context
|
||||||
|
if (this._audioContext) {
|
||||||
|
this._audioContext.close();
|
||||||
|
this._audioContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._backgroundMusicNodes = [];
|
||||||
|
this._isMusicPlaying = false;
|
||||||
|
|
||||||
|
console.log('🎵 River background music stopped');
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to stop background music:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_shuffleArray(array) {
|
_shuffleArray(array) {
|
||||||
const shuffled = [...array];
|
const shuffled = [...array];
|
||||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
@ -1055,6 +1357,35 @@ class RiverRun extends Module {
|
|||||||
z-index: 30;
|
z-index: 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.points-popup {
|
||||||
|
position: absolute;
|
||||||
|
color: #FFD700;
|
||||||
|
font-size: 1.5em;
|
||||||
|
font-weight: bold;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 100;
|
||||||
|
text-shadow:
|
||||||
|
0 0 10px rgba(255,215,0,0.8),
|
||||||
|
0 2px 4px rgba(0,0,0,0.5);
|
||||||
|
animation: pointsFloat 1s ease-out forwards;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pointsFloat {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0) scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -30px) scale(1.2);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -60px) scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.game-error {
|
.game-error {
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
border: 2px solid #ef4444;
|
border: 2px solid #ef4444;
|
||||||
|
|||||||
@ -1140,32 +1140,71 @@ class WhackAMole extends Module {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_speakWord(word) {
|
async _speakWord(word) {
|
||||||
// Use Web Speech API to pronounce the word
|
// Use Web Speech API to pronounce the word
|
||||||
if ('speechSynthesis' in window) {
|
if ('speechSynthesis' in window) {
|
||||||
// Cancel any ongoing speech
|
// Cancel any ongoing speech
|
||||||
speechSynthesis.cancel();
|
speechSynthesis.cancel();
|
||||||
|
|
||||||
const utterance = new SpeechSynthesisUtterance(word);
|
const utterance = new SpeechSynthesisUtterance(word);
|
||||||
utterance.lang = 'en-US'; // English pronunciation
|
const targetLanguage = this._content?.language || 'en-US';
|
||||||
|
utterance.lang = targetLanguage;
|
||||||
utterance.rate = 0.9; // Slightly slower for clarity
|
utterance.rate = 0.9; // Slightly slower for clarity
|
||||||
utterance.pitch = 1.0;
|
utterance.pitch = 1.0;
|
||||||
utterance.volume = 1.0;
|
utterance.volume = 1.0;
|
||||||
|
|
||||||
// Try to use a good English voice
|
// Try to use a good voice for the target language
|
||||||
const voices = speechSynthesis.getVoices();
|
const voices = await this._getVoices();
|
||||||
const englishVoice = voices.find(voice =>
|
const langPrefix = targetLanguage.split('-')[0];
|
||||||
voice.lang.startsWith('en') && (voice.name.includes('Google') || voice.name.includes('Neural'))
|
const preferredVoice = voices.find(voice =>
|
||||||
) || voices.find(voice => voice.lang.startsWith('en'));
|
voice.lang.startsWith(langPrefix) && (voice.name.includes('Google') || voice.name.includes('Neural') || voice.default)
|
||||||
|
) || voices.find(voice => voice.lang.startsWith(langPrefix));
|
||||||
|
|
||||||
if (englishVoice) {
|
if (preferredVoice) {
|
||||||
utterance.voice = englishVoice;
|
utterance.voice = preferredVoice;
|
||||||
|
console.log(`🔊 Using voice: ${preferredVoice.name} (${preferredVoice.lang})`);
|
||||||
|
} else {
|
||||||
|
console.warn(`🔊 No voice found for: ${targetLanguage}, available:`, voices.map(v => v.lang));
|
||||||
}
|
}
|
||||||
|
|
||||||
speechSynthesis.speak(utterance);
|
speechSynthesis.speak(utterance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available speech synthesis voices, waiting for them to load if necessary
|
||||||
|
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getVoices() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let voices = window.speechSynthesis.getVoices();
|
||||||
|
|
||||||
|
// If voices are already loaded, return them immediately
|
||||||
|
if (voices.length > 0) {
|
||||||
|
resolve(voices);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, wait for voiceschanged event
|
||||||
|
const voicesChangedHandler = () => {
|
||||||
|
voices = window.speechSynthesis.getVoices();
|
||||||
|
if (voices.length > 0) {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(voices);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
|
||||||
|
// Fallback timeout in case voices never load
|
||||||
|
setTimeout(() => {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(window.speechSynthesis.getVoices());
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
_shuffleArray(array) {
|
_shuffleArray(array) {
|
||||||
const shuffled = [...array];
|
const shuffled = [...array];
|
||||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
|||||||
@ -1351,32 +1351,71 @@ class WhackAMoleHard extends Module {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
_speakWord(word) {
|
async _speakWord(word) {
|
||||||
// Use Web Speech API to pronounce the word
|
// Use Web Speech API to pronounce the word
|
||||||
if ('speechSynthesis' in window) {
|
if ('speechSynthesis' in window) {
|
||||||
// Cancel any ongoing speech
|
// Cancel any ongoing speech
|
||||||
speechSynthesis.cancel();
|
speechSynthesis.cancel();
|
||||||
|
|
||||||
const utterance = new SpeechSynthesisUtterance(word);
|
const utterance = new SpeechSynthesisUtterance(word);
|
||||||
utterance.lang = 'en-US'; // English pronunciation
|
const targetLanguage = this._content?.language || 'en-US';
|
||||||
|
utterance.lang = targetLanguage;
|
||||||
utterance.rate = 0.9; // Slightly slower for clarity
|
utterance.rate = 0.9; // Slightly slower for clarity
|
||||||
utterance.pitch = 1.0;
|
utterance.pitch = 1.0;
|
||||||
utterance.volume = 1.0;
|
utterance.volume = 1.0;
|
||||||
|
|
||||||
// Try to use a good English voice
|
// Try to use a good voice for the target language
|
||||||
const voices = speechSynthesis.getVoices();
|
const voices = await this._getVoices();
|
||||||
const englishVoice = voices.find(voice =>
|
const langPrefix = targetLanguage.split('-')[0];
|
||||||
voice.lang.startsWith('en') && (voice.name.includes('Google') || voice.name.includes('Neural'))
|
const preferredVoice = voices.find(voice =>
|
||||||
) || voices.find(voice => voice.lang.startsWith('en'));
|
voice.lang.startsWith(langPrefix) && (voice.name.includes('Google') || voice.name.includes('Neural') || voice.default)
|
||||||
|
) || voices.find(voice => voice.lang.startsWith(langPrefix));
|
||||||
|
|
||||||
if (englishVoice) {
|
if (preferredVoice) {
|
||||||
utterance.voice = englishVoice;
|
utterance.voice = preferredVoice;
|
||||||
|
console.log(`🔊 Using voice: ${preferredVoice.name} (${preferredVoice.lang})`);
|
||||||
|
} else {
|
||||||
|
console.warn(`🔊 No voice found for: ${targetLanguage}, available:`, voices.map(v => v.lang));
|
||||||
}
|
}
|
||||||
|
|
||||||
speechSynthesis.speak(utterance);
|
speechSynthesis.speak(utterance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available speech synthesis voices, waiting for them to load if necessary
|
||||||
|
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getVoices() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let voices = window.speechSynthesis.getVoices();
|
||||||
|
|
||||||
|
// If voices are already loaded, return them immediately
|
||||||
|
if (voices.length > 0) {
|
||||||
|
resolve(voices);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, wait for voiceschanged event
|
||||||
|
const voicesChangedHandler = () => {
|
||||||
|
voices = window.speechSynthesis.getVoices();
|
||||||
|
if (voices.length > 0) {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(voices);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
|
||||||
|
// Fallback timeout in case voices never load
|
||||||
|
setTimeout(() => {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(window.speechSynthesis.getVoices());
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
_shuffleArray(array) {
|
_shuffleArray(array) {
|
||||||
const shuffled = [...array];
|
const shuffled = [...array];
|
||||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||||
|
|||||||
@ -70,6 +70,9 @@ class WizardSpellCaster extends Module {
|
|||||||
const sentences = content?.sentences || [];
|
const sentences = content?.sentences || [];
|
||||||
const storyChapters = content?.story?.chapters || [];
|
const storyChapters = content?.story?.chapters || [];
|
||||||
const dialogues = content?.dialogues || [];
|
const dialogues = content?.dialogues || [];
|
||||||
|
const texts = content?.texts || [];
|
||||||
|
const phrases = content?.phrases || {};
|
||||||
|
const dialogs = content?.dialogs || {};
|
||||||
|
|
||||||
let totalSentences = sentences.length;
|
let totalSentences = sentences.length;
|
||||||
|
|
||||||
@ -87,16 +90,45 @@ class WizardSpellCaster extends Module {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Count phrases (object format with key-value pairs)
|
||||||
|
if (typeof phrases === 'object') {
|
||||||
|
totalSentences += Object.keys(phrases).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count dialog lines (alternative spelling, object format)
|
||||||
|
if (typeof dialogs === 'object') {
|
||||||
|
Object.values(dialogs).forEach(dialog => {
|
||||||
|
if (dialog.lines && Array.isArray(dialog.lines)) {
|
||||||
|
totalSentences += dialog.lines.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count extractable sentences from texts (LEDU-style content)
|
||||||
|
let extractableSentences = 0;
|
||||||
|
texts.forEach(text => {
|
||||||
|
if (text.content) {
|
||||||
|
const sentencesInText = text.content.split(/[。!?\.\!\?]+/).filter(s => {
|
||||||
|
const trimmed = s.trim();
|
||||||
|
const wordCount = trimmed.split(/\s+/).length;
|
||||||
|
return trimmed && wordCount >= 3 && wordCount <= 15;
|
||||||
|
});
|
||||||
|
extractableSentences += sentencesInText.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
totalSentences += extractableSentences;
|
||||||
|
|
||||||
// If we have enough sentences, use them
|
// If we have enough sentences, use them
|
||||||
if (totalSentences >= 9) {
|
if (totalSentences >= 9) {
|
||||||
const score = Math.min(totalSentences / 30, 1);
|
const score = Math.min(totalSentences / 30, 1);
|
||||||
return {
|
return {
|
||||||
score,
|
score,
|
||||||
reason: `${totalSentences} sentences available for spell construction`,
|
reason: `${totalSentences} sentences/phrases available for spell construction`,
|
||||||
requirements: ['sentences', 'story', 'dialogues'],
|
requirements: ['sentences', 'story', 'dialogues', 'texts', 'phrases', 'dialogs'],
|
||||||
minSentences: 9,
|
minSentences: 9,
|
||||||
optimalSentences: 30,
|
optimalSentences: 30,
|
||||||
details: `Can create engaging spell combat with ${totalSentences} sentences`
|
details: `Can create engaging spell combat with ${totalSentences} sentences/phrases`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,11 +164,11 @@ class WizardSpellCaster extends Module {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
score: 0,
|
score: 0,
|
||||||
reason: `Insufficient content (${totalSentences} sentences, ${vocabCount} vocabulary words)`,
|
reason: `Insufficient content (${totalSentences} sentences/phrases, ${vocabCount} vocabulary words)`,
|
||||||
requirements: ['sentences', 'story', 'dialogues', 'vocabulary'],
|
requirements: ['sentences', 'story', 'dialogues', 'texts', 'phrases', 'dialogs', 'vocabulary'],
|
||||||
minSentences: 9,
|
minSentences: 9,
|
||||||
minWords: 15,
|
minWords: 15,
|
||||||
details: 'Wizard Spell Caster needs at least 9 sentences or 15 vocabulary words'
|
details: 'Wizard Spell Caster needs at least 9 sentences/phrases or 15 vocabulary words'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -292,6 +324,60 @@ class WizardSpellCaster extends Module {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract from phrases (key-value object format)
|
||||||
|
if (this._content.phrases && typeof this._content.phrases === 'object') {
|
||||||
|
Object.entries(this._content.phrases).forEach(([english, phraseData]) => {
|
||||||
|
const translation = typeof phraseData === 'string' ? phraseData : phraseData.user_language || phraseData.chinese;
|
||||||
|
if (english && translation) {
|
||||||
|
this._processSentence({
|
||||||
|
original: english,
|
||||||
|
translation: translation,
|
||||||
|
words: this._extractWordsFromSentence(english)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract from dialogs (alternative spelling of dialogues)
|
||||||
|
if (this._content.dialogs && typeof this._content.dialogs === 'object') {
|
||||||
|
Object.values(this._content.dialogs).forEach(dialog => {
|
||||||
|
if (dialog.lines && Array.isArray(dialog.lines)) {
|
||||||
|
dialog.lines.forEach(line => {
|
||||||
|
if (line.text && line.user_language) {
|
||||||
|
this._processSentence({
|
||||||
|
original: line.text,
|
||||||
|
translation: line.user_language,
|
||||||
|
words: this._extractWordsFromSentence(line.text)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Extract from texts (for LEDU-style content)
|
||||||
|
if (this._getTotalSpellCount() < 9 && this._content.texts && Array.isArray(this._content.texts)) {
|
||||||
|
console.log('WizardSpellCaster: Extracting spells from texts as fallback');
|
||||||
|
this._content.texts.forEach(text => {
|
||||||
|
if (text.content) {
|
||||||
|
// Split text into sentences using Chinese punctuation and periods
|
||||||
|
const sentences = text.content.split(/[。!?\.\!\?]+/).filter(s => s.trim().length > 0);
|
||||||
|
sentences.forEach(sentence => {
|
||||||
|
const trimmed = sentence.trim();
|
||||||
|
// Only use sentences with reasonable length (3-15 words)
|
||||||
|
const wordCount = trimmed.split(/\s+/).length;
|
||||||
|
if (trimmed && wordCount >= 3 && wordCount <= 15) {
|
||||||
|
this._processSentence({
|
||||||
|
original: trimmed,
|
||||||
|
translation: trimmed, // In Chinese content, use same for both
|
||||||
|
words: this._extractWordsFromSentence(trimmed)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_processSentence(sentenceData) {
|
_processSentence(sentenceData) {
|
||||||
@ -371,19 +457,22 @@ class WizardSpellCaster extends Module {
|
|||||||
style.textContent = `
|
style.textContent = `
|
||||||
.wizard-game-wrapper {
|
.wizard-game-wrapper {
|
||||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
color: white;
|
color: white;
|
||||||
font-family: 'Fantasy', serif;
|
font-family: 'Fantasy', serif;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizard-hud {
|
.wizard-hud {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 15px;
|
padding: 8px 15px;
|
||||||
background: rgba(0,0,0,0.3);
|
background: rgba(0,0,0,0.3);
|
||||||
border-bottom: 2px solid #ffd700;
|
border-bottom: 2px solid #ffd700;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizard-stats {
|
.wizard-stats {
|
||||||
@ -393,10 +482,10 @@ class WizardSpellCaster extends Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.health-bar {
|
.health-bar {
|
||||||
width: 150px;
|
width: 120px;
|
||||||
height: 20px;
|
height: 16px;
|
||||||
background: rgba(255,255,255,0.2);
|
background: rgba(255,255,255,0.2);
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 2px solid #ffd700;
|
border: 2px solid #ffd700;
|
||||||
}
|
}
|
||||||
@ -409,8 +498,9 @@ class WizardSpellCaster extends Module {
|
|||||||
|
|
||||||
.battle-area {
|
.battle-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 60vh;
|
height: 180px;
|
||||||
padding: 20px;
|
padding: 10px 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wizard-side {
|
.wizard-side {
|
||||||
@ -430,29 +520,29 @@ class WizardSpellCaster extends Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.wizard-character {
|
.wizard-character {
|
||||||
width: 120px;
|
width: 80px;
|
||||||
height: 120px;
|
height: 80px;
|
||||||
background: linear-gradient(45deg, #6c5ce7, #a29bfe);
|
background: linear-gradient(45deg, #6c5ce7, #a29bfe);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 48px;
|
font-size: 36px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 8px;
|
||||||
animation: float 3s ease-in-out infinite;
|
animation: float 3s ease-in-out infinite;
|
||||||
box-shadow: 0 0 30px rgba(108, 92, 231, 0.6);
|
box-shadow: 0 0 30px rgba(108, 92, 231, 0.6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.enemy-character {
|
.enemy-character {
|
||||||
width: 150px;
|
width: 100px;
|
||||||
height: 150px;
|
height: 100px;
|
||||||
background: linear-gradient(45deg, #ff4757, #ff6b7a);
|
background: linear-gradient(45deg, #ff4757, #ff6b7a);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 64px;
|
font-size: 48px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 8px;
|
||||||
animation: enemyPulse 2s ease-in-out infinite;
|
animation: enemyPulse 2s ease-in-out infinite;
|
||||||
box-shadow: 0 0 40px rgba(255, 71, 87, 0.6);
|
box-shadow: 0 0 40px rgba(255, 71, 87, 0.6);
|
||||||
}
|
}
|
||||||
@ -471,22 +561,27 @@ class WizardSpellCaster extends Module {
|
|||||||
background: rgba(0,0,0,0.4);
|
background: rgba(0,0,0,0.4);
|
||||||
border: 2px solid #ffd700;
|
border: 2px solid #ffd700;
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
padding: 20px;
|
padding: 12px 15px;
|
||||||
margin: 20px;
|
margin: 0 15px 10px 15px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spell-selection {
|
.spell-selection {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
gap: 15px;
|
gap: 10px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spell-card {
|
.spell-card {
|
||||||
background: linear-gradient(135deg, #2c2c54, #40407a);
|
background: linear-gradient(135deg, #2c2c54, #40407a);
|
||||||
border: 2px solid #ffd700;
|
border: 2px solid #ffd700;
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
padding: 15px;
|
padding: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -520,29 +615,32 @@ class WizardSpellCaster extends Module {
|
|||||||
|
|
||||||
.sentence-builder {
|
.sentence-builder {
|
||||||
background: rgba(255,255,255,0.1);
|
background: rgba(255,255,255,0.1);
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
padding: 15px;
|
padding: 10px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 10px;
|
||||||
min-height: 80px;
|
min-height: 60px;
|
||||||
border: 2px dashed #ffd700;
|
border: 2px dashed #ffd700;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.word-bank {
|
.word-bank {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.word-tile {
|
.word-tile {
|
||||||
background: linear-gradient(135deg, #5f27cd, #8854d0);
|
background: linear-gradient(135deg, #5f27cd, #8854d0);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 8px 15px;
|
padding: 6px 12px;
|
||||||
border-radius: 20px;
|
border-radius: 15px;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.word-tile:hover {
|
.word-tile:hover {
|
||||||
@ -564,14 +662,15 @@ class WizardSpellCaster extends Module {
|
|||||||
background: linear-gradient(135deg, #ff6b7a, #ff4757);
|
background: linear-gradient(135deg, #ff6b7a, #ff4757);
|
||||||
border: none;
|
border: none;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 15px 30px;
|
padding: 12px 25px;
|
||||||
border-radius: 25px;
|
border-radius: 20px;
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
box-shadow: 0 5px 15px rgba(255, 71, 87, 0.3);
|
box-shadow: 0 5px 15px rgba(255, 71, 87, 0.3);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cast-button:hover {
|
.cast-button:hover {
|
||||||
@ -1055,8 +1154,8 @@ class WizardSpellCaster extends Module {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sentence-builder" id="sentence-builder">
|
<div class="sentence-builder" id="sentence-builder">
|
||||||
<div style="color: #ffd700; margin-bottom: 10px;">Form your spell incantation:</div>
|
<div style="color: #ffd700; margin-bottom: 5px; font-size: 0.9em;">Form your spell incantation:</div>
|
||||||
<div id="current-sentence" style="font-size: 18px; min-height: 30px;"></div>
|
<div id="current-sentence" style="font-size: 16px; min-height: 24px;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="word-bank" id="word-bank">
|
<div class="word-bank" id="word-bank">
|
||||||
|
|||||||
@ -14,7 +14,6 @@ class WordDiscovery extends Module {
|
|||||||
container: null,
|
container: null,
|
||||||
difficulty: 'medium',
|
difficulty: 'medium',
|
||||||
practiceCount: 10,
|
practiceCount: 10,
|
||||||
timerDuration: 30,
|
|
||||||
...config
|
...config
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -294,16 +293,19 @@ class WordDiscovery extends Module {
|
|||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
|
|
||||||
<div class="word-content">
|
<div class="word-content">
|
||||||
<h3 class="word-text">${word.word}</h3>
|
<h3 class="word-text">
|
||||||
|
${word.word}
|
||||||
|
${word.pronunciation ? `<span class="word-pronunciation">[${word.pronunciation}]</span>` : ''}
|
||||||
|
</h3>
|
||||||
${word.translation ? `<p class="word-translation">${word.translation}</p>` : ''}
|
${word.translation ? `<p class="word-translation">${word.translation}</p>` : ''}
|
||||||
${word.definition ? `<p class="word-definition">${word.definition}</p>` : ''}
|
${word.definition ? `<p class="word-definition">${word.definition}</p>` : ''}
|
||||||
${word.example ? `<p class="word-example">"${word.example}"</p>` : ''}
|
${word.example ? `<p class="word-example">"${word.example}"</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="word-controls">
|
<div class="word-controls">
|
||||||
${word.audio ? `<button class="audio-btn" onclick="window.wordDiscovery._playAudio('${word.word}')">
|
<button class="audio-btn" onclick="window.wordDiscovery._playWordSound('${word.word}')">
|
||||||
🔊 Listen
|
🔊 Listen
|
||||||
</button>` : ''}
|
</button>
|
||||||
<button class="next-btn" onclick="window.wordDiscovery._nextWord()">
|
<button class="next-btn" onclick="window.wordDiscovery._nextWord()">
|
||||||
Next Word
|
Next Word
|
||||||
</button>
|
</button>
|
||||||
@ -319,6 +321,11 @@ class WordDiscovery extends Module {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
window.wordDiscovery = this;
|
window.wordDiscovery = this;
|
||||||
|
|
||||||
|
// Auto-play TTS when word is revealed (with slight delay for better UX)
|
||||||
|
setTimeout(() => {
|
||||||
|
this._playWordSound(word.word);
|
||||||
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
_nextWord() {
|
_nextWord() {
|
||||||
@ -341,6 +348,91 @@ class WordDiscovery extends Module {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_playWordSound(wordText) {
|
||||||
|
// First, try to play preloaded audio file if available
|
||||||
|
const audio = this._audioElements.get(wordText);
|
||||||
|
if (audio) {
|
||||||
|
audio.currentTime = 0;
|
||||||
|
audio.play().catch(error => {
|
||||||
|
console.warn(`Failed to play audio for ${wordText}:`, error);
|
||||||
|
// Fallback to TTS if audio file fails
|
||||||
|
this._playTTS(wordText);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No audio file, use TTS
|
||||||
|
this._playTTS(wordText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async _playTTS(text) {
|
||||||
|
if ('speechSynthesis' in window) {
|
||||||
|
// Cancel any ongoing speech
|
||||||
|
window.speechSynthesis.cancel();
|
||||||
|
|
||||||
|
const utterance = new SpeechSynthesisUtterance(text);
|
||||||
|
utterance.rate = 0.9; // Slightly slower for clarity
|
||||||
|
utterance.pitch = 1.0;
|
||||||
|
utterance.volume = 1.0;
|
||||||
|
|
||||||
|
// Get target language from content
|
||||||
|
const targetLanguage = this._content?.language || 'en-US';
|
||||||
|
utterance.lang = targetLanguage;
|
||||||
|
|
||||||
|
// Wait for voices to be loaded before selecting one
|
||||||
|
const voices = await this._getVoices();
|
||||||
|
const langPrefix = targetLanguage.split('-')[0]; // e.g., "zh" from "zh-CN"
|
||||||
|
|
||||||
|
const matchingVoice = voices.find(voice =>
|
||||||
|
voice.lang.startsWith(langPrefix) && voice.default
|
||||||
|
) || voices.find(voice => voice.lang.startsWith(langPrefix));
|
||||||
|
|
||||||
|
if (matchingVoice) {
|
||||||
|
utterance.voice = matchingVoice;
|
||||||
|
console.log(`🔊 Using TTS voice: ${matchingVoice.name} (${matchingVoice.lang})`);
|
||||||
|
} else {
|
||||||
|
console.warn(`🔊 No voice found for language: ${targetLanguage}, available:`, voices.map(v => v.lang));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.speechSynthesis.speak(utterance);
|
||||||
|
} else {
|
||||||
|
console.warn('Text-to-speech not supported in this browser');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available speech synthesis voices, waiting for them to load if necessary
|
||||||
|
* @returns {Promise<SpeechSynthesisVoice[]>} Array of available voices
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_getVoices() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let voices = window.speechSynthesis.getVoices();
|
||||||
|
|
||||||
|
// If voices are already loaded, return them immediately
|
||||||
|
if (voices.length > 0) {
|
||||||
|
resolve(voices);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, wait for voiceschanged event
|
||||||
|
const voicesChangedHandler = () => {
|
||||||
|
voices = window.speechSynthesis.getVoices();
|
||||||
|
if (voices.length > 0) {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(voices);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
|
||||||
|
// Fallback timeout in case voices never load
|
||||||
|
setTimeout(() => {
|
||||||
|
window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler);
|
||||||
|
resolve(window.speechSynthesis.getVoices());
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
_startPracticePhase() {
|
_startPracticePhase() {
|
||||||
if (this._discoveredWords.length === 0) {
|
if (this._discoveredWords.length === 0) {
|
||||||
this._discoveredWords = [...this._practiceWords];
|
this._discoveredWords = [...this._practiceWords];
|
||||||
@ -357,10 +449,10 @@ class WordDiscovery extends Module {
|
|||||||
_renderPracticeLevel() {
|
_renderPracticeLevel() {
|
||||||
const levels = ['Easy', 'Medium', 'Hard', 'Expert'];
|
const levels = ['Easy', 'Medium', 'Hard', 'Expert'];
|
||||||
const levelConfig = {
|
const levelConfig = {
|
||||||
0: { time: 45, options: 2, type: 'translation' },
|
0: { options: 4, type: 'translation' },
|
||||||
1: { time: 30, options: 3, type: 'mixed' },
|
1: { options: 3, type: 'mixed' },
|
||||||
2: { time: 20, options: 4, type: 'definition' },
|
2: { options: 4, type: 'definition' },
|
||||||
3: { time: 15, options: 4, type: 'context' }
|
3: { options: 4, type: 'context' }
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = levelConfig[this._currentPracticeLevel];
|
const config = levelConfig[this._currentPracticeLevel];
|
||||||
@ -375,7 +467,6 @@ class WordDiscovery extends Module {
|
|||||||
<span>Total: ${this._practiceTotal}</span>
|
<span>Total: ${this._practiceTotal}</span>
|
||||||
<span>Accuracy: ${this._practiceTotal > 0 ? Math.round((this._practiceCorrect / this._practiceTotal) * 100) : 0}%</span>
|
<span>Accuracy: ${this._practiceTotal > 0 ? Math.round((this._practiceCorrect / this._practiceTotal) * 100) : 0}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="timer">Time: <span id="timer-display">${config.time}</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="practice-question" id="practice-question">
|
<div class="practice-question" id="practice-question">
|
||||||
@ -394,41 +485,16 @@ class WordDiscovery extends Module {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this._timeLeft = config.time;
|
|
||||||
this._startTimer();
|
|
||||||
this._generateQuestion(config);
|
this._generateQuestion(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
_startTimer() {
|
|
||||||
const timerDisplay = document.getElementById('timer-display');
|
|
||||||
if (!timerDisplay) return;
|
|
||||||
|
|
||||||
this._timer = setInterval(() => {
|
|
||||||
this._timeLeft--;
|
|
||||||
timerDisplay.textContent = this._timeLeft;
|
|
||||||
|
|
||||||
if (this._timeLeft <= 0) {
|
|
||||||
clearInterval(this._timer);
|
|
||||||
this._handleTimeUp();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleTimeUp() {
|
|
||||||
this._practiceTotal++;
|
|
||||||
this._showResult(false, 'Time up!');
|
|
||||||
setTimeout(() => {
|
|
||||||
this._generateQuestion();
|
|
||||||
}, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
_generateQuestion(config = null) {
|
_generateQuestion(config = null) {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
const levelConfig = {
|
const levelConfig = {
|
||||||
0: { time: 45, options: 2, type: 'translation' },
|
0: { options: 4, type: 'translation' },
|
||||||
1: { time: 30, options: 3, type: 'mixed' },
|
1: { options: 3, type: 'mixed' },
|
||||||
2: { time: 20, options: 4, type: 'definition' },
|
2: { options: 4, type: 'definition' },
|
||||||
3: { time: 15, options: 4, type: 'context' }
|
3: { options: 4, type: 'context' }
|
||||||
};
|
};
|
||||||
config = levelConfig[this._currentPracticeLevel];
|
config = levelConfig[this._currentPracticeLevel];
|
||||||
}
|
}
|
||||||
@ -482,8 +548,11 @@ class WordDiscovery extends Module {
|
|||||||
<div class="question-content">
|
<div class="question-content">
|
||||||
<h3>What does this word mean?</h3>
|
<h3>What does this word mean?</h3>
|
||||||
<div class="question-word">
|
<div class="question-word">
|
||||||
${correctWord.word}
|
<div class="word-with-pronunciation">
|
||||||
${correctWord.audio ? `<button class="audio-btn-small" onclick="window.wordDiscovery._playAudio('${correctWord.word}')">🔊</button>` : ''}
|
<span>${correctWord.word}</span>
|
||||||
|
${correctWord.pronunciation ? `<span class="question-pronunciation">[${correctWord.pronunciation}]</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<button class="audio-btn-small" onclick="window.wordDiscovery._playWordSound('${correctWord.word}')">🔊</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="options-grid">
|
<div class="options-grid">
|
||||||
${this._practiceOptions.map(option => `
|
${this._practiceOptions.map(option => `
|
||||||
@ -506,7 +575,10 @@ class WordDiscovery extends Module {
|
|||||||
<div class="options-grid">
|
<div class="options-grid">
|
||||||
${this._practiceOptions.map(option => `
|
${this._practiceOptions.map(option => `
|
||||||
<button class="option-btn" onclick="window.wordDiscovery._selectAnswer('${option.word}')">
|
<button class="option-btn" onclick="window.wordDiscovery._selectAnswer('${option.word}')">
|
||||||
${option.word}
|
<div class="option-word-with-pronunciation">
|
||||||
|
<span>${option.word}</span>
|
||||||
|
${option.pronunciation ? `<span class="option-pronunciation">[${option.pronunciation}]</span>` : ''}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
@ -524,7 +596,10 @@ class WordDiscovery extends Module {
|
|||||||
<div class="options-grid">
|
<div class="options-grid">
|
||||||
${this._practiceOptions.map(option => `
|
${this._practiceOptions.map(option => `
|
||||||
<button class="option-btn" onclick="window.wordDiscovery._selectAnswer('${option.word}')">
|
<button class="option-btn" onclick="window.wordDiscovery._selectAnswer('${option.word}')">
|
||||||
${option.word}
|
<div class="option-word-with-pronunciation">
|
||||||
|
<span>${option.word}</span>
|
||||||
|
${option.pronunciation ? `<span class="option-pronunciation">[${option.pronunciation}]</span>` : ''}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
@ -538,6 +613,8 @@ class WordDiscovery extends Module {
|
|||||||
|
|
||||||
if (isCorrect) {
|
if (isCorrect) {
|
||||||
this._practiceCorrect++;
|
this._practiceCorrect++;
|
||||||
|
// Play TTS for correct answer
|
||||||
|
this._playWordSound(this._correctAnswer.word);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._showResult(isCorrect, isCorrect ? 'Correct!' : `Wrong! The answer was: ${this._correctAnswer.word}`);
|
this._showResult(isCorrect, isCorrect ? 'Correct!' : `Wrong! The answer was: ${this._correctAnswer.word}`);
|
||||||
@ -649,6 +726,17 @@ class WordDiscovery extends Module {
|
|||||||
color: #2c3e50;
|
color: #2c3e50;
|
||||||
margin: 15px 0;
|
margin: 15px 0;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-pronunciation {
|
||||||
|
font-size: 0.5em;
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.word-translation {
|
.word-translation {
|
||||||
@ -746,13 +834,6 @@ class WordDiscovery extends Module {
|
|||||||
color: #2c3e50;
|
color: #2c3e50;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timer {
|
|
||||||
font-size: 1.3em;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #e74c3c;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.question-content {
|
.question-content {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@ -777,6 +858,35 @@ class WordDiscovery extends Module {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-with-pronunciation {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-pronunciation {
|
||||||
|
font-size: 0.5em;
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-word-with-pronunciation {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-pronunciation {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.question-definition, .question-context {
|
.question-definition, .question-context {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import Module from '../core/Module.js';
|
import Module from '../core/Module.js';
|
||||||
|
import { soundSystem } from '../gameHelpers/MarioEducational/SoundSystem.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WordStorm - Fast-paced falling words game where players match vocabulary
|
* WordStorm - Fast-paced falling words game where players match vocabulary
|
||||||
@ -18,7 +19,7 @@ class WordStorm extends Module {
|
|||||||
this._config = {
|
this._config = {
|
||||||
container: null,
|
container: null,
|
||||||
maxWords: 50,
|
maxWords: 50,
|
||||||
fallSpeed: 8000, // ms to fall from top to bottom
|
fallSpeedVhPerSecond: 12.5, // % of viewport height per second (100vh in 8s = 12.5vh/s)
|
||||||
spawnRate: 4000, // ms between spawns
|
spawnRate: 4000, // ms between spawns
|
||||||
wordLifetime: 9200, // ms before word disappears (+15% more time)
|
wordLifetime: 9200, // ms before word disappears (+15% more time)
|
||||||
startingLives: 3,
|
startingLives: 3,
|
||||||
@ -110,6 +111,9 @@ class WordStorm extends Module {
|
|||||||
this._eventBus.on('game:pause', this._handlePause.bind(this), this.name);
|
this._eventBus.on('game:pause', this._handlePause.bind(this), this.name);
|
||||||
this._eventBus.on('game:resume', this._handleResume.bind(this), this.name);
|
this._eventBus.on('game:resume', this._handleResume.bind(this), this.name);
|
||||||
|
|
||||||
|
// Initialize sound system
|
||||||
|
soundSystem.initialize();
|
||||||
|
|
||||||
// Inject CSS
|
// Inject CSS
|
||||||
this._injectCSS();
|
this._injectCSS();
|
||||||
|
|
||||||
@ -719,14 +723,51 @@ class WordStorm extends Module {
|
|||||||
wordElement.className = 'falling-word';
|
wordElement.className = 'falling-word';
|
||||||
wordElement.textContent = word.original;
|
wordElement.textContent = word.original;
|
||||||
wordElement.style.left = Math.random() * 80 + 10 + '%';
|
wordElement.style.left = Math.random() * 80 + 10 + '%';
|
||||||
wordElement.style.top = '80px'; // Start just below the HUD
|
wordElement.style.top = '0vh'; // Start at top of viewport
|
||||||
|
|
||||||
gameArea.appendChild(wordElement);
|
gameArea.appendChild(wordElement);
|
||||||
|
|
||||||
|
// Start position check for this word
|
||||||
|
const positionCheck = setInterval(() => {
|
||||||
|
if (!wordElement.parentNode) {
|
||||||
|
clearInterval(positionCheck);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameArea = document.getElementById('game-area');
|
||||||
|
if (!gameArea) {
|
||||||
|
clearInterval(positionCheck);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get positions using getBoundingClientRect for accuracy
|
||||||
|
const wordRect = wordElement.getBoundingClientRect();
|
||||||
|
const gameAreaRect = gameArea.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Calculate word's position relative to game area
|
||||||
|
const wordTop = wordRect.top;
|
||||||
|
const wordHeight = wordRect.height;
|
||||||
|
const gameAreaBottom = gameAreaRect.bottom;
|
||||||
|
|
||||||
|
// Destroy when word's bottom edge nears the bottom of the game area
|
||||||
|
// Use larger margin to ensure word stays visible until destruction
|
||||||
|
const wordBottom = wordTop + wordHeight;
|
||||||
|
const threshold = gameAreaBottom - 100; // 100px margin before bottom
|
||||||
|
|
||||||
|
if (wordBottom >= threshold) {
|
||||||
|
clearInterval(positionCheck);
|
||||||
|
if (wordElement.parentNode) {
|
||||||
|
this._missWord(wordElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}, 50); // Check every 50ms for smooth detection
|
||||||
|
|
||||||
this._fallingWords.push({
|
this._fallingWords.push({
|
||||||
element: wordElement,
|
element: wordElement,
|
||||||
word: word,
|
word: word,
|
||||||
startTime: Date.now()
|
startTime: Date.now(),
|
||||||
|
positionCheck: positionCheck
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate new answer options when word spawns
|
// Generate new answer options when word spawns
|
||||||
@ -734,19 +775,25 @@ class WordStorm extends Module {
|
|||||||
|
|
||||||
// Animate falling
|
// Animate falling
|
||||||
this._animateFalling(wordElement);
|
this._animateFalling(wordElement);
|
||||||
|
|
||||||
// Remove after lifetime
|
|
||||||
setTimeout(() => {
|
|
||||||
if (wordElement.parentNode) {
|
|
||||||
this._missWord(wordElement);
|
|
||||||
}
|
|
||||||
}, this._config.wordLifetime);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_animateFalling(wordElement) {
|
_animateFalling(wordElement) {
|
||||||
wordElement.style.transition = `top ${this._config.fallSpeed}ms linear`;
|
const gameArea = document.getElementById('game-area');
|
||||||
|
if (!gameArea) return;
|
||||||
|
|
||||||
|
// Calculate fall duration based on gameArea height
|
||||||
|
const gameAreaHeight = gameArea.offsetHeight;
|
||||||
|
|
||||||
|
// Calculate duration based on configured speed (vh per second)
|
||||||
|
// Convert gameArea height to vh equivalent for timing calculation
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const gameAreaHeightVh = (gameAreaHeight / viewportHeight) * 100;
|
||||||
|
const fallDurationMs = (gameAreaHeightVh / this._config.fallSpeedVhPerSecond) * 1000;
|
||||||
|
|
||||||
|
wordElement.style.transition = `top ${fallDurationMs}ms linear`;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
wordElement.style.top = 'calc(100vh + 60px)'; // Continue falling past screen
|
// Animate to the bottom of the game area (use pixels for precision)
|
||||||
|
wordElement.style.top = `${gameAreaHeight}px`;
|
||||||
}, 50);
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -797,6 +844,14 @@ class WordStorm extends Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_correctAnswer(fallingWord) {
|
_correctAnswer(fallingWord) {
|
||||||
|
// Play success sound
|
||||||
|
soundSystem.play('coin');
|
||||||
|
|
||||||
|
// Clear position check interval
|
||||||
|
if (fallingWord.positionCheck) {
|
||||||
|
clearInterval(fallingWord.positionCheck);
|
||||||
|
}
|
||||||
|
|
||||||
// Remove from game with epic explosion
|
// Remove from game with epic explosion
|
||||||
if (fallingWord.element.parentNode) {
|
if (fallingWord.element.parentNode) {
|
||||||
fallingWord.element.classList.add('exploding');
|
fallingWord.element.classList.add('exploding');
|
||||||
@ -822,11 +877,15 @@ class WordStorm extends Module {
|
|||||||
// Remove from tracking
|
// Remove from tracking
|
||||||
this._fallingWords = this._fallingWords.filter(fw => fw !== fallingWord);
|
this._fallingWords = this._fallingWords.filter(fw => fw !== fallingWord);
|
||||||
|
|
||||||
// Update score
|
// Update score and combo
|
||||||
this._combo++;
|
this._combo++;
|
||||||
const points = 10 + (this._combo * 2);
|
const points = 10 + (this._combo * 2);
|
||||||
this._score += points;
|
this._score += points;
|
||||||
|
|
||||||
|
// Increase speed based on combo (3% per combo, max 2x speed)
|
||||||
|
const speedMultiplier = Math.min(1 + (this._combo * 0.03), 2);
|
||||||
|
this._config.fallSpeedVhPerSecond = 12.5 * speedMultiplier;
|
||||||
|
|
||||||
// Update display
|
// Update display
|
||||||
this._updateHUD();
|
this._updateHUD();
|
||||||
|
|
||||||
@ -855,8 +914,14 @@ class WordStorm extends Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_wrongAnswer() {
|
_wrongAnswer() {
|
||||||
|
// Play error sound
|
||||||
|
soundSystem.play('enemy_defeat');
|
||||||
|
|
||||||
this._combo = 0;
|
this._combo = 0;
|
||||||
|
|
||||||
|
// Reset speed to base when combo breaks
|
||||||
|
this._config.fallSpeedVhPerSecond = 12.5;
|
||||||
|
|
||||||
// Enhanced wrong answer animation
|
// Enhanced wrong answer animation
|
||||||
const answerPanel = document.getElementById('answer-panel');
|
const answerPanel = document.getElementById('answer-panel');
|
||||||
if (answerPanel) {
|
if (answerPanel) {
|
||||||
@ -929,6 +994,14 @@ class WordStorm extends Module {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_missWord(wordElement) {
|
_missWord(wordElement) {
|
||||||
|
// Find the falling word object
|
||||||
|
const fallingWord = this._fallingWords.find(fw => fw.element === wordElement);
|
||||||
|
|
||||||
|
// Clear position check interval
|
||||||
|
if (fallingWord && fallingWord.positionCheck) {
|
||||||
|
clearInterval(fallingWord.positionCheck);
|
||||||
|
}
|
||||||
|
|
||||||
// Remove word
|
// Remove word
|
||||||
if (wordElement.parentNode) {
|
if (wordElement.parentNode) {
|
||||||
wordElement.remove();
|
wordElement.remove();
|
||||||
@ -959,8 +1032,8 @@ class WordStorm extends Module {
|
|||||||
_levelUp() {
|
_levelUp() {
|
||||||
this._level++;
|
this._level++;
|
||||||
|
|
||||||
// Increase difficulty by 5% (x1.05 speed = /1.05 time)
|
// Increase difficulty by 5% (multiply speed by 1.05)
|
||||||
this._config.fallSpeed = Math.max(1000, this._config.fallSpeed / 1.05);
|
this._config.fallSpeedVhPerSecond = Math.min(50, this._config.fallSpeedVhPerSecond * 1.05);
|
||||||
this._config.spawnRate = Math.max(800, this._config.spawnRate / 1.05);
|
this._config.spawnRate = Math.max(800, this._config.spawnRate / 1.05);
|
||||||
|
|
||||||
// Restart intervals with new timing
|
// Restart intervals with new timing
|
||||||
@ -1020,8 +1093,11 @@ class WordStorm extends Module {
|
|||||||
this._spawnInterval = null;
|
this._spawnInterval = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear falling words
|
// Clear falling words and their intervals
|
||||||
this._fallingWords.forEach(fw => {
|
this._fallingWords.forEach(fw => {
|
||||||
|
if (fw.positionCheck) {
|
||||||
|
clearInterval(fw.positionCheck);
|
||||||
|
}
|
||||||
if (fw.element.parentNode) {
|
if (fw.element.parentNode) {
|
||||||
fw.element.remove();
|
fw.element.remove();
|
||||||
}
|
}
|
||||||
@ -1081,7 +1157,7 @@ class WordStorm extends Module {
|
|||||||
this._gameStartTime = Date.now();
|
this._gameStartTime = Date.now();
|
||||||
|
|
||||||
// Reset fall speed and spawn rate
|
// Reset fall speed and spawn rate
|
||||||
this._config.fallSpeed = 8000;
|
this._config.fallSpeedVhPerSecond = 12.5; // Reset to initial speed (100vh in 8s)
|
||||||
this._config.spawnRate = 4000;
|
this._config.spawnRate = 4000;
|
||||||
|
|
||||||
// Clear existing intervals
|
// Clear existing intervals
|
||||||
@ -1089,8 +1165,11 @@ class WordStorm extends Module {
|
|||||||
clearInterval(this._spawnInterval);
|
clearInterval(this._spawnInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear falling words
|
// Clear falling words and their intervals
|
||||||
this._fallingWords.forEach(fw => {
|
this._fallingWords.forEach(fw => {
|
||||||
|
if (fw.positionCheck) {
|
||||||
|
clearInterval(fw.positionCheck);
|
||||||
|
}
|
||||||
if (fw.element.parentNode) {
|
if (fw.element.parentNode) {
|
||||||
fw.element.remove();
|
fw.element.remove();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -232,77 +232,6 @@ p {
|
|||||||
background-color: #c53030;
|
background-color: #c53030;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Debug Panel */
|
|
||||||
.debug-panel {
|
|
||||||
position: fixed;
|
|
||||||
top: 70px;
|
|
||||||
right: 20px;
|
|
||||||
width: 300px;
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
|
||||||
z-index: 1000;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.debug-header {
|
|
||||||
background-color: #2d3748;
|
|
||||||
color: white;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: 0.5rem 0.5rem 0 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.debug-header h3 {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.debug-toggle {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.debug-content {
|
|
||||||
padding: 1rem;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.debug-content h4 {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: #718096;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.debug-content ul {
|
|
||||||
list-style: none;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.debug-content li {
|
|
||||||
padding: 0.25rem 0;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: #4a5568;
|
|
||||||
border-bottom: 1px solid #f7fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive Design */
|
/* Responsive Design */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.app-main {
|
.app-main {
|
||||||
@ -326,12 +255,6 @@ p {
|
|||||||
height: 6px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.debug-panel {
|
|
||||||
width: calc(100% - 40px);
|
|
||||||
right: 20px;
|
|
||||||
left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-screen h2 {
|
.loading-screen h2 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
@ -360,8 +283,4 @@ p {
|
|||||||
background: #000;
|
background: #000;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.debug-panel {
|
|
||||||
border: 2px solid black;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
136
src/utils/TTSHelper.js
Normal file
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