From 4714a4a1c693068b94664b3b5647b7648ba23119 Mon Sep 17 00:00:00 2001 From: StillHammer Date: Sat, 18 Oct 2025 02:49:48 +0800 Subject: [PATCH] Add TTS support and improve content compatibility system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- content/chapters/sbs-7-8.json | 237 ++++++++++ index.html | 88 +--- src/DRS/exercise-modules/VocabularyModule.js | 42 +- .../MarioEducational/PhysicsEngine.js | 76 +++- src/gameHelpers/MarioEducational/Renderer.js | 207 ++++++--- .../MarioEducational/enemies/FlyingEye.js | 36 +- src/games/AdventureReader.js | 270 ++++++++++-- src/games/FillTheBlank.js | 94 +++- src/games/FlashcardLearning.js | 40 +- src/games/GrammarDiscovery.js | 68 ++- src/games/MarioEducational.js | 274 ++++++++---- src/games/QuizGame.js | 99 ++++- src/games/RiverRun.js | 409 ++++++++++++++++-- src/games/WhackAMole.js | 57 ++- src/games/WhackAMoleHard.js | 57 ++- src/games/WizardSpellCaster.js | 179 ++++++-- src/games/WordDiscovery.js | 208 ++++++--- src/games/WordStorm.js | 115 ++++- src/styles/base.css | 81 ---- src/utils/TTSHelper.js | 136 ++++++ 20 files changed, 2203 insertions(+), 570 deletions(-) create mode 100644 src/utils/TTSHelper.js diff --git a/content/chapters/sbs-7-8.json b/content/chapters/sbs-7-8.json index 95e39a7..f35fac6 100644 --- a/content/chapters/sbs-7-8.json +++ b/content/chapters/sbs-7-8.json @@ -159,11 +159,248 @@ ] } }, + "grammar": { + "articles": { + "title": "Articles (a, an, the)", + "explanation": "Articles are used before nouns. 'A' and 'an' are indefinite articles (any one), 'the' is the definite article (a specific one).", + "examples": [ + { + "chinese": "I'm wearing a blue shirt", + "translation": "ζˆ‘η©Ώη€δΈ€δ»Άθ“θ‰²ηš„θ‘¬θ‘«", + "explanation": "We use 'a' before singular countable nouns" + }, + { + "chinese": "I live in the center of town", + "translation": "ζˆ‘δ½εœ¨εŸŽι•‡δΈ­εΏƒ", + "explanation": "We use 'the' when talking about a specific location" + } + ] + }, + "present-continuous": { + "title": "Present Continuous Tense (am/is/are + -ing)", + "explanation": "Used to describe actions happening right now or temporary situations. Form: subject + am/is/are + verb-ing", + "examples": [ + { + "chinese": "She is wearing a dress", + "translation": "ε₯Ήζ­£η©Ώη€δΈ€δ»ΆθΏžθ‘£θ£™", + "explanation": "Present continuous shows what someone is wearing now" + }, + { + "chinese": "What are you wearing?", + "translation": "δ½ η©Ώηš„ζ˜―δ»€δΉˆοΌŸ", + "explanation": "Question form: Question word + am/is/are + subject + verb-ing" + } + ] + }, + "adjectives-emotions": { + "title": "Adjectives for Feelings and Emotions", + "explanation": "Adjectives describe how someone feels. After 'feel' or 'be', we use adjectives (not adverbs).", + "examples": [ + { + "chinese": "I feel happy today", + "translation": "ζˆ‘δ»Šε€©ζ„Ÿθ§‰εΎˆεΌ€εΏƒ", + "explanation": "After 'feel', use adjective 'happy' not adverb 'happily'" + }, + { + "chinese": "Are you hungry?", + "translation": "δ½ ι₯Ώε—οΌŸ", + "explanation": "We use 'are' with adjectives like hungry, tired, cold" + } + ] + }, + "there-is-are": { + "title": "There is / There are", + "explanation": "Use 'there is' for singular or uncountable nouns, 'there are' for plural countable nouns.", + "examples": [ + { + "chinese": "There is a lot of noise", + "translation": "ζœ‰εΎˆε€šε™ͺ音", + "explanation": "'Noise' is uncountable, so we use 'is' not 'are'" + }, + { + "chinese": "There's a bus stop right outside", + "translation": "ε€–ι’ε°±ζœ‰δΈ€δΈͺ公亀车站", + "explanation": "Use 'there's' (there is) for a single bus stop" + } + ] + } + }, + "fillInBlanks": [ + { + "sentence": "I live in a two-bedroom ___", + "options": ["apartment", "building", "elevator", "closet"], + "correctAnswer": "apartment", + "explanation": "We use 'apartment' to describe a living space with bedrooms", + "grammarFocus": "housing-vocabulary" + }, + { + "sentence": "The apartment is in the ___ of town", + "options": ["center", "noise", "sidewalk", "building"], + "correctAnswer": "center", + "explanation": "'Center' means the middle or main part of town", + "grammarFocus": "location" + }, + { + "sentence": "I'm wearing a blue ___", + "options": ["shirt", "pants", "shoes", "hat"], + "correctAnswer": "shirt", + "explanation": "A shirt is a piece of clothing for the upper body", + "grammarFocus": "clothing-vocabulary" + }, + { + "sentence": "She is wearing a beautiful ___", + "options": ["dress", "tie", "belt", "gloves"], + "correctAnswer": "dress", + "explanation": "A dress is a one-piece garment typically worn by women", + "grammarFocus": "clothing-vocabulary" + }, + { + "sentence": "My ___ hurts from typing all day", + "options": ["fingers", "eyes", "ears", "nose"], + "correctAnswer": "fingers", + "explanation": "Fingers are used for typing on a keyboard", + "grammarFocus": "body-parts" + }, + { + "sentence": "I feel very ___ today", + "options": ["happy", "shirt", "computer", "building"], + "correctAnswer": "happy", + "explanation": "Happy is an emotion/feeling adjective", + "grammarFocus": "emotions" + }, + { + "sentence": "Are you ___? You haven't eaten all day", + "options": ["hungry", "tired", "cold", "hot"], + "correctAnswer": "hungry", + "explanation": "Hungry describes the feeling of needing food", + "grammarFocus": "emotions" + }, + { + "sentence": "I need to check my ___", + "options": ["email", "password", "website", "laptop"], + "correctAnswer": "email", + "explanation": "We 'check email' to read messages", + "grammarFocus": "technology" + }, + { + "sentence": "Do you have ___ access?", + "options": ["internet", "computer", "phone", "tablet"], + "correctAnswer": "internet", + "explanation": "We say 'internet access' to mean connection to the web", + "grammarFocus": "technology" + }, + { + "sentence": "There's a lot of ___ in the city", + "options": ["noise", "building", "elevator", "machine"], + "correctAnswer": "noise", + "explanation": "Noise refers to unwanted or loud sounds", + "grammarFocus": "housing-vocabulary" + }, + { + "sentence": "The bus ___ is right outside", + "options": ["stop", "building", "town", "sidewalk"], + "correctAnswer": "stop", + "explanation": "'Bus stop' is the place where buses pick up passengers", + "grammarFocus": "location" + }, + { + "sentence": "I'm wearing ___ because it's cold", + "options": ["gloves", "shorts", "sandals", "sunglasses"], + "correctAnswer": "gloves", + "explanation": "Gloves keep your hands warm in cold weather", + "grammarFocus": "clothing-vocabulary" + }, + { + "sentence": "She looks ___ about the test", + "options": ["worried", "exciting", "convenience", "building"], + "correctAnswer": "worried", + "explanation": "Worried describes feeling anxious or concerned", + "grammarFocus": "emotions" + }, + { + "sentence": "I need a new ___ for my job interview", + "options": ["suit", "jeans", "shorts", "sneakers"], + "correctAnswer": "suit", + "explanation": "A suit is formal clothing appropriate for interviews", + "grammarFocus": "clothing-vocabulary" + }, + { + "sentence": "My ___ are tired from walking all day", + "options": ["feet", "hands", "eyes", "ears"], + "correctAnswer": "feet", + "explanation": "Feet are used for walking", + "grammarFocus": "body-parts" + } + ], + "corrections": [ + { + "correct": "I'm wearing a blue shirt", + "incorrect": "I'm wearing blue shirt", + "explanation": "We need the article 'a' before singular countable nouns", + "grammarFocus": "articles" + }, + { + "correct": "There is a lot of noise", + "incorrect": "There are a lot of noise", + "grammarFocus": "subject-verb-agreement", + "explanation": "'Noise' is uncountable, so we use 'is' not 'are'" + }, + { + "correct": "I feel happy today", + "incorrect": "I feel happily today", + "explanation": "After 'feel', we use an adjective (happy) not an adverb (happily)", + "grammarFocus": "adjectives-adverbs" + }, + { + "correct": "She is wearing a dress", + "incorrect": "She wearing a dress", + "explanation": "We need the verb 'is' in present continuous tense", + "grammarFocus": "present-continuous" + }, + { + "correct": "I need to check my email", + "incorrect": "I need check my email", + "explanation": "After 'need', we use 'to' + infinitive verb", + "grammarFocus": "infinitives" + }, + { + "correct": "Do you have internet access?", + "incorrect": "Are you have internet access?", + "explanation": "We use 'do' not 'are' for questions with the verb 'have'", + "grammarFocus": "questions" + }, + { + "correct": "My fingers hurt", + "incorrect": "My fingers hurts", + "explanation": "Plural subjects take plural verbs (hurt, not hurts)", + "grammarFocus": "subject-verb-agreement" + }, + { + "correct": "The apartment is convenient", + "incorrect": "The apartment is convenience", + "explanation": "We need the adjective 'convenient', not the noun 'convenience'", + "grammarFocus": "adjectives-nouns" + }, + { + "correct": "I live in the center of town", + "incorrect": "I live in center of town", + "explanation": "We need 'the' before 'center' when talking about a specific location", + "grammarFocus": "articles" + }, + { + "correct": "Are you hungry?", + "incorrect": "Do you hungry?", + "explanation": "We use 'are' with adjectives, not 'do'", + "grammarFocus": "questions" + } + ], "statistics": { "vocabulary_count": 67, "phrases_count": 10, "dialogs_count": 2, "exercises_count": 2, + "fillInBlanks_count": 15, + "corrections_count": 10, "estimated_completion_time": 25 } } \ No newline at end of file diff --git a/index.html b/index.html index 1a02e89..e0ffd55 100644 --- a/index.html +++ b/index.html @@ -43,32 +43,6 @@
- - - @@ -151,14 +125,6 @@ if (appContainer) appContainer.style.display = 'block'; // Smart Preview Orchestrator is automatically initialized by Application.js - - // Show debug panel if enabled - const status = app.getStatus(); - if (status.config.enableDebug) { - const debugPanel = document.getElementById('debug-panel'); - if (debugPanel) debugPanel.style.display = 'block'; - updateDebugInfo(); - } }, 'Bootstrap'); // Handle navigation events @@ -411,9 +377,11 @@

${game.metadata.name}

${game.metadata.description}

- - ${game.metadata.difficulty} - + ${typeof game.metadata.difficulty === 'string' ? ` + + ${game.metadata.difficulty} + + ` : ''} ${Math.round(game.compatibility.score * 100)}% compatible @@ -585,9 +553,6 @@ // Set up keyboard shortcuts after app is ready setupKeyboardShortcuts(); - - // Set up debug panel after app is ready - setupDebugPanel(); } // Tooltip functions - Make them global @@ -644,32 +609,6 @@ } } - function updateDebugInfo() { - const debugStatus = document.getElementById('debug-status'); - if (debugStatus && app) { - const status = app.getStatus(); - debugStatus.innerHTML = ` -

System Status

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

${word.word}

+

+ ${word.word} + ${word.pronunciation ? `[${word.pronunciation}]` : ''} +

${word.translation ? `

${word.translation}

` : ''} ${word.definition ? `

${word.definition}

` : ''} ${word.example ? `

"${word.example}"

` : ''}
- ${word.audio ? `` : ''} + @@ -319,6 +321,11 @@ class WordDiscovery extends Module { `; window.wordDiscovery = this; + + // Auto-play TTS when word is revealed (with slight delay for better UX) + setTimeout(() => { + this._playWordSound(word.word); + }, 300); } _nextWord() { @@ -341,6 +348,91 @@ class WordDiscovery extends Module { } } + _playWordSound(wordText) { + // First, try to play preloaded audio file if available + const audio = this._audioElements.get(wordText); + if (audio) { + audio.currentTime = 0; + audio.play().catch(error => { + console.warn(`Failed to play audio for ${wordText}:`, error); + // Fallback to TTS if audio file fails + this._playTTS(wordText); + }); + } else { + // No audio file, use TTS + this._playTTS(wordText); + } + } + + async _playTTS(text) { + if ('speechSynthesis' in window) { + // Cancel any ongoing speech + window.speechSynthesis.cancel(); + + const utterance = new SpeechSynthesisUtterance(text); + utterance.rate = 0.9; // Slightly slower for clarity + utterance.pitch = 1.0; + utterance.volume = 1.0; + + // Get target language from content + const targetLanguage = this._content?.language || 'en-US'; + utterance.lang = targetLanguage; + + // Wait for voices to be loaded before selecting one + const voices = await this._getVoices(); + const langPrefix = targetLanguage.split('-')[0]; // e.g., "zh" from "zh-CN" + + const matchingVoice = voices.find(voice => + voice.lang.startsWith(langPrefix) && voice.default + ) || voices.find(voice => voice.lang.startsWith(langPrefix)); + + if (matchingVoice) { + utterance.voice = matchingVoice; + console.log(`πŸ”Š Using TTS voice: ${matchingVoice.name} (${matchingVoice.lang})`); + } else { + console.warn(`πŸ”Š No voice found for language: ${targetLanguage}, available:`, voices.map(v => v.lang)); + } + + window.speechSynthesis.speak(utterance); + } else { + console.warn('Text-to-speech not supported in this browser'); + } + } + + /** + * Get available speech synthesis voices, waiting for them to load if necessary + * @returns {Promise} Array of available voices + * @private + */ + _getVoices() { + return new Promise((resolve) => { + let voices = window.speechSynthesis.getVoices(); + + // If voices are already loaded, return them immediately + if (voices.length > 0) { + resolve(voices); + return; + } + + // Otherwise, wait for voiceschanged event + const voicesChangedHandler = () => { + voices = window.speechSynthesis.getVoices(); + if (voices.length > 0) { + window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler); + resolve(voices); + } + }; + + window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler); + + // Fallback timeout in case voices never load + setTimeout(() => { + window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler); + resolve(window.speechSynthesis.getVoices()); + }, 1000); + }); + } + _startPracticePhase() { if (this._discoveredWords.length === 0) { this._discoveredWords = [...this._practiceWords]; @@ -357,10 +449,10 @@ class WordDiscovery extends Module { _renderPracticeLevel() { const levels = ['Easy', 'Medium', 'Hard', 'Expert']; const levelConfig = { - 0: { time: 45, options: 2, type: 'translation' }, - 1: { time: 30, options: 3, type: 'mixed' }, - 2: { time: 20, options: 4, type: 'definition' }, - 3: { time: 15, options: 4, type: 'context' } + 0: { options: 4, type: 'translation' }, + 1: { options: 3, type: 'mixed' }, + 2: { options: 4, type: 'definition' }, + 3: { options: 4, type: 'context' } }; const config = levelConfig[this._currentPracticeLevel]; @@ -375,7 +467,6 @@ class WordDiscovery extends Module { Total: ${this._practiceTotal} Accuracy: ${this._practiceTotal > 0 ? Math.round((this._practiceCorrect / this._practiceTotal) * 100) : 0}%
-
Time: ${config.time}
@@ -394,41 +485,16 @@ class WordDiscovery extends Module {
`; - this._timeLeft = config.time; - this._startTimer(); this._generateQuestion(config); } - _startTimer() { - const timerDisplay = document.getElementById('timer-display'); - if (!timerDisplay) return; - - this._timer = setInterval(() => { - this._timeLeft--; - timerDisplay.textContent = this._timeLeft; - - if (this._timeLeft <= 0) { - clearInterval(this._timer); - this._handleTimeUp(); - } - }, 1000); - } - - _handleTimeUp() { - this._practiceTotal++; - this._showResult(false, 'Time up!'); - setTimeout(() => { - this._generateQuestion(); - }, 1500); - } - _generateQuestion(config = null) { if (!config) { const levelConfig = { - 0: { time: 45, options: 2, type: 'translation' }, - 1: { time: 30, options: 3, type: 'mixed' }, - 2: { time: 20, options: 4, type: 'definition' }, - 3: { time: 15, options: 4, type: 'context' } + 0: { options: 4, type: 'translation' }, + 1: { options: 3, type: 'mixed' }, + 2: { options: 4, type: 'definition' }, + 3: { options: 4, type: 'context' } }; config = levelConfig[this._currentPracticeLevel]; } @@ -482,8 +548,11 @@ class WordDiscovery extends Module {

What does this word mean?

- ${correctWord.word} - ${correctWord.audio ? `` : ''} +
+ ${correctWord.word} + ${correctWord.pronunciation ? `[${correctWord.pronunciation}]` : ''} +
+
${this._practiceOptions.map(option => ` @@ -506,7 +575,10 @@ class WordDiscovery extends Module {
${this._practiceOptions.map(option => ` `).join('')}
@@ -524,7 +596,10 @@ class WordDiscovery extends Module {
${this._practiceOptions.map(option => ` `).join('')}
@@ -538,6 +613,8 @@ class WordDiscovery extends Module { if (isCorrect) { this._practiceCorrect++; + // Play TTS for correct answer + this._playWordSound(this._correctAnswer.word); } this._showResult(isCorrect, isCorrect ? 'Correct!' : `Wrong! The answer was: ${this._correctAnswer.word}`); @@ -649,6 +726,17 @@ class WordDiscovery extends Module { color: #2c3e50; margin: 15px 0; font-weight: bold; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + } + + .word-pronunciation { + font-size: 0.5em; + color: #7f8c8d; + font-style: italic; + font-weight: normal; } .word-translation { @@ -746,13 +834,6 @@ class WordDiscovery extends Module { color: #2c3e50; } - .timer { - font-size: 1.3em; - font-weight: bold; - color: #e74c3c; - margin: 10px 0; - } - .question-content { background: white; border-radius: 12px; @@ -777,6 +858,35 @@ class WordDiscovery extends Module { align-items: center; justify-content: center; gap: 10px; + flex-wrap: wrap; + } + + .word-with-pronunciation { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + } + + .question-pronunciation { + font-size: 0.5em; + color: #7f8c8d; + font-style: italic; + font-weight: normal; + } + + .option-word-with-pronunciation { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + } + + .option-pronunciation { + font-size: 0.8em; + color: #7f8c8d; + font-style: italic; + font-weight: normal; } .question-definition, .question-context { diff --git a/src/games/WordStorm.js b/src/games/WordStorm.js index 574c718..3ec7019 100644 --- a/src/games/WordStorm.js +++ b/src/games/WordStorm.js @@ -1,4 +1,5 @@ import Module from '../core/Module.js'; +import { soundSystem } from '../gameHelpers/MarioEducational/SoundSystem.js'; /** * WordStorm - Fast-paced falling words game where players match vocabulary @@ -18,7 +19,7 @@ class WordStorm extends Module { this._config = { container: null, maxWords: 50, - fallSpeed: 8000, // ms to fall from top to bottom + fallSpeedVhPerSecond: 12.5, // % of viewport height per second (100vh in 8s = 12.5vh/s) spawnRate: 4000, // ms between spawns wordLifetime: 9200, // ms before word disappears (+15% more time) startingLives: 3, @@ -110,6 +111,9 @@ class WordStorm extends Module { this._eventBus.on('game:pause', this._handlePause.bind(this), this.name); this._eventBus.on('game:resume', this._handleResume.bind(this), this.name); + // Initialize sound system + soundSystem.initialize(); + // Inject CSS this._injectCSS(); @@ -719,14 +723,51 @@ class WordStorm extends Module { wordElement.className = 'falling-word'; wordElement.textContent = word.original; wordElement.style.left = Math.random() * 80 + 10 + '%'; - wordElement.style.top = '80px'; // Start just below the HUD + wordElement.style.top = '0vh'; // Start at top of viewport gameArea.appendChild(wordElement); + // Start position check for this word + const positionCheck = setInterval(() => { + if (!wordElement.parentNode) { + clearInterval(positionCheck); + return; + } + + const gameArea = document.getElementById('game-area'); + if (!gameArea) { + clearInterval(positionCheck); + return; + } + + // Get positions using getBoundingClientRect for accuracy + const wordRect = wordElement.getBoundingClientRect(); + const gameAreaRect = gameArea.getBoundingClientRect(); + + // Calculate word's position relative to game area + const wordTop = wordRect.top; + const wordHeight = wordRect.height; + const gameAreaBottom = gameAreaRect.bottom; + + // Destroy when word's bottom edge nears the bottom of the game area + // Use larger margin to ensure word stays visible until destruction + const wordBottom = wordTop + wordHeight; + const threshold = gameAreaBottom - 100; // 100px margin before bottom + + if (wordBottom >= threshold) { + clearInterval(positionCheck); + if (wordElement.parentNode) { + this._missWord(wordElement); + } + } + + }, 50); // Check every 50ms for smooth detection + this._fallingWords.push({ element: wordElement, word: word, - startTime: Date.now() + startTime: Date.now(), + positionCheck: positionCheck }); // Generate new answer options when word spawns @@ -734,19 +775,25 @@ class WordStorm extends Module { // Animate falling this._animateFalling(wordElement); - - // Remove after lifetime - setTimeout(() => { - if (wordElement.parentNode) { - this._missWord(wordElement); - } - }, this._config.wordLifetime); } _animateFalling(wordElement) { - wordElement.style.transition = `top ${this._config.fallSpeed}ms linear`; + const gameArea = document.getElementById('game-area'); + if (!gameArea) return; + + // Calculate fall duration based on gameArea height + const gameAreaHeight = gameArea.offsetHeight; + + // Calculate duration based on configured speed (vh per second) + // Convert gameArea height to vh equivalent for timing calculation + const viewportHeight = window.innerHeight; + const gameAreaHeightVh = (gameAreaHeight / viewportHeight) * 100; + const fallDurationMs = (gameAreaHeightVh / this._config.fallSpeedVhPerSecond) * 1000; + + wordElement.style.transition = `top ${fallDurationMs}ms linear`; setTimeout(() => { - wordElement.style.top = 'calc(100vh + 60px)'; // Continue falling past screen + // Animate to the bottom of the game area (use pixels for precision) + wordElement.style.top = `${gameAreaHeight}px`; }, 50); } @@ -797,6 +844,14 @@ class WordStorm extends Module { } _correctAnswer(fallingWord) { + // Play success sound + soundSystem.play('coin'); + + // Clear position check interval + if (fallingWord.positionCheck) { + clearInterval(fallingWord.positionCheck); + } + // Remove from game with epic explosion if (fallingWord.element.parentNode) { fallingWord.element.classList.add('exploding'); @@ -822,11 +877,15 @@ class WordStorm extends Module { // Remove from tracking this._fallingWords = this._fallingWords.filter(fw => fw !== fallingWord); - // Update score + // Update score and combo this._combo++; const points = 10 + (this._combo * 2); this._score += points; + // Increase speed based on combo (3% per combo, max 2x speed) + const speedMultiplier = Math.min(1 + (this._combo * 0.03), 2); + this._config.fallSpeedVhPerSecond = 12.5 * speedMultiplier; + // Update display this._updateHUD(); @@ -855,8 +914,14 @@ class WordStorm extends Module { } _wrongAnswer() { + // Play error sound + soundSystem.play('enemy_defeat'); + this._combo = 0; + // Reset speed to base when combo breaks + this._config.fallSpeedVhPerSecond = 12.5; + // Enhanced wrong answer animation const answerPanel = document.getElementById('answer-panel'); if (answerPanel) { @@ -929,6 +994,14 @@ class WordStorm extends Module { } _missWord(wordElement) { + // Find the falling word object + const fallingWord = this._fallingWords.find(fw => fw.element === wordElement); + + // Clear position check interval + if (fallingWord && fallingWord.positionCheck) { + clearInterval(fallingWord.positionCheck); + } + // Remove word if (wordElement.parentNode) { wordElement.remove(); @@ -959,8 +1032,8 @@ class WordStorm extends Module { _levelUp() { this._level++; - // Increase difficulty by 5% (x1.05 speed = /1.05 time) - this._config.fallSpeed = Math.max(1000, this._config.fallSpeed / 1.05); + // Increase difficulty by 5% (multiply speed by 1.05) + this._config.fallSpeedVhPerSecond = Math.min(50, this._config.fallSpeedVhPerSecond * 1.05); this._config.spawnRate = Math.max(800, this._config.spawnRate / 1.05); // Restart intervals with new timing @@ -1020,8 +1093,11 @@ class WordStorm extends Module { this._spawnInterval = null; } - // Clear falling words + // Clear falling words and their intervals this._fallingWords.forEach(fw => { + if (fw.positionCheck) { + clearInterval(fw.positionCheck); + } if (fw.element.parentNode) { fw.element.remove(); } @@ -1081,7 +1157,7 @@ class WordStorm extends Module { this._gameStartTime = Date.now(); // Reset fall speed and spawn rate - this._config.fallSpeed = 8000; + this._config.fallSpeedVhPerSecond = 12.5; // Reset to initial speed (100vh in 8s) this._config.spawnRate = 4000; // Clear existing intervals @@ -1089,8 +1165,11 @@ class WordStorm extends Module { clearInterval(this._spawnInterval); } - // Clear falling words + // Clear falling words and their intervals this._fallingWords.forEach(fw => { + if (fw.positionCheck) { + clearInterval(fw.positionCheck); + } if (fw.element.parentNode) { fw.element.remove(); } diff --git a/src/styles/base.css b/src/styles/base.css index 99fcc99..de225c1 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -232,77 +232,6 @@ p { background-color: #c53030; } -/* Debug Panel */ -.debug-panel { - position: fixed; - top: 70px; - right: 20px; - width: 300px; - background: white; - border: 1px solid #e2e8f0; - border-radius: 0.5rem; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); - z-index: 1000; - font-size: 0.875rem; -} - -.debug-header { - background-color: #2d3748; - color: white; - padding: 0.75rem 1rem; - border-radius: 0.5rem 0.5rem 0 0; - display: flex; - justify-content: space-between; - align-items: center; -} - -.debug-header h3 { - font-size: 0.875rem; - font-weight: 600; - margin: 0; - color: white; -} - -.debug-toggle { - background: none; - border: none; - color: white; - font-size: 1.25rem; - cursor: pointer; - padding: 0; - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; -} - -.debug-content { - padding: 1rem; - max-height: 400px; - overflow-y: auto; -} - -.debug-content h4 { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - color: #718096; - margin-bottom: 0.5rem; -} - -.debug-content ul { - list-style: none; - margin-bottom: 1rem; -} - -.debug-content li { - padding: 0.25rem 0; - font-size: 0.75rem; - color: #4a5568; - border-bottom: 1px solid #f7fafc; -} - /* Responsive Design */ @media (max-width: 768px) { .app-main { @@ -326,12 +255,6 @@ p { height: 6px; } - .debug-panel { - width: calc(100% - 40px); - right: 20px; - left: 20px; - } - .loading-screen h2 { font-size: 1.25rem; } @@ -360,8 +283,4 @@ p { background: #000; color: white; } - - .debug-panel { - border: 2px solid black; - } } \ No newline at end of file diff --git a/src/utils/TTSHelper.js b/src/utils/TTSHelper.js new file mode 100644 index 0000000..7c1f06c --- /dev/null +++ b/src/utils/TTSHelper.js @@ -0,0 +1,136 @@ +/** + * TTSHelper - Text-to-Speech utility for consistent TTS behavior across the app + * Fixes the common issue where voices aren't loaded on first call + */ + +class TTSHelper { + /** + * Get available speech synthesis voices, waiting for them to load if necessary + * @returns {Promise} Array of available voices + */ + static getVoices() { + return new Promise((resolve) => { + let voices = window.speechSynthesis.getVoices(); + + // If voices are already loaded, return them immediately + if (voices.length > 0) { + resolve(voices); + return; + } + + // Otherwise, wait for voiceschanged event + const voicesChangedHandler = () => { + voices = window.speechSynthesis.getVoices(); + if (voices.length > 0) { + window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler); + resolve(voices); + } + }; + + window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler); + + // Fallback timeout in case voices never load + setTimeout(() => { + window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler); + resolve(window.speechSynthesis.getVoices()); + }, 1000); + }); + } + + /** + * Select the best voice for a given language + * @param {string} language - Language code (e.g., "zh-CN", "en-US") + * @param {SpeechSynthesisVoice[]} voices - Available voices + * @returns {SpeechSynthesisVoice|null} - Best matching voice or null + */ + static selectVoice(language, voices) { + if (!voices || voices.length === 0) return null; + + const langPrefix = language.split('-')[0]; // e.g., "zh" from "zh-CN" + + // Try to find: + // 1. Default voice for the exact language + // 2. Any voice for the exact language + // 3. Default voice for the language prefix + // 4. Any voice for the language prefix + const matchingVoice = voices.find(voice => + voice.lang === language && voice.default + ) || voices.find(voice => + voice.lang === language + ) || voices.find(voice => + voice.lang.startsWith(langPrefix) && voice.default + ) || voices.find(voice => + voice.lang.startsWith(langPrefix) + ); + + return matchingVoice || null; + } + + /** + * Speak text using TTS with proper voice selection and error handling + * @param {string} text - Text to speak + * @param {Object} options - TTS options + * @param {string} options.lang - Language code (e.g., "zh-CN", "en-US") + * @param {number} options.rate - Speech rate (default 0.8) + * @param {number} options.pitch - Speech pitch (default 1.0) + * @param {number} options.volume - Speech volume (default 1.0) + * @param {Function} options.onStart - Callback when speech starts + * @param {Function} options.onEnd - Callback when speech ends + * @param {Function} options.onError - Callback on error + * @returns {Promise} + */ + static async speak(text, options = {}) { + if (!('speechSynthesis' in window)) { + console.warn('πŸ”Š Speech Synthesis not supported in this browser'); + if (options.onError) options.onError(new Error('Speech Synthesis not supported')); + return; + } + + try { + // Cancel any ongoing speech + window.speechSynthesis.cancel(); + + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = options.lang || 'en-US'; + utterance.rate = options.rate || 0.8; + utterance.pitch = options.pitch || 1.0; + utterance.volume = options.volume || 1.0; + + // Wait for voices to be loaded before selecting one + const voices = await TTSHelper.getVoices(); + const selectedVoice = TTSHelper.selectVoice(utterance.lang, voices); + + if (selectedVoice) { + utterance.voice = selectedVoice; + console.log(`πŸ”Š Using voice: ${selectedVoice.name} (${selectedVoice.lang})`); + } else { + console.warn(`πŸ”Š No voice found for language: ${utterance.lang}, using default`); + } + + // Add event handlers + utterance.onstart = () => { + console.log('πŸ”Š TTS started for:', text); + if (options.onStart) options.onStart(); + }; + + utterance.onend = () => { + console.log('πŸ”Š TTS finished for:', text); + if (options.onEnd) options.onEnd(); + }; + + utterance.onerror = (event) => { + console.warn('πŸ”Š TTS error:', event.error); + if (options.onError) options.onError(event); + }; + + // Speak the text + window.speechSynthesis.speak(utterance); + + } catch (error) { + console.warn('πŸ”Š TTS failed:', error); + if (options.onError) options.onError(error); + } + } +} + +export default TTSHelper;