diff --git a/.envTMP b/.envTMP new file mode 100644 index 0000000..21f23ee --- /dev/null +++ b/.envTMP @@ -0,0 +1,48 @@ +# ======================================== +# FICHIER: .env - CONFIGURATION COMPLÈTE PRÊTE À UTILISER +# ======================================== + +# GOOGLE SHEETS - Configuration complète fournie +GOOGLE_SERVICE_ACCOUNT_EMAIL=seo-generator@seo-generator-470715.iam.gserviceaccount.com +GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC/pvp9roHCUqgv\nOaabM3LLY1XLeqajhN6s0XXXDPOgvuPPuMlg4v7nEuFQOrft39bXS5D73MtihmxE\ngrQQJpzsv1dyuhB6fPmbmNgOsctoQuCFMGb/Mc6Pt3MZD7hHeifyoPhBlu9a6Fpp\n2NB4Bi9i/fN82UegOz03wYUpqXJEuMYexUP6iOj9KrUlJ8hgd+SUABMFrc5WKHYa\neRCeI/XuK9W3u0W1UXu/DSJRkay4hzuseyYgLldUUCISNlpw9XVsvEg0CSJ5N62D\nykKwiYmW78+UaQQPupiOtRHZWzt4Nr8DpACgVFqkohY2BRmqcyDqq3jEvZdOFweZ\nqWt9ZNSFAgMBAAECggEAKtvsqK6d1hcmBWmfGJYo1dMhHKARJABSy9MLx0veL9SA\nnbN1VXVuC77tJEP9XfTw1rTPd4Oo+B+XlrqkCfiYn1kq9T0m8j2AlItZxe98zZQn\nIaHxZqB80Sb1VmVtkI6A4IGfAwv9+xZ7IbCa7jxz3G9uRD1TB0I4Ln/Yh7idFUDZ\nsHXO30VDaB3QNEQnOTFTQCJ+e/JxQCMALXiLllaW/9aXD19LbgcjQaFAlR2/kKZo\nxBArFZ8ozmV2RINLEAKVXLqf5hHklLAIF77vox4yjhP+VKJ8JKI8cIItmLLLkZsZ\n2liNxFjqemeu4GT8Mgjy5JemkDRsI4s8BQtFLk9IAQKBgQDvOFX8Z8Xp+3+UmOpC\nmG1P62xV66v4i2tdYd1mqEWwlunPvHsufSiyFWRINi3a2FYt0mElVtfB5K6qOF91\nXXEAia80YQHjvqznZJChgJkuz1jlYU+9pSbeLLGKTVHX2JAeF9B8LEZEjf9zYRcd\nbRs/Wr0LXPPUP5bEmZ7RUo34eQKBgQDNGHg8BqwIRmXzhA81VrRI5R+AM/t0xMuf\nsyVJj4rBCVOChgn2kURu9ZkppXrvP6sFSOXBhhXF0/4sN9sYKaMa6FyB7Pz/c8EM\nagB80csV0GsZj0/CYRpqryxdtxGy6v4vFE7ncSS+je8M5Du9PCKx0JXrCEuAroMQ\naP8+nIIRbQKBgHcMu0YUwtryDYj/HL4tq2D1kYGk+n2DrNfZR1y6a4w5XnzCmS8G\nnIUbvj9trx5VQXYmV7BEarWUwBP4YBFBgmY5HxdbG5yinNu/IXcuT42LJPtqlUuU\n8CXraiOg3RUlMnu3cEsLoaCmZjWeYOmFDeVWm/QWu0Wqq7aFmRMlGYBJAoGANml2\nhJ5Uh8F9jNSNYF5HaEt5Rv8DiGApkY3qp5BwhHQf9rHu9L5nhHSeFOF1MwIWMkm7\nwtL69cgfV8Xd15Q8VIgu+r1QBcnE/rEkvfi+w2PO9jICPBSc+I7O23IVPP2BQCZI\nJLjswa1QLYBjpPnOTpSDIZ7KwTILTZA9n3PQQiUCgYEAzhW/vR4M8mqWZ+f1Kiwo\ngBrzQmtRzDAr4FpZ2NGK8o2KYox1DOvLHV/BExfALN025hoUcMifW4wK4ionPqwy\n3lxRvLMRZ4ObkVzWpI2q9L2rvNfINo60QcnX8tJC7oElzYPZeHp0naEzJSbPfQsM\nxmmc5R1PzIynW+Q2cfapzXY=\n-----END PRIVATE KEY-----\n" +GOOGLE_SHEETS_ID=1iA2GvWeUxX-vpnAMfVm3ZMG9LhaC070SdGssEcXAh2c + +# LLM APIs - EXTRAITES DE LLMManager.js +# ⚠️ SÉCURITÉ: Ces clés étaient hardcodées dans votre code ! +OPENAI_API_KEY=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 + +ANTHROPIC_API_KEY=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 +CLAUDE_API_KEY=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 + +GEMINI_API_KEY=AIzaSyAMzmIGbW5nJlBG5Qyr35sdjb3U2bIBtoE +GOOGLE_API_KEY=AIzaSyAMzmIGbW5nJlBG5Qyr35sdjb3U2bIBtoE + +DEEPSEEK_API_KEY=sk-6e02bc9513884bb8b92b9920524e17b5 + +MOONSHOT_API_KEY=sk-zU9gyNkux2zcsj61cdKfztuP1Jozr6lFJ9viUJRPD8p8owhL + +MISTRAL_API_KEY=wESikMCIuixajSH8WHCiOV2z5sevgmVF + +# CONFIGURATION LOGGING +LOG_LEVEL=INFO +NODE_ENV=development +ENABLE_FILE_LOG=true +ENABLE_CONSOLE_LOG=true +ENABLE_SHEETS_LOGGING=false + +# DIGITALOCEAN SPACES - EXTRAITES DE DigitalOceanWorkflow.js +# ⚠️ Ces credentials étaient hardcodées dans votre code ! +DO_ENDPOINT=https://autocollant.fra1.digitaloceanspaces.com +DO_BUCKET_NAME=autocollant +DO_ACCESS_KEY_ID=DO801XTYPE968NZGAQM3 +DO_SECRET_ACCESS_KEY=5aCCBiS9K+J8gsAe3M3/0GlliHCNjtLntwla1itCN1s +DO_REGION=fra1 +DO_SPACES_BUCKET=autocollant + +# EMAIL (optionnel - pour ErrorReporting.js) +EMAIL_USER=your-email@gmail.com +EMAIL_APP_PASSWORD=your_app_password + +# Configuration supplémentaire pour les tests +MAX_COST_PER_ARTICLE=1.00 +TRACE_PATH=logs/trace.log \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 661a524..22f60a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,14 +8,23 @@ Building a **bulletproof modular system** with strict separation of concerns using vanilla JavaScript, HTML, and CSS. The architecture enforces inviolable responsibility patterns with sealed modules and dependency injection. ### 🏗️ Architecture Status -**PHASE 1 COMPLETED ✅** - Core foundation built with rigorous architectural patterns: -- **Module.js** - Abstract base class with WeakMap privates and sealed instances -- **EventBus.js** - Strict event system with validation and module registration -- **ModuleLoader.js** - Dependency injection with proper initialization order -- **Router.js** - Navigation with guards, middleware, and state management -- **Application.js** - Auto-bootstrap system with lifecycle management -- **Development Server** - HTTP server with ES6 modules and CORS support +**PHASE 1 COMPLETED ✅** - Core foundation built with rigorous architectural patterns: +- ✅ **Module.js** - Abstract base class with WeakMap privates and sealed instances +- ✅ **EventBus.js** - Strict event system with validation and module registration +- ✅ **ModuleLoader.js** - Dependency injection with proper initialization order +- ✅ **Router.js** - Navigation with guards, middleware, and state management +- ✅ **Application.js** - Auto-bootstrap system with lifecycle management +- ✅ **Development Server** - HTTP server with ES6 modules and CORS support + +**DRS SYSTEM COMPLETED ✅** - Advanced learning modules with AI integration: +- ✅ **TextModule** - Reading comprehension exercises with AI text analysis +- ✅ **AudioModule** - Listening exercises with AI audio comprehension +- ✅ **ImageModule** - Visual comprehension with AI vision analysis +- ✅ **GrammarModule** - Grammar exercises with AI linguistic analysis +- ✅ **AI Integration** - OpenAI → DeepSeek → Disable fallback system +- ✅ **Persistent Storage** - Progress tracking with timestamps and metadata +- ✅ **Data Merge System** - Local/external data synchronization ## 🔥 Critical Requirements @@ -34,6 +43,13 @@ Building a **bulletproof modular system** with strict separation of concerns usi - **Modular CSS** - Component-scoped styling - **Event-Driven** - No direct module coupling +### UI/UX Design Principles (CRITICAL) +- **NO SCROLL POLICY** - All interfaces MUST fit within viewport height without scrolling +- **Height Management** - Vertical space is precious, horizontal space is abundant +- **Compact Navigation** - Top bars and headers must be minimal height +- **Responsive Layout** - Use available width, preserve viewport height +- **Mobile-First** - Design for smallest screens first, then scale up + ## 🚀 Development Workflow ### Starting the System @@ -173,10 +189,10 @@ window.app.getCore().router.navigate('/games') ## 🚧 Next Development Phase ### Immediate Tasks (PHASE 2) -1. **Component-based UI System** - Reusable UI components with scoped CSS -2. **Example Game Module** - Simple memory game to validate architecture -3. **Content System Integration** - Port content loading from Legacy -4. **Testing Framework** - Validate module contracts and event flow +1. ❌ **Component-based UI System** - Reusable UI components with scoped CSS +2. ❌ **Example Game Module** - Simple memory game to validate architecture +3. ❌ **Content System Integration** - Port content loading from Legacy +4. ❌ **Testing Framework** - Validate module contracts and event flow ### Known Legacy Issues to Fix 31 bug fixes and improvements from the old system: @@ -212,6 +228,8 @@ The system provides explicit error messages for violations: - ✅ Call `_setInitialized()` after successful init - ✅ Use private methods with underscore prefix - ✅ Seal objects to prevent modification +- ✅ **Start with simple solutions first** - Test basic functionality before adding complexity +- ✅ **Test code in console first** - Validate logic with quick console tests before file changes ### DON'Ts - ❌ Never access another module's internals directly @@ -219,6 +237,23 @@ The system provides explicit error messages for violations: - ❌ Never modify Module base class or core system - ❌ Never skip dependency validation - ❌ Never use file:// protocol (always use HTTP server) +- ❌ **NEVER HARDCODE JSON PATHS** - Always use dynamic paths based on selected book/chapter +- ❌ **Never overcomplicate positioning logic** - Use simple CSS transforms (translate(-50%, -50%)) for centering before complex calculations + +## 🧠 Problem-Solving Best Practices + +### UI Positioning Issues +1. **Start Simple**: Use basic CSS positioning (center with transform) first +2. **Test in Console**: Validate positioning logic with `console.log` and direct DOM manipulation +3. **Check Scope**: Ensure variables like `contentLoader` are globally accessible when needed +4. **Cache-bust**: Add `?v=2` to CSS/JS files when browser cache causes issues +5. **Verify Real Dimensions**: Use `getBoundingClientRect()` only when basic centering fails + +### Debugging Workflow +1. **Console First**: Test functions directly in browser console before modifying files +2. **Log Everything**: Add extensive logging to understand execution flow +3. **One Change at a Time**: Make incremental changes and test each step +4. **Simple Solutions Win**: Prefer `left: 50%; transform: translateX(-50%)` over complex calculations ## 🎯 Success Metrics @@ -259,4 +294,244 @@ The `Legacy/` folder contains the complete old system. Key architectural changes --- +## 📋 COMPREHENSIVE TEST CHECKLIST + +### 🏗️ Architecture Tests + +#### Core System Tests +- [ ] **Module.js Tests** + - [ ] Abstract class cannot be instantiated directly + - [ ] WeakMap private data is truly private + - [ ] Object.seal() prevents modification + - [ ] Lifecycle methods work correctly (init, destroy) + - [ ] Validation methods throw appropriate errors + +- [ ] **EventBus.js Tests** + - [ ] Event registration and deregistration + - [ ] Module validation before event usage + - [ ] Event history tracking + - [ ] Cross-module communication isolation + - [ ] Memory leak prevention on module destroy + +- [ ] **ModuleLoader.js Tests** + - [ ] Dependency injection order + - [ ] Circular dependency detection + - [ ] Module initialization sequence + - [ ] Error handling for missing dependencies + - [ ] Module unloading and cleanup + +- [ ] **Router.js Tests** + - [ ] Navigation guards functionality + - [ ] Middleware execution order + - [ ] State management + - [ ] URL parameter handling + - [ ] History management + +- [ ] **Application.js Tests** + - [ ] Auto-bootstrap system + - [ ] Lifecycle management + - [ ] Module registration + - [ ] Error recovery + - [ ] Debug panel functionality + +### 🎮 DRS System Tests + +#### Module Interface Tests +- [ ] **ExerciseModuleInterface Tests** + - [ ] All required methods implemented + - [ ] Method signatures correct + - [ ] Error throwing for abstract methods + +#### Individual Module Tests +- [ ] **TextModule Tests** + - [ ] Text loading and display + - [ ] Question generation/extraction + - [ ] AI validation with fallback + - [ ] Progress tracking + - [ ] UI interaction (buttons, inputs) + - [ ] Viewing time tracking + - [ ] Results calculation + +- [ ] **AudioModule Tests** + - [ ] Audio playback controls + - [ ] Playback counting + - [ ] Transcript reveal timing + - [ ] AI audio analysis + - [ ] Progress tracking + - [ ] Penalty system for excessive playbacks + +- [ ] **ImageModule Tests** + - [ ] Image loading and display + - [ ] Zoom functionality + - [ ] Observation time tracking + - [ ] AI vision analysis + - [ ] Question types (description, details, interpretation) + - [ ] Progress tracking + +- [ ] **GrammarModule Tests** + - [ ] Rule explanation display + - [ ] Exercise type variety (fill-blank, correction, etc.) + - [ ] Hint system + - [ ] Attempt tracking + - [ ] AI grammar analysis + - [ ] Scoring with penalties/bonuses + +### 🤖 AI Integration Tests + +#### AI Provider Tests +- [ ] **OpenAI Integration** + - [ ] API connectivity test + - [ ] Response format validation + - [ ] Error handling + - [ ] Timeout management + +- [ ] **DeepSeek Integration** + - [ ] API connectivity test + - [ ] Fallback from OpenAI + - [ ] Response format validation + - [ ] Error handling + +- [ ] **AI Fallback System** + - [ ] Provider switching logic + - [ ] Graceful degradation to basic validation + - [ ] Status tracking and reporting + - [ ] Recovery mechanisms + +#### Response Parsing Tests +- [ ] **Structured Response Parsing** + - [ ] [answer]yes/no extraction + - [ ] [explanation] extraction + - [ ] Error handling for malformed responses + - [ ] Multiple format support + +### 💾 Data Persistence Tests + +#### Progress Tracking Tests +- [ ] **Mastery Tracking** + - [ ] Timestamp recording + - [ ] Metadata storage + - [ ] Progress calculation + - [ ] Persistent storage integration + +- [ ] **Data Merge System** + - [ ] Local vs external data merging + - [ ] Conflict resolution strategies + - [ ] Import/export functionality + - [ ] Data integrity validation + +### 🎨 UI/UX Tests + +#### Design Principles Tests +- [ ] **No Scroll Policy** + - [ ] All interfaces fit viewport height + - [ ] Responsive breakpoint testing + - [ ] Mobile viewport compliance + +- [ ] **Responsive Design** + - [ ] Mobile-first approach validation + - [ ] Horizontal space utilization + - [ ] Vertical space conservation + +#### Component Tests +- [ ] **Button Interactions** + - [ ] Hover effects + - [ ] Disabled states + - [ ] Click handlers + - [ ] Loading states + +- [ ] **Form Controls** + - [ ] Input validation + - [ ] Error display + - [ ] Accessibility compliance + - [ ] Keyboard navigation + +### 🌐 Network & Server Tests + +#### Development Server Tests +- [ ] **ES6 Modules Support** + - [ ] Import/export functionality + - [ ] MIME type handling + - [ ] CORS configuration + +- [ ] **Caching Strategy** + - [ ] Assets cached correctly + - [ ] HTML not cached for development + - [ ] Cache invalidation + +- [ ] **Error Handling** + - [ ] 404 page display + - [ ] Graceful error recovery + - [ ] Error message clarity + +### 🔄 Integration Tests + +#### End-to-End Scenarios +- [ ] **Complete Exercise Flow** + - [ ] Module loading + - [ ] Exercise presentation + - [ ] User interaction + - [ ] AI validation + - [ ] Progress saving + - [ ] Results display + +- [ ] **Multi-Module Navigation** + - [ ] Module switching + - [ ] State preservation + - [ ] Memory cleanup + +- [ ] **Data Persistence Flow** + - [ ] Progress tracking across sessions + - [ ] Data export/import + - [ ] Sync functionality + +### ⚡ Performance Tests + +#### Loading Performance +- [ ] **Module Loading Times** + - [ ] <100ms module loading + - [ ] <50ms event propagation + - [ ] <200ms application startup + +#### Memory Management +- [ ] **Memory Leaks** + - [ ] Module cleanup verification + - [ ] Event listener removal + - [ ] DOM element cleanup + +### 🔒 Security Tests + +#### Module Isolation Tests +- [ ] **Private State Protection** + - [ ] WeakMap data inaccessible + - [ ] Sealed object modification prevention + - [ ] Cross-module boundary enforcement + +#### Input Validation Tests +- [ ] **Boundary Validation** + - [ ] All inputs validated + - [ ] Error messages for violations + - [ ] Malicious input handling + +### 🎯 TESTING PRIORITY + +#### **HIGH PRIORITY** (Core System) +1. Module.js lifecycle and sealing tests +2. EventBus communication isolation +3. ModuleLoader dependency injection +4. Basic DRS module functionality + +#### **MEDIUM PRIORITY** (Integration) +1. AI provider fallback system +2. Data persistence and merging +3. UI/UX compliance tests +4. End-to-end exercise flows + +#### **LOW PRIORITY** (Polish) +1. Performance benchmarks +2. Advanced security tests +3. Edge case scenarios +4. Browser compatibility + +--- + **This is a high-quality, maintainable system built for educational software that will scale.** \ No newline at end of file diff --git a/LLMManager.js b/LLMManager.js new file mode 100644 index 0000000..55c99d2 --- /dev/null +++ b/LLMManager.js @@ -0,0 +1,545 @@ +// ======================================== +// FICHIER: LLMManager.js +// Description: Hub central pour tous les appels LLM (Version Node.js) +// Support: Claude, OpenAI, Gemini, Deepseek, Moonshot, Mistral +// ======================================== + +const fetch = globalThis.fetch.bind(globalThis); +const { logSh } = require('./ErrorReporting'); + +// Charger les variables d'environnement +require('dotenv').config(); + +// ============= CONFIGURATION CENTRALISÉE ============= + +const LLM_CONFIG = { + openai: { + apiKey: process.env.OPENAI_API_KEY, + endpoint: 'https://api.openai.com/v1/chat/completions', + model: 'gpt-4o-mini', + headers: { + 'Authorization': 'Bearer {API_KEY}', + 'Content-Type': 'application/json' + }, + temperature: 0.7, + timeout: 300000, // 5 minutes + retries: 3 + }, + + claude: { + apiKey: process.env.ANTHROPIC_API_KEY, + endpoint: 'https://api.anthropic.com/v1/messages', + model: 'claude-sonnet-4-20250514', + headers: { + 'x-api-key': '{API_KEY}', + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01' + }, + temperature: 0.7, + maxTokens: 6000, + timeout: 300000, // 5 minutes + retries: 6 + }, + + deepseek: { + apiKey: process.env.DEEPSEEK_API_KEY, + endpoint: 'https://api.deepseek.com/v1/chat/completions', + model: 'deepseek-chat', + headers: { + 'Authorization': 'Bearer {API_KEY}', + 'Content-Type': 'application/json' + }, + temperature: 0.7, + timeout: 300000, // 5 minutes + retries: 3 + }, + + moonshot: { + apiKey: process.env.MOONSHOT_API_KEY, + endpoint: 'https://api.moonshot.ai/v1/chat/completions', + model: 'moonshot-v1-32k', + headers: { + 'Authorization': 'Bearer {API_KEY}', + 'Content-Type': 'application/json' + }, + temperature: 0.7, + timeout: 300000, // 5 minutes + retries: 3 + }, + + mistral: { + apiKey: process.env.MISTRAL_API_KEY, + endpoint: 'https://api.mistral.ai/v1/chat/completions', + model: 'mistral-small-latest', + headers: { + 'Authorization': 'Bearer {API_KEY}', + 'Content-Type': 'application/json' + }, + max_tokens: 5000, + temperature: 0.7, + timeout: 300000, // 5 minutes + retries: 3 + } +}; + +// Alias pour compatibilité avec le code existant +LLM_CONFIG.gpt4 = LLM_CONFIG.openai; + +// ============= HELPER FUNCTIONS ============= + +const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); + +// ============= INTERFACE UNIVERSELLE ============= + +/** + * Fonction principale pour appeler n'importe quel LLM + * @param {string} llmProvider - claude|openai|deepseek|moonshot|mistral + * @param {string} prompt - Le prompt à envoyer + * @param {object} options - Options personnalisées (température, tokens, etc.) + * @param {object} personality - Personnalité pour contexte système + * @returns {Promise} - Réponse générée + */ +async function callLLM(llmProvider, prompt, options = {}, personality = null) { + const startTime = Date.now(); + + try { + // Vérifier si le provider existe + if (!LLM_CONFIG[llmProvider]) { + throw new Error(`Provider LLM inconnu: ${llmProvider}`); + } + + // Vérifier si l'API key est configurée + const config = LLM_CONFIG[llmProvider]; + if (!config.apiKey || config.apiKey.startsWith('VOTRE_CLE_')) { + throw new Error(`Clé API manquante pour ${llmProvider}`); + } + + logSh(`🤖 Appel LLM: ${llmProvider.toUpperCase()} (${config.model}) | Personnalité: ${personality?.nom || 'aucune'}`, 'DEBUG'); + + // 📢 AFFICHAGE PROMPT COMPLET POUR DEBUG AVEC INFO IA + logSh(`\n🔍 ===== PROMPT ENVOYÉ À ${llmProvider.toUpperCase()} (${config.model}) | PERSONNALITÉ: ${personality?.nom || 'AUCUNE'} =====`, 'PROMPT'); + logSh(prompt, 'PROMPT'); + + // 📤 LOG LLM REQUEST COMPLET + logSh(`📤 LLM REQUEST [${llmProvider.toUpperCase()}] (${config.model}) | Personnalité: ${personality?.nom || 'AUCUNE'}`, 'LLM'); + logSh(prompt, 'LLM'); + + // Préparer la requête selon le provider + const requestData = buildRequestData(llmProvider, prompt, options, personality); + + // Effectuer l'appel avec retry logic + const response = await callWithRetry(llmProvider, requestData, config); + + // Parser la réponse selon le format du provider + const content = parseResponse(llmProvider, response); + + // 📥 LOG LLM RESPONSE COMPLET + logSh(`📥 LLM RESPONSE [${llmProvider.toUpperCase()}] (${config.model}) | Durée: ${Date.now() - startTime}ms`, 'LLM'); + logSh(content, 'LLM'); + + const duration = Date.now() - startTime; + logSh(`✅ ${llmProvider.toUpperCase()} (${personality?.nom || 'sans personnalité'}) réponse en ${duration}ms`, 'INFO'); + + // Enregistrer les stats d'usage + await recordUsageStats(llmProvider, prompt.length, content.length, duration); + + return content; + + } catch (error) { + const duration = Date.now() - startTime; + logSh(`❌ Erreur ${llmProvider.toUpperCase()} (${personality?.nom || 'sans personnalité'}): ${error.toString()}`, 'ERROR'); + + // Enregistrer l'échec + await recordUsageStats(llmProvider, prompt.length, 0, duration, error.toString()); + + throw error; + } +} + +// ============= CONSTRUCTION DES REQUÊTES ============= + +function buildRequestData(provider, prompt, options, personality) { + const config = LLM_CONFIG[provider]; + const temperature = options.temperature || config.temperature; + const maxTokens = options.maxTokens || config.maxTokens; + + // Construire le système prompt si personnalité fournie + const systemPrompt = personality ? + `Tu es ${personality.nom}. ${personality.description}. Style: ${personality.style}` : + 'Tu es un assistant expert.'; + + switch (provider) { + case 'openai': + case 'gpt4': + case 'deepseek': + case 'moonshot': + case 'mistral': + return { + model: config.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: prompt } + ], + max_tokens: maxTokens, + temperature: temperature, + stream: false + }; + + case 'claude': + return { + model: config.model, + max_tokens: maxTokens, + temperature: temperature, + system: systemPrompt, + messages: [ + { role: 'user', content: prompt } + ] + }; + + + default: + throw new Error(`Format de requête non supporté pour ${provider}`); + } +} + +// ============= APPELS AVEC RETRY ============= + +async function callWithRetry(provider, requestData, config) { + let lastError; + + for (let attempt = 1; attempt <= config.retries; attempt++) { + try { + logSh(`🔄 Tentative ${attempt}/${config.retries} pour ${provider.toUpperCase()}`, 'DEBUG'); + + // Préparer les headers avec la clé API + const headers = {}; + Object.keys(config.headers).forEach(key => { + headers[key] = config.headers[key].replace('{API_KEY}', config.apiKey); + }); + + // URL standard + let url = config.endpoint; + + const options = { + method: 'POST', + headers: headers, + body: JSON.stringify(requestData), + timeout: config.timeout + }; + + const response = await fetch(url, options); + const responseText = await response.text(); + + if (response.ok) { + return JSON.parse(responseText); + } else if (response.status === 429) { + // Rate limiting - attendre plus longtemps + const waitTime = Math.pow(2, attempt) * 1000; // Exponential backoff + logSh(`⏳ Rate limit ${provider.toUpperCase()}, attente ${waitTime}ms`, 'WARNING'); + await sleep(waitTime); + continue; + } else { + throw new Error(`HTTP ${response.status}: ${responseText}`); + } + + } catch (error) { + lastError = error; + + if (attempt < config.retries) { + const waitTime = 1000 * attempt; + logSh(`⚠ Erreur tentative ${attempt}: ${error.toString()}, retry dans ${waitTime}ms`, 'WARNING'); + await sleep(waitTime); + } + } + } + + throw new Error(`Échec après ${config.retries} tentatives: ${lastError.toString()}`); +} + +// ============= PARSING DES RÉPONSES ============= + +function parseResponse(provider, responseData) { + try { + switch (provider) { + case 'openai': + case 'gpt4': + case 'deepseek': + case 'moonshot': + case 'mistral': + return responseData.choices[0].message.content.trim(); + + case 'claude': + return responseData.content[0].text.trim(); + + default: + throw new Error(`Parser non supporté pour ${provider}`); + } + } catch (error) { + logSh(`❌ Erreur parsing ${provider}: ${error.toString()}`, 'ERROR'); + logSh(`Response brute: ${JSON.stringify(responseData)}`, 'DEBUG'); + throw new Error(`Impossible de parser la réponse ${provider}: ${error.toString()}`); + } +} + +// ============= GESTION DES STATISTIQUES ============= + +async function recordUsageStats(provider, promptTokens, responseTokens, duration, error = null) { + try { + // TODO: Adapter selon votre système de stockage Node.js + // Peut être une base de données, un fichier, MongoDB, etc. + const statsData = { + timestamp: new Date(), + provider: provider, + model: LLM_CONFIG[provider].model, + promptTokens: promptTokens, + responseTokens: responseTokens, + duration: duration, + error: error || '' + }; + + // Exemple: log vers console ou fichier + logSh(`📊 Stats: ${JSON.stringify(statsData)}`, 'DEBUG'); + + // TODO: Implémenter sauvegarde réelle (DB, fichier, etc.) + + } catch (statsError) { + // Ne pas faire planter le workflow si les stats échouent + logSh(`⚠ Erreur enregistrement stats: ${statsError.toString()}`, 'WARNING'); + } +} + +// ============= FONCTIONS UTILITAIRES ============= + +/** + * Tester la connectivité de tous les LLMs + */ +async function testAllLLMs() { + const testPrompt = "Dis bonjour en 5 mots maximum."; + const results = {}; + + const allProviders = Object.keys(LLM_CONFIG); + + for (const provider of allProviders) { + try { + logSh(`🧪 Test ${provider}...`, 'INFO'); + + const response = await callLLM(provider, testPrompt); + results[provider] = { + status: 'SUCCESS', + response: response, + model: LLM_CONFIG[provider].model + }; + + } catch (error) { + results[provider] = { + status: 'ERROR', + error: error.toString(), + model: LLM_CONFIG[provider].model + }; + } + + // Petit délai entre tests + await sleep(500); + } + + logSh(`📊 Tests terminés: ${JSON.stringify(results, null, 2)}`, 'INFO'); + return results; +} + +/** + * Obtenir les providers disponibles (avec clés API valides) + */ +function getAvailableProviders() { + const available = []; + + Object.keys(LLM_CONFIG).forEach(provider => { + const config = LLM_CONFIG[provider]; + if (config.apiKey && !config.apiKey.startsWith('VOTRE_CLE_')) { + available.push(provider); + } + }); + + return available; +} + +/** + * Obtenir des statistiques d'usage par provider + */ +async function getUsageStats() { + try { + // TODO: Adapter selon votre système de stockage + // Pour l'instant retourne un message par défaut + return { message: 'Statistiques non implémentées en Node.js' }; + + } catch (error) { + return { error: error.toString() }; + } +} + +// ============= MIGRATION DE L'ANCIEN CODE ============= + +/** + * Fonction de compatibilité pour remplacer votre ancien callOpenAI() + * Maintient la même signature pour ne pas casser votre code existant + */ +async function callOpenAI(prompt, personality) { + return await callLLM('openai', prompt, {}, personality); +} + +// ============= EXPORTS POUR TESTS ============= + +/** + * Fonction de test rapide + */ +async function testLLMManager() { + logSh('🚀 Test du LLM Manager Node.js...', 'INFO'); + + // Test des providers disponibles + const available = getAvailableProviders(); + logSh('Providers disponibles: ' + available.join(', ') + ' (' + available.length + '/5)', 'INFO'); + + // Test d'appel simple sur chaque provider disponible + for (const provider of available) { + try { + logSh(`🧪 Test ${provider}...`, 'DEBUG'); + const startTime = Date.now(); + + const response = await callLLM(provider, 'Dis juste "Test OK"'); + const duration = Date.now() - startTime; + + logSh(`✅ Test ${provider} réussi: "${response}" (${duration}ms)`, 'INFO'); + + } catch (error) { + logSh(`❌ Test ${provider} échoué: ${error.toString()}`, 'ERROR'); + } + + // Petit délai pour éviter rate limits + await sleep(500); + } + + // Test spécifique OpenAI (compatibilité avec ancien code) + try { + logSh('🎯 Test spécifique OpenAI (compatibilité)...', 'DEBUG'); + const response = await callLLM('openai', 'Dis juste "Test OK"'); + logSh('✅ Test OpenAI compatibilité: ' + response, 'INFO'); + } catch (error) { + logSh('❌ Test OpenAI compatibilité échoué: ' + error.toString(), 'ERROR'); + } + + // Afficher les stats d'usage + try { + logSh('📊 Récupération statistiques d\'usage...', 'DEBUG'); + const stats = await getUsageStats(); + + if (stats.error) { + logSh('⚠ Erreur récupération stats: ' + stats.error, 'WARNING'); + } else if (stats.message) { + logSh('📊 Stats: ' + stats.message, 'INFO'); + } else { + // Formatter les stats pour les logs + Object.keys(stats).forEach(provider => { + const s = stats[provider]; + logSh(`📈 ${provider}: ${s.calls} appels, ${s.successRate}% succès, ${s.avgDuration}ms moyen`, 'INFO'); + }); + } + } catch (error) { + logSh('❌ Erreur lors de la récupération des stats: ' + error.toString(), 'ERROR'); + } + + // Résumé final + const workingCount = available.length; + const totalProviders = Object.keys(LLM_CONFIG).length; + + if (workingCount === totalProviders) { + logSh(`✅ Test LLM Manager COMPLET: ${workingCount}/${totalProviders} providers opérationnels`, 'INFO'); + } else if (workingCount >= 2) { + logSh(`✅ Test LLM Manager PARTIEL: ${workingCount}/${totalProviders} providers opérationnels (suffisant pour DNA Mixing)`, 'INFO'); + } else { + logSh(`❌ Test LLM Manager INSUFFISANT: ${workingCount}/${totalProviders} providers opérationnels (minimum 2 requis)`, 'ERROR'); + } + + logSh('🏁 Test LLM Manager terminé', 'INFO'); +} + +/** + * Version complète avec test de tous les providers (même non configurés) + */ +async function testLLMManagerComplete() { + logSh('🚀 Test COMPLET du LLM Manager (tous providers)...', 'INFO'); + + const allProviders = Object.keys(LLM_CONFIG); + logSh(`Providers configurés: ${allProviders.join(', ')}`, 'INFO'); + + const results = { + configured: 0, + working: 0, + failed: 0 + }; + + for (const provider of allProviders) { + const config = LLM_CONFIG[provider]; + + // Vérifier si configuré + if (!config.apiKey || config.apiKey.startsWith('VOTRE_CLE_')) { + logSh(`⚙️ ${provider}: NON CONFIGURÉ (clé API manquante)`, 'WARNING'); + continue; + } + + results.configured++; + + try { + logSh(`🧪 Test ${provider} (${config.model})...`, 'DEBUG'); + const startTime = Date.now(); + + const response = await callLLM(provider, 'Réponds "OK" seulement.', { maxTokens: 100 }); + const duration = Date.now() - startTime; + + results.working++; + logSh(`✅ ${provider}: "${response.trim()}" (${duration}ms)`, 'INFO'); + + } catch (error) { + results.failed++; + logSh(`❌ ${provider}: ${error.toString()}`, 'ERROR'); + } + + // Délai entre tests + await sleep(700); + } + + // Résumé final complet + logSh(`📊 RÉSUMÉ FINAL:`, 'INFO'); + logSh(` • Providers total: ${allProviders.length}`, 'INFO'); + logSh(` • Configurés: ${results.configured}`, 'INFO'); + logSh(` • Fonctionnels: ${results.working}`, 'INFO'); + logSh(` • En échec: ${results.failed}`, 'INFO'); + + const status = results.working >= 4 ? 'EXCELLENT' : + results.working >= 2 ? 'BON' : 'INSUFFISANT'; + + logSh(`🏆 STATUS: ${status} (${results.working} LLMs opérationnels)`, + status === 'INSUFFISANT' ? 'ERROR' : 'INFO'); + + logSh('🏁 Test LLM Manager COMPLET terminé', 'INFO'); + + return { + total: allProviders.length, + configured: results.configured, + working: results.working, + failed: results.failed, + status: status + }; +} + +// ============= EXPORTS MODULE ============= + +module.exports = { + callLLM, + callOpenAI, + testAllLLMs, + getAvailableProviders, + getUsageStats, + testLLMManager, + testLLMManagerComplete, + LLM_CONFIG +}; + diff --git a/SMART_PREVIEW_SPECS.md b/SMART_PREVIEW_SPECS.md new file mode 100644 index 0000000..9382230 --- /dev/null +++ b/SMART_PREVIEW_SPECS.md @@ -0,0 +1,521 @@ +# Smart Preview System - Technical Specifications + +## 🎯 System Overview + +**Smart Preview** is an intelligent course preparation system that provides systematic content review with LLM-powered validation. Students can preview and validate their understanding of entire chapter content through adaptive exercises with prerequisite-based progression. + +## 🏗️ Dynamic Modular Architecture + +### Orchestrator + Semi-Independent Modules + +**Core Philosophy**: Dynamic class system with an orchestrator that pilots semi-independent exercise modules. Each module can be loaded, unloaded, and controlled dynamically. + +#### **SmartPreviewOrchestrator.js** - Main Controller +- Extends Module base class +- Manages dynamic loading/unloading of exercise modules +- Handles exercise sequencing and variation patterns +- Coordinates shared services (LLM, Prerequisites) +- Tracks overall progress and session state + +#### **Semi-Independent Exercise Modules** +1. **VocabularyModule.js** - Vocabulary exercises (groups of 5) +2. **VocabExamModule.js** - AI-verified comprehensive vocabulary exam system +3. **PhraseModule.js** - Individual phrase comprehension +4. **TextModule.js** - Sentence-by-sentence text processing +5. **AudioModule.js** - Audio comprehension exercises +6. **ImageModule.js** - Image description exercises +7. **GrammarModule.js** - Grammar construction and validation + +#### **Shared Service Modules** +- **LLMValidator.js** - LLM integration for all exercise types +- **PrerequisiteEngine.js** - Dependency tracking and content filtering +- **ContextMemory.js** - Progressive context building across exercises + +### Module Interface Standard +```javascript +// All exercise modules must implement this interface +class ExerciseModuleInterface { + // Check if module can run with current prerequisites + canRun(prerequisites, chapterContent): boolean; + + // Present exercise UI and content + present(container, exerciseData): Promise; + + // Validate user input with LLM + validate(userInput, context): Promise; + + // Get current progress data + getProgress(): ProgressData; + + // Clean up and prepare for unloading + cleanup(): void; +} +``` + +### Dynamic Loading System +```javascript +// Orchestrator can dynamically control modules +orchestrator.loadModule('vocabulary'); +orchestrator.switchTo('phrase', prerequisites); +orchestrator.unloadModule('text'); +orchestrator.getAvailableModules(currentPrerequisites); + +// Module lifecycle management +module.canRun(prereqs) → module.present() → module.validate() → module.cleanup() +``` + +### Module Dependencies +```javascript +// Orchestrator dependencies +dependencies: ['eventBus', 'contentLoader'] + +// Each exercise module receives shared services via injection +constructor(orchestrator, llmValidator, prerequisiteEngine, contextMemory) +``` + +## 📋 Functional Requirements + +### 1. Systematic Review by Content Type + +#### Vocabulary Review (Groups of 5) +- Display 5 vocabulary words with translations +- Test recognition/translation with LLM validation +- Must achieve mastery (80%+) before next group +- Track learned vocabulary for prerequisite system + +#### Vocabulary Exam System (AI-Verified Comprehensive Assessment) +- **Batch Testing Approach**: Present 15-20 vocabulary questions in sequence, collect all answers, then send complete batch to AI for evaluation +- **Multiple Question Types**: + - Translation (L1→L2 and L2→L1) + - Definition matching and explanation + - Context usage (fill-in-the-blank with sentences) + - Synonym/antonym identification + - Audio recognition (if audio available) +- **AI-Powered Batch Evaluation**: + - Single API call evaluates entire batch for efficiency + - Cross-question consistency checking within batch + - Semantic understanding over exact word matching + - Context-aware validation for multiple correct answers + - Partial credit for near-correct responses + - Intelligent feedback for incorrect answers +- **Batch Processing Benefits**: + - Reduced API costs and latency + - Consistent evaluation across question set + - Better pattern recognition for student strengths/weaknesses +- **Adaptive Difficulty**: Next batch adapts based on previous batch performance +- **Mastery Certification**: Must achieve 85%+ overall score to pass +- **Spaced Repetition Integration**: Failed words automatically added to review queue +- **Progress Analytics**: Detailed breakdown by word, question type, and difficulty level + +#### Phrase Comprehension (Individual) +- Present single phrases for translation/comprehension +- LLM evaluation for semantic accuracy (not exact matching) +- Context-aware validation considering multiple correct answers + +#### Text Processing (Sentence by Sentence) +- Break texts into individual sentences +- Progressive context building (sentence 1 → sentence 1+2 → sentence 1+2+3) +- Each sentence validated with accumulated context +- Prerequisites: only texts containing learned vocabulary words + +#### Audio Comprehension +- Play audio segments with transcription for LLM reference +- User provides comprehension in their own words +- LLM evaluates understanding against transcription + +#### Image Description +- Present images for description exercises +- LLM evaluates vocabulary usage and accuracy of description +- No exact matching required - intelligent semantic evaluation + +#### Grammar Exercises +- Present grammar-focused exercises (sentence construction, tense usage, etc.) +- LLM evaluates grammar correctness and natural language flow +- Context-aware grammar validation considering multiple correct structures +- Track grammar concept mastery (present tense, articles, sentence structure, etc.) + +### 2. Prerequisite Dependency System + +#### Smart Filtering +```javascript +// Only chapter vocabulary words are prerequisites +chapterVocab = ['shirt', 'coat', 'blue', 'work', 'wear']; + +// Basic words assumed known +assumedKnown = ['the', 'is', 'a', 'to', 'in', 'on', 'at', ...]; + +// Dependency extraction +function getPrerequisites(content, chapterVocabulary) { + const words = extractWords(content); + return words.filter(word => + chapterVocabulary.includes(word) || + chapterVocabulary.includes(getBaseForm(word)) + ); +} +``` + +#### Progressive Unlocking +- Vocabulary groups unlock based on completion of previous groups +- Phrases unlock when ALL required chapter words are learned +- Text sentences unlock progressively with vocabulary mastery +- Real-time content availability updates + +### 3. Exercise Variation Engine + +#### Dynamic Pattern Generation +```javascript +const exercisePattern = [ + 'vocabulary-5', // First 5 vocab words + 'single-phrase', // Available phrase with learned words + 'vocabulary-5', // Next 5 vocab words + 'text-sentence-1', // First sentence of available text + 'vocabulary-5', // Continue vocabulary + 'vocab-exam-checkpoint', // Comprehensive exam after 15-20 learned words + 'text-sentence-2-ctx', // Second sentence with context from first + 'image-description', // Image exercise + 'audio-comprehension', // Audio exercise + 'vocab-exam-final', // Final comprehensive exam at chapter end + // Pattern continues... +]; + +// Vocabulary Exam Trigger Logic +const examTriggers = { + checkpoint: { + condition: 'wordsLearned >= 15 && wordsLearned % 15 === 0', + examType: 'checkpoint', + wordsToTest: 'last15Learned', + passingScore: 80 + }, + final: { + condition: 'chapterComplete', + examType: 'comprehensive', + wordsToTest: 'allChapterWords', + passingScore: 85 + }, + remedial: { + condition: 'examFailed', + examType: 'focused', + wordsToTest: 'failedWords', + passingScore: 75 + } +}; +``` + +#### Context Memory System +- Store user responses for progressive exercises +- Build context for text comprehension exercises +- Pass accumulated context to LLM for evaluation +- Reset context appropriately between different texts/topics + +### 4. LLM Integration + +#### Evaluation Prompts +```javascript +// Translation Validation +const translationPrompt = ` +Evaluate this language learning translation: +- Original (English): "${originalText}" +- Student translation (Chinese/French): "${userAnswer}" +- Context: ${exerciseType} exercise +- Previous context: ${contextHistory} + +Evaluate if the translation captures the essential meaning, even if not word-for-word exact. +Return JSON: { + score: 0-100, + correct: boolean, + feedback: "constructive feedback in user's language", + keyPoints: ["important vocabulary/grammar noted"] +} +`; + +// Vocabulary Exam Batch Validation +const vocabExamBatchPrompt = ` +Evaluate this vocabulary exam batch (15-20 questions): +- Language level: ${languageLevel} +- Total questions in batch: ${batchSize} + +Questions and responses: +${questionsArray.map((q, i) => ` +${i+1}. Question type: ${q.type} | Target word: "${q.targetWord}" + Expected: "${q.expectedAnswer}" + Student answer: "${q.userAnswer}" + Context: "${q.contextSentence || 'N/A'}" +`).join('')} + +For different question types: +- Translation: Accept semantic equivalents, consider cultural variations +- Definition: Accept paraphrasing that captures core meaning +- Context usage: Evaluate grammatical correctness and semantic appropriateness +- Synonyms: Accept close semantic relationships, not just exact synonyms + +Evaluate ALL questions in the batch and look for consistency patterns. + +Return JSON: { + batchScore: 0-100, + totalCorrect: number, + totalPartialCredit: number, + overallFeedback: "general performance summary", + questions: [ + { + questionId: number, + score: 0-100, + correct: boolean, + partialCredit: boolean, + feedback: "specific feedback for this question", + correctAlternatives: ["alternative answers if applicable"], + learningTip: "helpful tip for this word/concept" + } + // ... for each question in batch + ], + strengthAreas: ["areas where student performed well"], + weaknessAreas: ["areas needing improvement"], + recommendedActions: ["specific next steps for improvement"] +} +`; + +// Audio Comprehension Validation +const audioPrompt = ` +Evaluate audio comprehension: +- Audio transcription: "${transcription}" +- Student comprehension: "${userAnswer}" +- Language level: beginner/intermediate + +Did the student understand the main meaning? Accept paraphrasing and different expressions. +Return JSON: {score: 0-100, correct: boolean, feedback: "..."} +`; + +// Image Description Validation +const imagePrompt = ` +Evaluate image description: +- Student description: "${userAnswer}" +- Target vocabulary from chapter: ${chapterVocab} +- Exercise type: free description + +Evaluate vocabulary usage, accuracy of description, and language naturalness. +Return JSON: {score: 0-100, correct: boolean, feedback: "...", vocabularyUsed: []} +`; + +// Grammar Exercise Validation +const grammarPrompt = ` +Evaluate grammar usage: +- Exercise type: ${grammarType} (sentence construction, tense usage, etc.) +- Student response: "${userAnswer}" +- Target grammar concepts: ${grammarConcepts} +- Language level: ${languageLevel} + +Evaluate grammatical correctness, naturalness, and appropriate usage of target concepts. +Accept multiple correct variations but identify errors clearly. +Return JSON: { + score: 0-100, + correct: boolean, + feedback: "constructive grammar feedback", + grammarErrors: ["specific errors identified"], + grammarStrengths: ["correct usage noted"], + suggestion: "alternative correct formulation if needed" +} +`; +``` + +#### API Configuration +- Support multiple LLM providers (OpenAI, Claude, local Ollama) +- Fallback mechanisms for API failures +- Rate limiting and error handling +- Configurable temperature and model parameters + +### 5. Progress Tracking + +#### Mastery Validation +- Individual word mastery tracking (attempts, success rate, last reviewed) +- Phrase/sentence comprehension scores +- Grammar concept mastery tracking (tenses, sentence structure, etc.) +- **Vocabulary Exam Performance**: + - Checkpoint exam scores (every 15 words) + - Comprehensive exam certification status + - Question-type specific performance (translation, definition, context, etc.) + - Remedial exam tracking for failed words +- Overall chapter progress percentage +- Difficulty adaptation based on performance + +#### Visual Progress Indicators +- Progress bar showing overall completion +- Section-based progress (vocabulary: 45/60, phrases: 8/12, texts: 2/5, grammar: 6/8) +- Mastery indicators (✅ learned, 🟡 reviewing, ❌ needs work) +- Grammar concept tracking (present tense: ✅, articles: 🟡, word order: ❌) +- Time estimates for completion + +## 🎮 User Experience Flow + +### Session Start +1. Load chapter content and analyze prerequisites +2. Display progress overview and available exercises +3. Present first available exercise based on mastery state + +### Exercise Flow +1. **Present Content** - Show vocabulary/phrase/text/image/audio/grammar exercise +2. **User Interaction** - Input translation/description/comprehension/grammar construction +3. **LLM Validation** - Intelligent evaluation with feedback (including grammar analysis) +4. **Progress Update** - Update mastery tracking (vocabulary, grammar concepts, etc.) +5. **Next Exercise** - Dynamic selection based on progress and variation pattern + +### Adaptive Progression +- If user struggles (< 60% score): additional practice exercises +- If user excels (> 90% score): accelerated progression +- Smart retry system for failed exercises +- Prerequisite re-evaluation after each mastery update + +### Session End +- Progress summary with achievements +- Recommendations for next session +- Mastery gaps identified for focused review + +## 🔧 Technical Implementation + +### Dynamic Modular File Structure +``` +src/games/smart-preview/ +├── SmartPreviewOrchestrator.js # Main orchestrator module +├── exercise-modules/ # Semi-independent exercise modules +│ ├── VocabularyModule.js # Vocabulary exercises (groups of 5) +│ ├── VocabExamModule.js # AI-verified batch vocabulary exams +│ ├── PhraseModule.js # Individual phrase comprehension +│ ├── TextModule.js # Sentence-by-sentence processing +│ ├── AudioModule.js # Audio comprehension +│ ├── ImageModule.js # Image description +│ └── GrammarModule.js # Grammar construction +├── services/ # Shared service modules +│ ├── LLMValidator.js # LLM integration for all modules +│ ├── PrerequisiteEngine.js # Dependency tracking +│ └── ContextMemory.js # Progressive context building +├── interfaces/ +│ └── ExerciseModuleInterface.js # Standard interface for all modules +└── templates/ + ├── orchestrator.html # Main UI template + └── modules/ # Module-specific templates + ├── vocabulary.html + ├── vocab-exam.html # Batch exam interface + ├── phrase.html + ├── text.html + ├── audio.html + ├── image.html + └── grammar.html +``` + +### Dynamic Module Data Flow +``` +1. Orchestrator Initialization + └── Load shared services (LLM, Prerequisites, Context) + └── Analyze chapter content and prerequisites + └── Determine available exercise modules + +2. Module Loading & Sequencing + └── orchestrator.getAvailableModules(prerequisites) + └── orchestrator.loadModule(selectedType) + └── module.canRun(prerequisites) → boolean + +3. Exercise Execution + └── module.present(container, exerciseData) + └── user interaction & input capture + └── module.validate(userInput, context) → ValidationResult + +4. Dynamic Adaptation + └── Update mastery tracking + └── prerequisiteEngine.reevaluate(newMastery) + └── orchestrator.selectNextModule(progress, variation) + └── module.cleanup() → orchestrator.unloadModule() + +5. Module Lifecycle + canRun() → present() → validate() → cleanup() → unload/switch +``` + +### Integration Points + +#### With Existing System +- Extends Module base class architecture +- Uses EventBus for communication +- Integrates with existing content loading system +- Compatible with current routing system + +#### Content Requirements +- Chapter vocabulary lists with base forms +- Phrase/sentence collections with difficulty indicators +- Audio files with transcriptions +- Images with contextual information +- Text passages segmented by sentences + +### Performance Considerations +- Lazy loading of exercises and content +- LLM request caching for repeated validations +- Efficient prerequisite checking algorithms +- Minimal DOM manipulation for smooth UX + +## 🚀 Dynamic Implementation Strategy + +### **MVP Phase 1: Orchestrator + Vocabulary Module (Week 1)** +``` +1. SmartPreviewOrchestrator.js - Core controller + └── Module loading/unloading system + └── Basic exercise sequencing + └── Progress tracking foundation + +2. VocabularyModule.js - First exercise module + └── Groups of 5 vocabulary system + └── Basic LLM mock validation + └── Standard interface implementation + +3. Services Foundation + └── LLMValidator.js (mock responses initially) + └── PrerequisiteEngine.js (chapter vocab filtering) + └── ExerciseModuleInterface.js (standard contract) +``` + +### **Phase 2: Add Core Modules (Week 2)** +``` +4. PhraseModule.js - Individual phrase comprehension +5. TextModule.js - Sentence-by-sentence processing +6. Basic ContextMemory.js - Progressive context building +7. Real LLM integration (replace mocks) +``` + +### **Phase 3: Complete Module Set (Week 3)** +``` +8. AudioModule.js - Audio comprehension +9. ImageModule.js - Image description +10. GrammarModule.js - Grammar construction +11. Advanced prerequisite logic +12. Exercise variation patterns +``` + +### **Phase 4: Intelligence & Polish (Week 4)** +``` +13. Adaptive difficulty system +14. Advanced context memory +15. Performance optimization +16. Error handling and fallbacks +17. UI/UX refinements +``` + +### **Development Approach** +- **Modular Testing**: Each module independently testable +- **Progressive Enhancement**: Add modules incrementally +- **Interface-Driven**: All modules follow standard contract +- **Service Injection**: Shared services injected into modules +- **Dynamic Loading**: Modules loaded only when needed + +## 📊 Success Metrics + +### Learning Effectiveness +- User completion rates per session +- Average time to vocabulary mastery +- Retention rates in follow-up sessions +- User satisfaction scores + +### Technical Performance +- LLM response times (< 2 seconds) +- Exercise loading times (< 500ms) +- System uptime and error rates +- Content coverage completeness + +### Adaptive Accuracy +- Prerequisite system accuracy (avoiding impossible exercises) +- LLM validation consistency with human evaluation +- Progression appropriateness for user level \ No newline at end of file diff --git a/analyze-failures.js b/analyze-failures.js new file mode 100644 index 0000000..3ae5405 --- /dev/null +++ b/analyze-failures.js @@ -0,0 +1,26 @@ +import { runAllTests } from './src/testing/runTests.js'; + +async function analyzeFailures() { + console.log('Running test suite...'); + const results = await runAllTests(); + + console.log('\n=== DETAILED FAILURE ANALYSIS ==='); + + results.suites.forEach(suite => { + if (!suite.success && suite.result && suite.result.details) { + console.log(`\n${suite.suiteName}:`); + suite.result.details + .filter(test => test.state === 'failed') + .forEach(test => { + console.log(` ❌ ${test.name}`); + console.log(` Error: ${test.error?.message || 'Unknown'}`); + if (test.error?.stack) { + const stackLine = test.error.stack.split('\n')[0]; + console.log(` Stack: ${stackLine}`); + } + }); + } + }); +} + +analyzeFailures().catch(console.error); \ No newline at end of file diff --git a/assets/SBSBook.jpg b/assets/SBSBook.jpg new file mode 100644 index 0000000..2270772 Binary files /dev/null and b/assets/SBSBook.jpg differ diff --git a/assets/favicon.ico b/assets/favicon.ico new file mode 100644 index 0000000..e264ae9 --- /dev/null +++ b/assets/favicon.ico @@ -0,0 +1 @@ +data:image/svg+xml,C \ No newline at end of file diff --git a/caddy.exe b/caddy.exe new file mode 100644 index 0000000..8af9c16 Binary files /dev/null and b/caddy.exe differ diff --git a/content/books/sbs.json b/content/books/sbs.json new file mode 100644 index 0000000..1798524 --- /dev/null +++ b/content/books/sbs.json @@ -0,0 +1,51 @@ +{ + "id": "sbs", + "name": "Side by Side", + "description": "Side by Side English Learning Series - Complete course for intermediate learners", + "difficulty": "intermediate", + "language": "en-US", + "metadata": { + "version": "1.0", + "created": "2025-09-23", + "updated": "2025-09-23", + "source": "Side by Side English Learning Series", + "target_level": "intermediate", + "total_estimated_hours": 100, + "prerequisites": ["basic-english"], + "learning_objectives": [ + "Master intermediate vocabulary for daily situations", + "Understand grammar structures in context", + "Develop conversational skills", + "Practice reading and listening comprehension" + ], + "content_tags": ["vocabulary", "grammar", "conversation", "practical-english"], + "total_chapters": 12, + "available_chapters": ["7-8"], + "completion_criteria": { + "overall_progress": 80, + "chapters_completed": 8, + "vocabulary_mastery": 85 + } + }, + "chapters": [ + { + "id": "sbs-7-8", + "chapter_number": "7-8", + "name": "Daily Life & Vocabulary", + "description": "Master intermediate vocabulary for daily situations including clothing, body parts, emotions and technology", + "estimated_hours": 25, + "difficulty": "intermediate", + "prerequisites": ["sbs-5-6"], + "learning_objectives": [ + "Master intermediate vocabulary for daily situations", + "Understand clothing and body parts terminology", + "Learn emotional expressions and feelings", + "Practice technology and social media vocabulary" + ], + "vocabulary_count": 150, + "phrases_count": 45, + "dialogs_count": 8, + "exercises_count": 25 + } + ] +} \ No newline at end of file diff --git a/content/chapters/sbs-7-8.json b/content/chapters/sbs-7-8.json new file mode 100644 index 0000000..95e39a7 --- /dev/null +++ b/content/chapters/sbs-7-8.json @@ -0,0 +1,169 @@ +{ + "id": "sbs-7-8", + "book_id": "sbs", + "name": "Daily Life & Vocabulary", + "description": "Side by Side Level 7-8 vocabulary with language-agnostic format", + "difficulty": "intermediate", + "language": "en-US", + "chapter_number": "7-8", + "metadata": { + "version": "1.0", + "created": "2025-09-23", + "updated": "2025-09-23", + "source": "Side by Side English Learning Series", + "target_level": "intermediate", + "estimated_hours": 25, + "prerequisites": ["sbs-5-6"], + "learning_objectives": [ + "Master intermediate vocabulary for daily situations", + "Understand clothing and body parts terminology", + "Learn emotional expressions and feelings", + "Practice technology and social media vocabulary" + ], + "content_tags": ["vocabulary", "daily-life", "practical-english", "conversational"], + "completion_criteria": { + "vocabulary_mastery": 80, + "quiz_score": 75, + "games_completed": 5 + } + }, + "vocabulary": { + "central": { "user_language": "中心的;中央的", "type": "adjective", "pronunciation": "/ˈsentrəl/" }, + "avenue": { "user_language": "大街;林荫道", "type": "noun", "pronunciation": "/ˈævənjuː/" }, + "refrigerator": { "user_language": "冰箱", "type": "noun", "pronunciation": "/rɪˈfrɪdʒəreɪtər/" }, + "closet": { "user_language": "衣柜;壁橱", "type": "noun", "pronunciation": "/ˈklɒzɪt/" }, + "elevator": { "user_language": "电梯", "type": "noun", "pronunciation": "/ˈeləveɪtər/" }, + "building": { "user_language": "建筑物;大楼", "type": "noun", "pronunciation": "/ˈbɪldɪŋ/" }, + "air conditioner": { "user_language": "空调", "type": "noun", "pronunciation": "/ɛr kənˈdɪʃənər/" }, + "superintendent": { "user_language": "主管;负责人", "type": "noun", "pronunciation": "/ˌsuːpərɪnˈtendənt/" }, + "bus stop": { "user_language": "公交车站", "type": "noun", "pronunciation": "/bʌs stɒp/" }, + "jacuzzi": { "user_language": "按摩浴缸", "type": "noun", "pronunciation": "/dʒəˈkuːzi/" }, + "machine": { "user_language": "机器;设备", "type": "noun", "pronunciation": "/məˈʃiːn/" }, + "two and a half": { "user_language": "两个半", "type": "number", "pronunciation": "/tuː ænd ə hæf/" }, + "in the center of": { "user_language": "在……中心", "type": "preposition", "pronunciation": "/ɪn ðə ˈsentər ʌv/" }, + "town": { "user_language": "城镇", "type": "noun", "pronunciation": "/taʊn/" }, + "a lot of": { "user_language": "许多", "type": "determiner", "pronunciation": "/ə lɑt ʌv/" }, + "noise": { "user_language": "噪音", "type": "noun", "pronunciation": "/nɔɪz/" }, + "sidewalks": { "user_language": "人行道", "type": "noun", "pronunciation": "/ˈsaɪdwɔːks/" }, + "all day and all night": { "user_language": "整日整夜", "type": "adverb", "pronunciation": "/ɔːl deɪ ænd ɔːl naɪt/" }, + "convenient": { "user_language": "便利的", "type": "adjective", "pronunciation": "/kənˈviːniənt/" }, + "shirt": { "user_language": "衬衫", "type": "noun", "pronunciation": "/ʃɜːrt/" }, + "coat": { "user_language": "外套、大衣", "type": "noun", "pronunciation": "/koʊt/" }, + "pants": { "user_language": "裤子", "type": "noun", "pronunciation": "/pænts/" }, + "shoes": { "user_language": "鞋子", "type": "noun", "pronunciation": "/ʃuːz/" }, + "hat": { "user_language": "帽子", "type": "noun", "pronunciation": "/hæt/" }, + "dress": { "user_language": "连衣裙", "type": "noun", "pronunciation": "/drɛs/" }, + "suit": { "user_language": "套装", "type": "noun", "pronunciation": "/suːt/" }, + "tie": { "user_language": "领带", "type": "noun", "pronunciation": "/taɪ/" }, + "socks": { "user_language": "袜子", "type": "noun", "pronunciation": "/sɑːks/" }, + "blouse": { "user_language": "女式衬衫", "type": "noun", "pronunciation": "/blaʊs/" }, + "skirt": { "user_language": "裙子", "type": "noun", "pronunciation": "/skɜːrt/" }, + "sweater": { "user_language": "毛衣", "type": "noun", "pronunciation": "/ˈswɛtər/" }, + "jacket": { "user_language": "夹克", "type": "noun", "pronunciation": "/ˈdʒækɪt/" }, + "jeans": { "user_language": "牛仔裤", "type": "noun", "pronunciation": "/dʒiːnz/" }, + "shorts": { "user_language": "短裤", "type": "noun", "pronunciation": "/ʃɔːrts/" }, + "sneakers": { "user_language": "运动鞋", "type": "noun", "pronunciation": "/ˈsniːkərz/" }, + "boots": { "user_language": "靴子", "type": "noun", "pronunciation": "/buːts/" }, + "gloves": { "user_language": "手套", "type": "noun", "pronunciation": "/ɡlʌvz/" }, + "scarf": { "user_language": "围巾", "type": "noun", "pronunciation": "/skɑːrf/" }, + "belt": { "user_language": "腰带", "type": "noun", "pronunciation": "/bɛlt/" }, + "head": { "user_language": "头", "type": "noun", "pronunciation": "/hɛd/" }, + "hair": { "user_language": "头发", "type": "noun", "pronunciation": "/hɛr/" }, + "eyes": { "user_language": "眼睛", "type": "noun", "pronunciation": "/aɪz/" }, + "nose": { "user_language": "鼻子", "type": "noun", "pronunciation": "/noʊz/" }, + "mouth": { "user_language": "嘴", "type": "noun", "pronunciation": "/maʊθ/" }, + "ears": { "user_language": "耳朵", "type": "noun", "pronunciation": "/ɪrz/" }, + "face": { "user_language": "脸", "type": "noun", "pronunciation": "/feɪs/" }, + "neck": { "user_language": "脖子", "type": "noun", "pronunciation": "/nɛk/" }, + "shoulders": { "user_language": "肩膀", "type": "noun", "pronunciation": "/ˈʃoʊldərz/" }, + "arms": { "user_language": "胳膊", "type": "noun", "pronunciation": "/ɑːrmz/" }, + "hands": { "user_language": "手", "type": "noun", "pronunciation": "/hændz/" }, + "fingers": { "user_language": "手指", "type": "noun", "pronunciation": "/ˈfɪŋɡərz/" }, + "chest": { "user_language": "胸部", "type": "noun", "pronunciation": "/tʃɛst/" }, + "back": { "user_language": "背部", "type": "noun", "pronunciation": "/bæk/" }, + "stomach": { "user_language": "腹部、肚子", "type": "noun", "pronunciation": "/ˈstʌmək/" }, + "legs": { "user_language": "腿", "type": "noun", "pronunciation": "/lɛɡz/" }, + "feet": { "user_language": "脚", "type": "noun", "pronunciation": "/fiːt/" }, + "happy": { "user_language": "快乐的", "type": "adjective", "pronunciation": "/ˈhæpi/" }, + "sad": { "user_language": "悲伤的", "type": "adjective", "pronunciation": "/sæd/" }, + "angry": { "user_language": "生气的", "type": "adjective", "pronunciation": "/ˈæŋɡri/" }, + "worried": { "user_language": "担心的", "type": "adjective", "pronunciation": "/ˈwɜːrid/" }, + "excited": { "user_language": "兴奋的", "type": "adjective", "pronunciation": "/ɪkˈsaɪtɪd/" }, + "tired": { "user_language": "疲劳的", "type": "adjective", "pronunciation": "/ˈtaɪərd/" }, + "hungry": { "user_language": "饥饿的", "type": "adjective", "pronunciation": "/ˈhʌŋɡri/" }, + "thirsty": { "user_language": "口渴的", "type": "adjective", "pronunciation": "/ˈθɜːrsti/" }, + "cold": { "user_language": "寒冷的", "type": "adjective", "pronunciation": "/koʊld/" }, + "hot": { "user_language": "炎热的", "type": "adjective", "pronunciation": "/hɑːt/" }, + "computer": { "user_language": "电脑", "type": "noun", "pronunciation": "/kəmˈpjuːtər/" }, + "laptop": { "user_language": "笔记本电脑", "type": "noun", "pronunciation": "/ˈlæptɑːp/" }, + "phone": { "user_language": "电话", "type": "noun", "pronunciation": "/foʊn/" }, + "tablet": { "user_language": "平板电脑", "type": "noun", "pronunciation": "/ˈtæblət/" }, + "internet": { "user_language": "互联网", "type": "noun", "pronunciation": "/ˈɪntərnet/" }, + "email": { "user_language": "电子邮件", "type": "noun", "pronunciation": "/ˈiːmeɪl/" }, + "website": { "user_language": "网站", "type": "noun", "pronunciation": "/ˈwɛbsaɪt/" }, + "app": { "user_language": "应用程序", "type": "noun", "pronunciation": "/æp/" }, + "social media": { "user_language": "社交媒体", "type": "noun", "pronunciation": "/ˈsoʊʃəl ˈmidiə/" }, + "password": { "user_language": "密码", "type": "noun", "pronunciation": "/ˈpæswərd/" } + }, + "phrases": { + "I live in a two-bedroom apartment": { "user_language": "我住在一间两居室的公寓", "context": "housing", "pronunciation": "/aɪ lɪv ɪn ə tuː ˈbɛdruːm əˈpɑːrtmənt/" }, + "It's in the center of town": { "user_language": "它在城镇中心", "context": "location", "pronunciation": "/ɪts ɪn ðə ˈsentər ʌv taʊn/" }, + "There's a lot of noise": { "user_language": "有很多噪音", "context": "complaint", "pronunciation": "/ðɛrz ə lɑt ʌv nɔɪz/" }, + "It's very convenient": { "user_language": "这很便利", "context": "advantage", "pronunciation": "/ɪts ˈvɛri kənˈviniənt/" }, + "What are you wearing?": { "user_language": "你穿的是什么?", "context": "clothing", "pronunciation": "/wʌt ɑr ju ˈwɛrɪŋ/" }, + "I'm wearing a blue shirt": { "user_language": "我穿着一件蓝色的衬衫", "context": "clothing", "pronunciation": "/aɪm ˈwɛrɪŋ ə blu ʃɜrt/" }, + "How do you feel?": { "user_language": "你感觉怎么样?", "context": "emotions", "pronunciation": "/haʊ du ju fil/" }, + "I feel happy today": { "user_language": "我今天感觉很开心", "context": "emotions", "pronunciation": "/aɪ fil ˈhæpi təˈdeɪ/" }, + "Do you have internet access?": { "user_language": "你有网络连接吗?", "context": "technology", "pronunciation": "/du ju hæv ˈɪntərnet ˈækses/" }, + "I need to check my email": { "user_language": "我需要查看我的电子邮件", "context": "technology", "pronunciation": "/aɪ nid tu tʃɛk maɪ ˈimeɪl/" } + }, + "dialogs": { + "apartment_search": { + "title": "Looking for an Apartment", + "participants": ["Alex", "Manager"], + "lines": [ + { "speaker": "Alex", "text": "I'm looking for a two-bedroom apartment.", "user_language": "我在找一间两居室的公寓。" }, + { "speaker": "Manager", "text": "We have one available on Central Avenue.", "user_language": "我们在中央大道有一间可用的。" }, + { "speaker": "Alex", "text": "Is it convenient for transportation?", "user_language": "交通方便吗?" }, + { "speaker": "Manager", "text": "Yes, there's a bus stop right outside.", "user_language": "是的,外面就有一个公交车站。" } + ] + }, + "clothing_shopping": { + "title": "Shopping for Clothes", + "participants": ["Customer", "Salesperson"], + "lines": [ + { "speaker": "Customer", "text": "I need a shirt for work.", "user_language": "我需要一件工作穿的衬衫。" }, + { "speaker": "Salesperson", "text": "What size do you wear?", "user_language": "你穿什么尺码?" }, + { "speaker": "Customer", "text": "Medium. Do you have it in blue?", "user_language": "中码。你们有蓝色的吗?" }, + { "speaker": "Salesperson", "text": "Yes, here's a nice blue shirt.", "user_language": "有,这里有一件漂亮的蓝色衬衫。" } + ] + } + }, + "exercises": { + "vocabulary_matching": { + "type": "matching", + "instructions": "Match the English words with their Chinese meanings", + "pairs": [ + { "english": "shirt", "chinese": "衬衫" }, + { "english": "happy", "chinese": "快乐的" }, + { "english": "computer", "chinese": "电脑" }, + { "english": "apartment", "chinese": "公寓" } + ] + }, + "fill_in_blanks": { + "type": "fill_blanks", + "instructions": "Fill in the blanks with the correct words", + "sentences": [ + { "text": "I live in a two-bedroom _______", "answer": "apartment", "user_language": "我住在一间两居室的_______" }, + { "text": "I'm wearing a blue _______", "answer": "shirt", "user_language": "我穿着一件蓝色的_______" } + ] + } + }, + "statistics": { + "vocabulary_count": 67, + "phrases_count": 10, + "dialogs_count": 2, + "exercises_count": 2, + "estimated_completion_time": 25 + } +} \ No newline at end of file diff --git a/content/chapters/sbs.json b/content/chapters/sbs.json new file mode 100644 index 0000000..9f33ec7 --- /dev/null +++ b/content/chapters/sbs.json @@ -0,0 +1,19 @@ +{ + "name": "Side by Side English Course", + "description": "Complete English language learning course", + "version": "1.0", + "language": "en", + "targetLanguage": "fr", + "chapters": [ + { + "id": "sbs-7-8", + "name": "Chapters 7-8: Past Tense & Irregular Verbs", + "description": "Learn past tense and irregular verbs with practical exercises" + } + ], + "metadata": { + "created": "2025-09-26", + "level": "intermediate", + "estimatedHours": 20 + } +} \ No newline at end of file diff --git a/drs-main.html b/drs-main.html new file mode 100644 index 0000000..b358d3f --- /dev/null +++ b/drs-main.html @@ -0,0 +1,482 @@ + + + + + + 🎓 DRS Unifié - Class Generator 2.0 + + + + + + + + +
+
+

🎓 DRS Unifié

+

Système d'apprentissage avec composants UI unifiés

+
+ +
+ +
+
+ 📚 +
Compréhension Écrite
+

Lecture et analyse de texte

+
+ +
+ 🎵 +
Compréhension Orale
+

Écoute et analyse audio

+
+ +
+ 🖼️ +
Analyse d'Image
+

Observation et description

+
+ +
+ 📝 +
Grammaire
+

Exercices grammaticaux

+
+
+ + +
+ + + +
+ + +
+ + +
+ + + + + +
+
+

👆 Choisissez un type d'exercice

+

Sélectionnez un exercice ci-dessus, choisissez la difficulté, puis cliquez sur "Démarrer"

+
+
+
+
+ + + + + \ No newline at end of file diff --git a/drs-unified-test.html b/drs-unified-test.html new file mode 100644 index 0000000..5285943 --- /dev/null +++ b/drs-unified-test.html @@ -0,0 +1,465 @@ + + + + + + 🎓 Test DRS Unifié - Class Generator 2.0 + + + + + + + + +
+
+

🎓 Test DRS Unifié

+

Nouveau système DRS avec composants UI extraits

+
+ +
+ +
+ + + + + +
+ + + + + +
+
+

👆 Choisissez un type d'exercice

+

Sélectionnez un bouton ci-dessus pour tester le DRS unifié

+
+
+ + +
+

🔍 Debug Info

+
Ready to test...
+
+
+
+ + + + \ No newline at end of file diff --git a/fix-server.bat b/fix-server.bat new file mode 100644 index 0000000..4f24f83 --- /dev/null +++ b/fix-server.bat @@ -0,0 +1,26 @@ +@echo off +echo 🔧 Fixing server issue... + +REM Kill process on port 8080 specifically +echo 🛑 Stopping server on port 8080... +for /f "tokens=5" %%a in ('netstat -aon ^| findstr ":8080 "') do ( + echo Killing process %%a + taskkill /F /PID %%a 2>nul +) + +REM Kill all Node processes just in case +echo 🛑 Stopping all Node.js processes... +taskkill /F /IM node.exe /T 2>nul + +REM Wait a moment +echo ⏳ Waiting... +timeout /t 3 /nobreak >nul + +REM Start the correct server +echo 🚀 Starting correct server... +cd /d "%~dp0" +echo Working directory: %CD% +echo Starting: node server.js +node server.js + +pause \ No newline at end of file diff --git a/index.html b/index.html index 0eff6af..38c9846 100644 --- a/index.html +++ b/index.html @@ -6,8 +6,8 @@ Class Generator - Educational Games Platform - - + + @@ -27,7 +27,6 @@
-

Class Generator

Online @@ -58,48 +57,555 @@ \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..fe0d465 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,31 @@ +{ + "name": "class-generator", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "class-generator", + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "dotenv": "^17.2.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/dotenv": { + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz", + "integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + } + } +} diff --git a/package.json b/package.json index 5bda68d..83624e8 100644 --- a/package.json +++ b/package.json @@ -20,5 +20,8 @@ "license": "MIT", "engines": { "node": ">=14.0.0" + }, + "dependencies": { + "dotenv": "^17.2.2" } -} \ No newline at end of file +} diff --git a/restart-clean.bat b/restart-clean.bat new file mode 100644 index 0000000..6ce639f --- /dev/null +++ b/restart-clean.bat @@ -0,0 +1,16 @@ +@echo off +echo 🔄 Restarting Class Generator with clean shutdown... + +REM Kill all Node.js processes +echo 🛑 Stopping all Node.js processes... +taskkill /F /IM node.exe /T 2>nul + +REM Wait a moment +timeout /t 2 /nobreak >nul + +REM Start the server +echo 🚀 Starting fresh server... +cd /d "%~dp0" +node server.js + +pause \ No newline at end of file diff --git a/server.js b/server.js index 4786a28..01f3110 100644 --- a/server.js +++ b/server.js @@ -4,15 +4,16 @@ */ import { createServer } from 'http'; -import { readFile, stat } from 'fs/promises'; +import { readFile, writeFile, stat, readdir, mkdir } from 'fs/promises'; import { join, extname } from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; +import { existsSync } from 'fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const PORT = process.env.PORT || 3000; +const PORT = process.env.PORT || 8080; const HOST = process.env.HOST || 'localhost'; // MIME types for different file extensions @@ -46,11 +47,55 @@ const server = createServer(async (req, res) => { console.log(`${new Date().toISOString()} - ${req.method} ${urlPath}`); + // Legacy API endpoint to get all available books (deprecated - use ContentLoader) + if (urlPath === '/api/books') { + console.warn('⚠️ /api/books is deprecated. Use ContentLoader.loadBooks() instead'); + return await handleBooksAPI(res); + } + + // API endpoint for LLM configuration (IAEngine) + if (urlPath === '/api/llm-config') { + return await handleLLMConfigAPI(req, res); + } + + // Progress API endpoints (DRS and Flashcards only) + if (urlPath === '/api/progress/save') { + return await handleProgressSave(req, res); + } + + // Data merge endpoint for combining local and external sources + if (urlPath === '/api/progress/merge') { + return await handleProgressMerge(req, res); + } + + // Sync status endpoint + if (urlPath === '/api/progress/sync-status') { + return await handleSyncStatus(req, res); + } + + // DRS progress load: /api/progress/load/drs/bookId/chapterId + const drsLoadMatch = urlPath.match(/^\/api\/progress\/load\/drs\/([^\/]+)\/([^\/]+)$/); + if (drsLoadMatch) { + const [, bookId, chapterId] = drsLoadMatch; + return await handleProgressLoad(req, res, 'drs', bookId, chapterId); + } + + // Flashcards progress load: /api/progress/load/flashcards + if (urlPath === '/api/progress/load/flashcards') { + return await handleProgressLoad(req, res, 'flashcards'); + } + // Set CORS headers for all requests res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + // Disable caching completely for development + res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); + res.setHeader('Pragma', 'no-cache'); + res.setHeader('Expires', '0'); + res.setHeader('Surrogate-Control', 'no-store'); + // Handle preflight requests if (req.method === 'OPTIONS') { res.writeHead(200); @@ -90,6 +135,91 @@ const server = createServer(async (req, res) => { } }); +async function handleBooksAPI(res) { + try { + const booksDir = join(__dirname, 'content', 'books'); + const files = await readdir(booksDir); + const jsonFiles = files.filter(file => file.endsWith('.json')); + + const books = []; + + for (const file of jsonFiles) { + try { + const filePath = join(booksDir, file); + const content = await readFile(filePath, 'utf8'); + const data = JSON.parse(content); + + books.push({ + id: data.id, + name: data.name, + description: data.description, + difficulty: data.difficulty, + language: data.language, + chapters: data.chapters || [] + }); + } catch (error) { + console.error(`Error reading ${file}:`, error); + } + } + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.writeHead(200); + res.end(JSON.stringify(books, null, 2)); + + console.log(` ✅ Served API books list (${books.length} books)`); + + } catch (error) { + console.error('Error in books API:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Failed to load books' })); + } +} + +async function handleChaptersAPI(res, bookId) { + try { + const booksDir = join(__dirname, 'content', 'books'); + const bookPath = join(booksDir, `${bookId}.json`); + + const content = await readFile(bookPath, 'utf8'); + const bookData = JSON.parse(content); + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.writeHead(200); + res.end(JSON.stringify(bookData.chapters || [], null, 2)); + + console.log(` ✅ Served API chapters for book ${bookId} (${bookData.chapters?.length || 0} chapters)`); + + } catch (error) { + console.error(`Error in chapters API for ${bookId}:`, error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Failed to load chapters' })); + } +} + +async function handleChapterContentAPI(res, chapterId) { + try { + const chaptersDir = join(__dirname, 'content', 'chapters'); + const chapterPath = join(chaptersDir, `${chapterId}.json`); + + const content = await readFile(chapterPath, 'utf8'); + const chapterData = JSON.parse(content); + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.writeHead(200); + res.end(JSON.stringify(chapterData, null, 2)); + + console.log(` ✅ Served API content for chapter ${chapterId}`); + + } catch (error) { + console.error(`Error in chapter content API for ${chapterId}:`, error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Failed to load chapter content' })); + } +} + async function serveFile(filePath, res) { try { const ext = extname(filePath).toLowerCase(); @@ -99,10 +229,10 @@ async function serveFile(filePath, res) { res.setHeader('Content-Type', mimeType); // Set cache headers for static assets - if (['.css', '.js', '.png', '.jpg', '.gif', '.svg', '.ico', '.woff', '.woff2'].includes(ext)) { + if (['.css', '.png', '.jpg', '.gif', '.svg', '.ico', '.woff', '.woff2'].includes(ext)) { res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour } else { - res.setHeader('Cache-Control', 'no-cache'); // No cache for HTML and other files + res.setHeader('Cache-Control', 'no-cache'); // No cache for HTML and JS files (development) } // Add security headers @@ -163,6 +293,486 @@ function send404(res, message = 'Not Found') { console.log(` ❌ 404: ${message}`); } +// Progress storage functions for DRS and Flashcards +async function handleProgressSave(req, res) { + try { + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); + return; + } + + // Read request body + let body = ''; + req.on('data', chunk => body += chunk.toString()); + req.on('end', async () => { + try { + const { system, bookId, chapterId, progressData } = JSON.parse(body); + + // Validate system (only DRS and Flashcards allowed) + if (!['drs', 'flashcards'].includes(system)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid system. Only "drs" and "flashcards" allowed' })); + return; + } + + // Create saves directory if it doesn't exist + const savesDir = join(__dirname, 'saves'); + if (!existsSync(savesDir)) { + await mkdir(savesDir, { recursive: true }); + } + + // Create filename based on system and identifiers + const filename = system === 'drs' + ? `${system}-progress-${bookId}-${chapterId}.json` + : `${system}-progress.json`; + + const filePath = join(savesDir, filename); + + // Add metadata + const saveData = { + ...progressData, + system, + bookId: system === 'drs' ? bookId : undefined, + chapterId: system === 'drs' ? chapterId : undefined, + savedAt: new Date().toISOString(), + version: '1.0' + }; + + await writeFile(filePath, JSON.stringify(saveData, null, 2)); + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.writeHead(200); + res.end(JSON.stringify({ + success: true, + filename, + savedAt: saveData.savedAt + })); + + console.log(` ✅ Saved ${system} progress: ${filename}`); + + } catch (parseError) { + console.error('Error parsing progress save request:', parseError); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON data' })); + } + }); + + } catch (error) { + console.error('Error in progress save API:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Failed to save progress' })); + } +} + +async function handleProgressLoad(req, res, system, bookId, chapterId) { + try { + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); + return; + } + + // Validate system + if (!['drs', 'flashcards'].includes(system)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid system' })); + return; + } + + const filename = system === 'drs' + ? `${system}-progress-${bookId}-${chapterId}.json` + : `${system}-progress.json`; + + const filePath = join(__dirname, 'saves', filename); + + try { + const content = await readFile(filePath, 'utf8'); + const progressData = JSON.parse(content); + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.writeHead(200); + res.end(JSON.stringify(progressData)); + + console.log(` ✅ Loaded ${system} progress: ${filename}`); + + } catch (fileError) { + // File doesn't exist - return empty progress + const emptyProgress = system === 'drs' ? { + masteredVocabulary: [], + masteredPhrases: [], + masteredGrammar: [], + completed: false, + masteryCount: 0, + system, + bookId, + chapterId + } : { + system: 'flashcards', + progress: {}, + stats: {} + }; + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.writeHead(200); + res.end(JSON.stringify(emptyProgress)); + + console.log(` ℹ️ No saved progress found for ${system}, returning empty`); + } + + } catch (error) { + console.error('Error in progress load API:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Failed to load progress' })); + } +} + +// API handler for LLM configuration +async function handleLLMConfigAPI(req, res) { + try { + // Only allow GET requests + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); + return; + } + + // Load environment variables + const { config } = await import('dotenv'); + config(); + + // Extract only the LLM API keys + const llmConfig = { + OPENAI_API_KEY: process.env.OPENAI_API_KEY, + ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, + DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY, + MISTRAL_API_KEY: process.env.MISTRAL_API_KEY, + GEMINI_API_KEY: process.env.GEMINI_API_KEY + }; + + // Filter out undefined keys + const validKeys = Object.fromEntries( + Object.entries(llmConfig).filter(([key, value]) => value && value.length > 0) + ); + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.writeHead(200); + res.end(JSON.stringify(validKeys)); + + console.log(` ✅ Served LLM config with ${Object.keys(validKeys).length} API keys`); + + } catch (error) { + console.error('Error in LLM config API:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Failed to load LLM configuration' })); + } +} + +// Data merge handler for combining local and external progress +async function handleProgressMerge(req, res) { + try { + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); + return; + } + + // Read request body + let body = ''; + req.on('data', chunk => body += chunk.toString()); + req.on('end', async () => { + try { + const { system, bookId, chapterId, localData, externalData, mergeStrategy = 'timestamp' } = JSON.parse(body); + + // Validate system + if (!['drs', 'flashcards'].includes(system)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid system. Only "drs" and "flashcards" allowed' })); + return; + } + + // Perform data merge + const mergedData = await mergeProgressData(localData, externalData, mergeStrategy); + + // Add merge metadata + mergedData.mergeInfo = { + strategy: mergeStrategy, + mergedAt: new Date().toISOString(), + localItems: countProgressItems(localData), + externalItems: countProgressItems(externalData), + totalItems: countProgressItems(mergedData), + conflicts: mergedData.conflicts || [] + }; + + // Save merged data + const savesDir = join(__dirname, 'saves'); + if (!existsSync(savesDir)) { + await mkdir(savesDir, { recursive: true }); + } + + const filename = system === 'drs' + ? `${system}-progress-${bookId}-${chapterId}.json` + : `${system}-progress.json`; + + const filePath = join(savesDir, filename); + await writeFile(filePath, JSON.stringify(mergedData, null, 2)); + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.writeHead(200); + res.end(JSON.stringify({ + success: true, + mergedData, + mergeInfo: mergedData.mergeInfo + })); + + console.log(` ✅ Merged ${system} progress: ${mergedData.mergeInfo.totalItems} total items`); + + } catch (parseError) { + console.error('Error parsing progress merge request:', parseError); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON data' })); + } + }); + + } catch (error) { + console.error('Error in progress merge API:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Failed to merge progress' })); + } +} + +// Sync status handler +async function handleSyncStatus(req, res) { + try { + if (req.method !== 'GET') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); + return; + } + + const savesDir = join(__dirname, 'saves'); + const syncStatus = { + savesDirectory: savesDir, + lastSync: null, + savedFiles: [], + totalFiles: 0 + }; + + try { + if (existsSync(savesDir)) { + const files = await readdir(savesDir); + const jsonFiles = files.filter(file => file.endsWith('.json')); + + syncStatus.totalFiles = jsonFiles.length; + + for (const file of jsonFiles) { + try { + const filePath = join(savesDir, file); + const stats = await stat(filePath); + const content = await readFile(filePath, 'utf8'); + const data = JSON.parse(content); + + syncStatus.savedFiles.push({ + filename: file, + lastModified: stats.mtime.toISOString(), + savedAt: data.savedAt || stats.mtime.toISOString(), + system: data.system || 'unknown', + bookId: data.bookId, + chapterId: data.chapterId, + hasTimestamps: hasTimestampData(data), + itemCount: countProgressItems(data) + }); + + // Update last sync time to most recent file + if (!syncStatus.lastSync || stats.mtime > new Date(syncStatus.lastSync)) { + syncStatus.lastSync = stats.mtime.toISOString(); + } + + } catch (fileError) { + console.warn(`Error reading file ${file}:`, fileError); + } + } + } + } catch (dirError) { + console.warn('Saves directory not accessible:', dirError); + } + + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.writeHead(200); + res.end(JSON.stringify(syncStatus)); + + console.log(` ✅ Sync status: ${syncStatus.totalFiles} files`); + + } catch (error) { + console.error('Error in sync status API:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Failed to get sync status' })); + } +} + +// Data merge utilities +async function mergeProgressData(localData, externalData, strategy = 'timestamp') { + const merged = { + masteredVocabulary: [], + masteredPhrases: [], + masteredGrammar: [], + completed: false, + masteryCount: 0, + conflicts: [] + }; + + // Copy metadata from most recent source + const localTimestamp = localData?.savedAt || localData?.lastModified || '1970-01-01T00:00:00.000Z'; + const externalTimestamp = externalData?.savedAt || externalData?.lastModified || '1970-01-01T00:00:00.000Z'; + const useExternal = new Date(externalTimestamp) > new Date(localTimestamp); + + const primarySource = useExternal ? externalData : localData; + const secondarySource = useExternal ? localData : externalData; + + // Copy basic properties + merged.system = primarySource.system || localData.system || 'drs'; + merged.bookId = primarySource.bookId || localData.bookId; + merged.chapterId = primarySource.chapterId || localData.chapterId; + merged.completed = primarySource.completed || secondarySource.completed || false; + merged.savedAt = new Date().toISOString(); + + // Merge each category + const categories = ['masteredVocabulary', 'masteredPhrases', 'masteredGrammar']; + + for (const category of categories) { + const localItems = localData[category] || []; + const externalItems = externalData[category] || []; + + const mergeResult = mergeItemArrays(localItems, externalItems, strategy); + merged[category] = mergeResult.items; + merged.conflicts.push(...mergeResult.conflicts.map(c => ({ ...c, category }))); + } + + // Calculate mastery count (max of both sources plus any new merged items) + merged.masteryCount = Math.max( + localData.masteryCount || 0, + externalData.masteryCount || 0, + merged.masteredVocabulary.length + merged.masteredPhrases.length + merged.masteredGrammar.length + ); + + return merged; +} + +function mergeItemArrays(localItems, externalItems, strategy) { + const result = { + items: [], + conflicts: [] + }; + + const itemMap = new Map(); + + // Add all items to map, handling conflicts + const addToMap = (items, source) => { + items.forEach(item => { + const itemKey = typeof item === 'string' ? item : item.item; + const itemData = typeof item === 'string' ? { item, masteredAt: '1970-01-01T00:00:00.000Z', source } : { ...item, source }; + + if (itemMap.has(itemKey)) { + const existing = itemMap.get(itemKey); + const conflict = { + item: itemKey, + local: source === 'local' ? itemData : existing, + external: source === 'external' ? itemData : existing, + resolution: 'pending' + }; + + // Resolve conflict based on strategy + let resolvedItem; + switch (strategy) { + case 'timestamp': + const existingTime = new Date(existing.masteredAt || existing.lastReviewAt || '1970-01-01'); + const newTime = new Date(itemData.masteredAt || itemData.lastReviewAt || '1970-01-01'); + + if (newTime > existingTime) { + resolvedItem = { ...itemData, attempts: (existing.attempts || 1) + (itemData.attempts || 1) }; + conflict.resolution = `newer_timestamp_from_${source}`; + } else { + resolvedItem = { ...existing, attempts: (existing.attempts || 1) + (itemData.attempts || 1) }; + conflict.resolution = 'kept_existing_newer_timestamp'; + } + break; + + case 'attempts': + resolvedItem = { + ...existing, + attempts: (existing.attempts || 1) + (itemData.attempts || 1), + lastReviewAt: new Date().toISOString() + }; + conflict.resolution = 'merged_attempts'; + break; + + case 'prefer_local': + resolvedItem = source === 'local' ? itemData : existing; + conflict.resolution = 'preferred_local'; + break; + + case 'prefer_external': + resolvedItem = source === 'external' ? itemData : existing; + conflict.resolution = 'preferred_external'; + break; + + default: + resolvedItem = existing; + conflict.resolution = 'kept_existing_default'; + } + + itemMap.set(itemKey, resolvedItem); + result.conflicts.push(conflict); + + } else { + itemMap.set(itemKey, itemData); + } + }); + }; + + // Process both arrays + addToMap(localItems, 'local'); + addToMap(externalItems, 'external'); + + // Convert map back to array, removing source metadata + result.items = Array.from(itemMap.values()).map(item => { + const { source, ...cleanItem } = item; + return cleanItem; + }); + + return result; +} + +function countProgressItems(data) { + if (!data) return 0; + + const vocab = data.masteredVocabulary?.length || 0; + const phrases = data.masteredPhrases?.length || 0; + const grammar = data.masteredGrammar?.length || 0; + + return vocab + phrases + grammar; +} + +function hasTimestampData(data) { + if (!data) return false; + + const checkArray = (arr) => { + return arr && arr.length > 0 && arr.some(item => + typeof item === 'object' && (item.masteredAt || item.lastReviewAt) + ); + }; + + return checkArray(data.masteredVocabulary) || + checkArray(data.masteredPhrases) || + checkArray(data.masteredGrammar); +} + // Start server server.listen(PORT, HOST, () => { console.log('\n🚀 Class Generator Development Server'); diff --git a/src/Application.js b/src/Application.js index 923a65d..0c135b8 100644 --- a/src/Application.js +++ b/src/Application.js @@ -17,6 +17,9 @@ class Application { this._moduleLoader = new ModuleLoader(this._eventBus); this._router = null; + // Register core modules immediately for early access + this._registerCoreModules(); + // Configuration this._config = { autoStart: config.autoStart !== false, // Default true @@ -139,6 +142,15 @@ class Application { }; } + /** + * Get a loaded module by name + * @param {string} moduleName - Name of the module to get + * @returns {Object|null} Module instance or null if not found + */ + getModule(moduleName) { + return this._moduleLoader ? this._moduleLoader.getModule(moduleName) : null; + } + // Private methods async _autoStart() { // Wait for DOM to be ready @@ -150,6 +162,27 @@ class Application { } } + _registerCoreModules() { + // Register bootstrap module to allow HTML script to use EventBus + this._eventBus.registerModule({ name: 'Bootstrap' }); + + // Register Application itself as a module + this._eventBus.registerModule({ name: 'Application' }); + + // Register ModuleLoader as a module + this._eventBus.registerModule({ name: 'ModuleLoader' }); + + // Register core dependencies by storing them directly in ModuleLoader + // This allows other modules to depend on 'eventBus' + this._moduleLoader._loadedModules.set('eventBus', this._eventBus); + this._moduleLoader._modules.set('eventBus', { + name: 'eventBus', + instance: this._eventBus, + loaded: true, + initialized: true + }); + } + async _initializeCore() { // Register router as a module this._moduleLoader.register('router', Router, ['eventBus']); @@ -166,10 +199,14 @@ class Application { } async _loadModules() { + console.log(`🔄 Loading ${this._config.modules.length} modules...`); + for (const moduleConfig of this._config.modules) { try { const { name, path, dependencies = [], config = {} } = moduleConfig; + console.log(`📦 Loading module: ${name} from ${path}`); + // Dynamically import module const moduleModule = await import(path); const ModuleClass = moduleModule.default; @@ -178,9 +215,7 @@ class Application { this._moduleLoader.register(name, ModuleClass, dependencies); await this._moduleLoader.loadAndInitialize(name, config); - if (this._config.enableDebug) { - console.log(`📦 Module ${name} loaded successfully`); - } + console.log(`✅ Module ${name} loaded and initialized successfully`); } catch (error) { console.error(`❌ Failed to load module ${moduleConfig.name}:`, error); @@ -195,10 +230,17 @@ class Application { } async _startRouting() { - // Register default routes + // Register generic routes with dynamic loading this._router.register('/', this._handleHomeRoute.bind(this)); - this._router.register('/games', this._handleGamesRoute.bind(this)); - this._router.register('/play', this._handlePlayRoute.bind(this)); + this._router.register('/books', this._handleBooksRoute.bind(this)); + this._router.register('/chapters', this._handleChaptersRoute.bind(this), { exact: false }); + this._router.register('/games', this._handleGamesRoute.bind(this), { exact: false }); + this._router.register('/dynamic-revision', this._handleDynamicRevisionRoute.bind(this)); + this._router.register('/settings', this._handleSettingsRoute.bind(this)); + + // Now that routes are registered, handle the current route + console.log('🛣️ Routes registered, handling initial route...'); + this._router._handleCurrentRoute(); if (this._config.enableDebug) { console.log('🛣️ Routing system started'); @@ -235,12 +277,47 @@ class Application { this._eventBus.emit('navigation:home', { path, state }, 'Application'); } - async _handleGamesRoute(path, state) { - this._eventBus.emit('navigation:games', { path, state }, 'Application'); + async _handleBooksRoute(path, state) { + this._eventBus.emit('navigation:books', { path, state }, 'Application'); } - async _handlePlayRoute(path, state) { - this._eventBus.emit('navigation:play', { path, state }, 'Application'); + async _handleChaptersRoute(path, state) { + this._eventBus.emit('navigation:chapters', { path, state }, 'Application'); + } + + async _handleGamesRoute(path, state) { + this._eventBus.emit('navigation:games', { path, state }, 'Application'); + + // Simple approach: Force re-render by emitting the chapter navigation event + console.log('🔄 Games route - path:', path, 'state:', state); + + // Extract chapter ID from path or use current one + const pathParts = path.split('/'); + let chapterId = pathParts[2] || window.currentChapterId || 'sbs'; + + console.log('🔄 Games route - using chapterId:', chapterId); + + // Make sure currentChapterId is set + if (!window.currentChapterId) { + window.currentChapterId = chapterId; + } + + // Force navigation to the chapter games - this will trigger the content loading + setTimeout(() => { + console.log('🔄 Games route - forcing navigation event'); + this._eventBus.emit('navigation:games', { + path: `/games/${chapterId}`, + data: { path: `/games/${chapterId}` } + }, 'Application'); + }, 100); + } + + async _handleDynamicRevisionRoute(path, state) { + this._eventBus.emit('navigation:dynamic-revision', { path, state }, 'Application'); + } + + async _handleSettingsRoute(path, state) { + this._eventBus.emit('navigation:settings', { path, state }, 'Application'); } } @@ -248,9 +325,15 @@ class Application { const app = new Application({ enableDebug: true, modules: [ - // Modules will be registered here - // { name: 'ui', path: './components/UI.js', dependencies: ['eventBus'] }, - // { name: 'gameEngine', path: './games/GameEngine.js', dependencies: ['eventBus', 'ui'] } + // Core system modules + { name: 'contentLoader', path: './core/ContentLoader.js', dependencies: ['eventBus'] }, + { name: 'gameLoader', path: './core/GameLoader.js', dependencies: ['eventBus'] }, + { name: 'intelligentSequencer', path: './core/IntelligentSequencer.js', dependencies: ['eventBus'] }, + // DRS system + { name: 'unifiedDRS', path: './DRS/UnifiedDRS.js', dependencies: ['eventBus', 'contentLoader'] }, + { name: 'smartPreviewOrchestrator', path: './DRS/SmartPreviewOrchestrator.js', dependencies: ['eventBus', 'contentLoader'] }, + // UI components + { name: 'settingsDebug', path: './components/SettingsDebug.js', dependencies: ['eventBus', 'router'] } ] }); diff --git a/src/DRS/SmartPreviewOrchestrator.js b/src/DRS/SmartPreviewOrchestrator.js new file mode 100644 index 0000000..b872089 --- /dev/null +++ b/src/DRS/SmartPreviewOrchestrator.js @@ -0,0 +1,748 @@ +/** + * SmartPreviewOrchestrator - Main controller for Dynamic Revision System + * Manages dynamic loading/unloading of exercise modules and coordinates shared services + */ + +import Module from '../core/Module.js'; + +const privateData = new WeakMap(); + +class SmartPreviewOrchestrator extends Module { + constructor(name, dependencies, config) { + super(name, ['eventBus', 'contentLoader']); + + // Validate dependencies + if (!dependencies.eventBus) { + throw new Error('SmartPreviewOrchestrator requires EventBus dependency'); + } + if (!dependencies.contentLoader) { + throw new Error('SmartPreviewOrchestrator requires ContentLoader dependency'); + } + + // Store dependencies and configuration + this._eventBus = dependencies.eventBus; + this._contentLoader = dependencies.contentLoader; + this._config = config || {}; + + // Initialize private data + privateData.set(this, { + loadedModules: new Map(), + availableModules: new Map(), + currentModule: null, + sharedServices: { + llmValidator: null, + prerequisiteEngine: null, + contextMemory: null, + aiReportInterface: null + }, + sessionState: { + currentChapter: null, + chapterContent: null, + masteredVocabulary: new Set(), + masteredPhrases: new Set(), + masteredGrammar: new Set(), + sessionProgress: {}, + exerciseSequence: [], + sequenceIndex: 0 + }, + moduleRegistry: { + 'vocabulary': './exercise-modules/VocabularyModule.js', + 'phrase': './exercise-modules/PhraseModule.js', + 'text': './exercise-modules/TextModule.js', + 'audio': './exercise-modules/AudioModule.js', + 'image': './exercise-modules/ImageModule.js', + 'grammar': './exercise-modules/GrammarModule.js' + } + }); + + Object.seal(this); + } + + async init() { + this._validateNotDestroyed(); + + try { + console.log('🎯 Initializing Smart Preview Orchestrator...'); + + // Initialize shared services + await this._initializeSharedServices(); + + // Set up event listeners + this._setupEventListeners(); + + // Register available module types + this._registerModuleTypes(); + + this._setInitialized(); + console.log('✅ Smart Preview Orchestrator initialized successfully'); + + } catch (error) { + console.error('❌ SmartPreviewOrchestrator initialization failed:', error); + throw error; + } + } + + async destroy() { + this._validateNotDestroyed(); + + try { + console.log('🧹 Cleaning up Smart Preview Orchestrator...'); + + // Unload all loaded modules + await this._unloadAllModules(); + + // Cleanup shared services + await this._cleanupSharedServices(); + + // Remove event listeners + this._eventBus.off('drs:startSession', this._handleStartSession, this.name); + this._eventBus.off('drs:switchModule', this._handleSwitchModule, this.name); + this._eventBus.off('drs:updateProgress', this._handleUpdateProgress, this.name); + + this._setDestroyed(); + console.log('✅ Smart Preview Orchestrator destroyed successfully'); + + } catch (error) { + console.error('❌ SmartPreviewOrchestrator cleanup failed:', error); + throw error; + } + } + + // Public API Methods + + /** + * Start a new revision session for a chapter + * @param {string} bookId - Book identifier + * @param {string} chapterId - Chapter identifier + * @returns {Promise} - Success status + */ + async startRevisionSession(bookId, chapterId) { + this._validateInitialized(); + + try { + console.log(`🚀 Starting revision session: ${bookId} - ${chapterId}`); + + // Load chapter content + const chapterContent = await this._contentLoader.loadContent(chapterId); + + const data = privateData.get(this); + data.sessionState.currentChapter = { bookId, chapterId }; + data.sessionState.chapterContent = chapterContent; + + // Load existing progress from files + if (window.getChapterProgress) { + try { + const savedProgress = await window.getChapterProgress(bookId, chapterId); + + // Populate session state with saved progress (handle both old and new format) + if (savedProgress.masteredVocabulary) { + const vocabItems = savedProgress.masteredVocabulary.map(entry => { + return typeof entry === 'string' ? entry : entry.item; + }); + data.sessionState.masteredVocabulary = new Set(vocabItems); + } + if (savedProgress.masteredPhrases) { + const phraseItems = savedProgress.masteredPhrases.map(entry => { + return typeof entry === 'string' ? entry : entry.item; + }); + data.sessionState.masteredPhrases = new Set(phraseItems); + } + if (savedProgress.masteredGrammar) { + const grammarItems = savedProgress.masteredGrammar.map(entry => { + return typeof entry === 'string' ? entry : entry.item; + }); + data.sessionState.masteredGrammar = new Set(grammarItems); + } + + console.log(`📁 Loaded existing progress: ${savedProgress.masteredVocabulary.length} vocab, ${savedProgress.masteredPhrases.length} phrases, mastery count: ${savedProgress.masteryCount}`); + + } catch (error) { + console.warn('Failed to load existing progress:', error); + } + } + + // Initialize prerequisites + await this._analyzePrerequisites(chapterContent); + + // Generate exercise sequence + await this._generateExerciseSequence(); + + // Start AI reporting session + if (data.sharedServices.llmValidator && data.sharedServices.aiReportInterface) { + const sessionId = data.sharedServices.llmValidator.startReportSession({ + bookId, + chapterId, + difficulty: this._config.difficulty || 'medium', + exerciseTypes: Array.from(data.availableModules.keys()), + totalExercises: data.sessionState.exerciseSequence.length + }); + + // Notify the report interface + data.sharedServices.aiReportInterface.onSessionStart({ + bookId, + chapterId, + sessionId + }); + + console.log(`📊 Started AI report session: ${sessionId}`); + } + + // Start with first available exercise + await this._startNextExercise(); + + // Emit session started event + this._eventBus.emit('drs:sessionStarted', { + bookId, + chapterId, + totalExercises: data.sessionState.exerciseSequence.length, + availableModules: Array.from(data.availableModules.keys()) + }, this.name); + + return true; + + } catch (error) { + console.error('❌ Failed to start revision session:', error); + this._eventBus.emit('drs:sessionError', { error: error.message }, this.name); + return false; + } + } + + /** + * Get available exercise modules based on current prerequisites + * @returns {Array} - Available module names + */ + getAvailableModules() { + this._validateInitialized(); + + const data = privateData.get(this); + return Array.from(data.availableModules.keys()); + } + + /** + * Get shared services for external access + * @returns {Object} - Shared services + */ + getSharedServices() { + this._validateInitialized(); + const data = privateData.get(this); + return data.sharedServices; + } + + /** + * Switch to a different exercise module + * @param {string} moduleType - Type of module to switch to + * @returns {Promise} - Success status + */ + async switchToModule(moduleType) { + this._validateInitialized(); + + try { + const data = privateData.get(this); + + if (!data.availableModules.has(moduleType)) { + throw new Error(`Module type ${moduleType} is not available`); + } + + // Unload current module + if (data.currentModule) { + await this._unloadModule(data.currentModule); + } + + // Load new module + const module = await this._loadModule(moduleType); + data.currentModule = moduleType; + + // Present exercise + const exerciseData = await this._getExerciseData(moduleType); + const container = document.getElementById('drs-exercise-container'); + await module.present(container, exerciseData); + + this._eventBus.emit('drs:moduleActivated', { moduleType, exerciseData }, this.name); + return true; + + } catch (error) { + console.error(`❌ Failed to switch to module ${moduleType}:`, error); + return false; + } + } + + /** + * Get current session progress + * @returns {Object} - Progress information + */ + getSessionProgress() { + this._validateInitialized(); + + const data = privateData.get(this); + const state = data.sessionState; + + return { + currentChapter: state.currentChapter, + masteredVocabulary: state.masteredVocabulary.size, + masteredPhrases: state.masteredPhrases.size, + masteredGrammar: state.masteredGrammar.size, + completedExercises: state.sequenceIndex, + totalExercises: state.exerciseSequence.length, + progressPercentage: Math.round((state.sequenceIndex / state.exerciseSequence.length) * 100) + }; + } + + // Private Methods + + async _initializeSharedServices() { + console.log('🔧 Initializing shared services...'); + + const data = privateData.get(this); + + try { + // Initialize LLMValidator (mock for now) + const { default: LLMValidator } = await import('./services/LLMValidator.js'); + data.sharedServices.llmValidator = new LLMValidator(this._config.llm || {}); + + // Initialize AIReportInterface + const { default: AIReportInterface } = await import('../components/AIReportInterface.js'); + data.sharedServices.aiReportInterface = new AIReportInterface( + data.sharedServices.llmValidator, + this._config.aiReporting || {} + ); + + // Initialize PrerequisiteEngine + const { default: PrerequisiteEngine } = await import('./services/PrerequisiteEngine.js'); + data.sharedServices.prerequisiteEngine = new PrerequisiteEngine(); + + // Initialize ContextMemory + const { default: ContextMemory } = await import('./services/ContextMemory.js'); + data.sharedServices.contextMemory = new ContextMemory(); + + console.log('✅ Shared services initialized'); + + } catch (error) { + console.error('❌ Failed to initialize shared services:', error); + throw error; + } + } + + async _cleanupSharedServices() { + const data = privateData.get(this); + + // Cleanup services if they have cleanup methods + Object.values(data.sharedServices).forEach(service => { + if (service && typeof service.cleanup === 'function') { + service.cleanup(); + } + }); + } + + _setupEventListeners() { + this._eventBus.on('drs:startSession', this._handleStartSession.bind(this), this.name); + this._eventBus.on('drs:switchModule', this._handleSwitchModule.bind(this), this.name); + this._eventBus.on('drs:updateProgress', this._handleUpdateProgress.bind(this), this.name); + this._eventBus.on('drs:exerciseCompleted', this._handleExerciseCompleted.bind(this), this.name); + } + + _registerModuleTypes() { + const data = privateData.get(this); + + // Register all available module types + Object.keys(data.moduleRegistry).forEach(moduleType => { + data.availableModules.set(moduleType, { + path: data.moduleRegistry[moduleType], + loaded: false, + instance: null + }); + }); + } + + async _loadModule(moduleType) { + const data = privateData.get(this); + const moduleInfo = data.availableModules.get(moduleType); + + if (!moduleInfo) { + throw new Error(`Unknown module type: ${moduleType}`); + } + + if (data.loadedModules.has(moduleType)) { + return data.loadedModules.get(moduleType); + } + + try { + console.log(`📦 Loading module: ${moduleType}`); + + // Dynamic import of module + const modulePath = moduleInfo.path.startsWith('./') ? + moduleInfo.path : `./${moduleInfo.path}`; + + const { default: ModuleClass } = await import(modulePath); + + // Create instance with shared services + const moduleInstance = new ModuleClass( + this, // orchestrator reference + data.sharedServices.llmValidator, + data.sharedServices.prerequisiteEngine, + data.sharedServices.contextMemory + ); + + // Initialize module + await moduleInstance.init(); + + data.loadedModules.set(moduleType, moduleInstance); + moduleInfo.loaded = true; + moduleInfo.instance = moduleInstance; + + console.log(`✅ Module loaded: ${moduleType}`); + return moduleInstance; + + } catch (error) { + console.error(`❌ Failed to load module ${moduleType}:`, error); + throw error; + } + } + + async _unloadModule(moduleType) { + const data = privateData.get(this); + const module = data.loadedModules.get(moduleType); + + if (module) { + try { + await module.cleanup(); + data.loadedModules.delete(moduleType); + + const moduleInfo = data.availableModules.get(moduleType); + if (moduleInfo) { + moduleInfo.loaded = false; + moduleInfo.instance = null; + } + + console.log(`📤 Module unloaded: ${moduleType}`); + + } catch (error) { + console.error(`❌ Error unloading module ${moduleType}:`, error); + } + } + } + + async _unloadAllModules() { + const data = privateData.get(this); + const moduleTypes = Array.from(data.loadedModules.keys()); + + for (const moduleType of moduleTypes) { + await this._unloadModule(moduleType); + } + } + + async _analyzePrerequisites(chapterContent) { + const data = privateData.get(this); + + // Use PrerequisiteEngine to analyze chapter content + const prerequisites = data.sharedServices.prerequisiteEngine.analyzeChapter(chapterContent); + + console.log('📊 Prerequisites analyzed:', prerequisites); + } + + async _generateExerciseSequence() { + const data = privateData.get(this); + + // Generate exercise sequence based on content and mastery + const chapterContent = data.sessionState.chapterContent; + const masteredVocab = data.sessionState.masteredVocabulary; + const masteredPhrases = data.sessionState.masteredPhrases; + + // Filter content to focus on non-mastered items + const allVocab = Object.keys(chapterContent.vocabulary || {}); + const allPhrases = Object.keys(chapterContent.phrases || {}); + + const unmasteredVocab = allVocab.filter(word => !masteredVocab.has(word)); + const unmasteredPhrases = allPhrases.filter(phrase => !masteredPhrases.has(phrase)); + + console.log(`📊 Content analysis:`); + console.log(` 📚 Vocabulary: ${unmasteredVocab.length}/${allVocab.length} unmastered`); + console.log(` 💬 Phrases: ${unmasteredPhrases.length}/${allPhrases.length} unmastered`); + + const sequence = []; + + // Create vocabulary groups (focus on unmastered, but include some mastered for review) + const vocabGroupSize = 5; + const vocabGroups = Math.ceil(unmasteredVocab.length / vocabGroupSize); + + for (let i = 0; i < vocabGroups; i++) { + sequence.push({ + type: 'vocabulary', + subtype: 'group', + groupSize: vocabGroupSize, + groupIndex: i, + adaptive: true // Mark as adaptive sequence + }); + } + + // Add unmastered phrases (prioritize new content) + unmasteredPhrases.forEach((phrase, index) => { + if (index < 10) { // Limit to 10 phrases per session + sequence.push({ + type: 'phrase', + subtype: 'individual', + index: allPhrases.indexOf(phrase), + adaptive: true + }); + } + }); + + // Add some review items if we have extra capacity + if (sequence.length < 15) { + const reviewVocab = [...masteredVocab].slice(0, 3); + const reviewPhrases = [...masteredPhrases].slice(0, 2); + + reviewVocab.forEach(word => { + sequence.push({ + type: 'vocabulary', + subtype: 'review', + word: word, + adaptive: true + }); + }); + + reviewPhrases.forEach(phrase => { + sequence.push({ + type: 'phrase', + subtype: 'review', + index: allPhrases.indexOf(phrase), + adaptive: true + }); + }); + } + + // Shuffle for variety + for (let i = sequence.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [sequence[i], sequence[j]] = [sequence[j], sequence[i]]; + } + + const adaptiveInfo = unmasteredVocab.length === 0 && unmasteredPhrases.length === 0 ? + ' (Review mode - all content mastered!)' : + ' (Adaptive - focusing on unmastered content)'; + + console.log(`🧠 Generated adaptive sequence: ${sequence.length} exercises${adaptiveInfo}`); + + data.sessionState.exerciseSequence = sequence; + data.sessionState.sequenceIndex = 0; + } + + async _startNextExercise() { + const data = privateData.get(this); + const sequence = data.sessionState.exerciseSequence; + const currentIndex = data.sessionState.sequenceIndex; + + if (currentIndex >= sequence.length) { + // End AI reporting session + if (data.sharedServices.llmValidator && data.sharedServices.aiReportInterface) { + const sessionStats = this.getSessionProgress(); + + // End the report session + data.sharedServices.llmValidator.endReportSession(); + + // Notify the report interface + data.sharedServices.aiReportInterface.onSessionEnd({ + exerciseCount: sequence.length, + averageScore: sessionStats.averageScore || 0, + completedAt: new Date() + }); + + console.log('📊 Ended AI report session'); + } + + // Session complete - mark as completed and save + const currentChapter = data.sessionState.currentChapter; + if (currentChapter && window.markChapterCompleted) { + try { + await window.markChapterCompleted(currentChapter.bookId, currentChapter.chapterId); + console.log(`🏆 Chapter marked as completed: ${currentChapter.bookId}/${currentChapter.chapterId}`); + } catch (error) { + console.warn('Failed to mark chapter as completed:', error); + } + } + + this._eventBus.emit('drs:sessionComplete', this.getSessionProgress(), this.name); + return; + } + + const exercise = sequence[currentIndex]; + await this.switchToModule(exercise.type); + } + + async _getExerciseData(moduleType) { + const data = privateData.get(this); + const chapterContent = data.sessionState.chapterContent; + const sequence = data.sessionState.exerciseSequence; + const currentExercise = sequence[data.sessionState.sequenceIndex]; + + // Generate exercise data based on module type and current exercise parameters + switch (moduleType) { + case 'vocabulary': + return this._generateVocabularyExerciseData(chapterContent, currentExercise); + case 'phrase': + return this._generatePhraseExerciseData(chapterContent, currentExercise); + case 'text': + return this._generateTextExerciseData(chapterContent, currentExercise); + default: + return { type: moduleType, content: chapterContent }; + } + } + + _generateVocabularyExerciseData(chapterContent, exercise) { + const vocabulary = chapterContent.vocabulary || {}; + const vocabArray = Object.entries(vocabulary); + + const startIndex = exercise.groupIndex * exercise.groupSize; + const endIndex = Math.min(startIndex + exercise.groupSize, vocabArray.length); + const vocabGroup = vocabArray.slice(startIndex, endIndex); + + return { + type: 'vocabulary', + subtype: exercise.subtype, + groupIndex: exercise.groupIndex, + vocabulary: vocabGroup.map(([word, data]) => ({ + word, + translation: data.user_language, + pronunciation: data.pronunciation, + type: data.type + })) + }; + } + + _generatePhraseExerciseData(chapterContent, exercise) { + const phrases = chapterContent.phrases || {}; + const phraseEntries = Object.entries(phrases); + const phraseIndex = exercise.index || 0; + + // Check if phrase exists at this index + if (phraseIndex >= phraseEntries.length) { + console.warn(`⚠️ Phrase at index ${phraseIndex} not found (total: ${phraseEntries.length})`); + return null; + } + + const [phraseText, phraseData] = phraseEntries[phraseIndex]; + + // Create phrase object for compatibility + const phrase = { + id: `phrase_${phraseIndex}`, + english: phraseText, + text: phraseText, + translation: phraseData.user_language, + user_language: phraseData.user_language, + pronunciation: phraseData.pronunciation, + context: phraseData.context || 'general', + ...phraseData + }; + + // Verify prerequisites for this phrase + const data = privateData.get(this); + const unlockStatus = data.sharedServices.prerequisiteEngine.canUnlock('phrase', phrase); + + return { + type: 'phrase', + subtype: exercise.subtype, + phrase: phrase, + phraseIndex: phraseIndex, + totalPhrases: phraseEntries.length, + unlockStatus: unlockStatus, + chapterContent: chapterContent, // For language detection + metadata: { + userLanguage: chapterContent.metadata?.userLanguage || 'English', + targetLanguage: chapterContent.metadata?.targetLanguage || 'French' + } + }; + } + + _generateTextExerciseData(chapterContent, exercise) { + const texts = chapterContent.texts || []; + const textIndex = exercise.textIndex || 0; + const text = texts[textIndex]; + + return { + type: 'text', + subtype: exercise.subtype, + text, + sentenceIndex: exercise.sentenceIndex || 0 + }; + } + + // Event Handlers + + async _handleStartSession(event) { + const { bookId, chapterId } = event.data; + await this.startRevisionSession(bookId, chapterId); + } + + async _handleSwitchModule(event) { + const { moduleType } = event.data; + await this.switchToModule(moduleType); + } + + async _handleUpdateProgress(event) { + const data = privateData.get(this); + const { type, item, mastered } = event.data; + const currentChapter = data.sessionState.currentChapter; + + if (type === 'vocabulary' && mastered) { + data.sessionState.masteredVocabulary.add(item); + // Save to persistent storage with metadata + if (currentChapter && window.addMasteredItem) { + try { + const metadata = { + exerciseType: 'vocabulary', + sessionId: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + moduleType: 'VocabularyModule', + sequenceIndex: data.sessionState.sequenceIndex + }; + await window.addMasteredItem(currentChapter.bookId, currentChapter.chapterId, 'vocabulary', item, metadata); + } catch (error) { + console.warn('Failed to save vocabulary progress:', error); + } + } + } else if (type === 'phrase' && mastered) { + data.sessionState.masteredPhrases.add(item); + // Save to persistent storage with metadata + if (currentChapter && window.addMasteredItem) { + try { + const metadata = { + exerciseType: 'phrase', + sessionId: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + moduleType: 'PhraseModule', + sequenceIndex: data.sessionState.sequenceIndex, + aiValidated: true + }; + await window.addMasteredItem(currentChapter.bookId, currentChapter.chapterId, 'phrases', item, metadata); + } catch (error) { + console.warn('Failed to save phrase progress:', error); + } + } + } else if (type === 'grammar' && mastered) { + data.sessionState.masteredGrammar.add(item); + // Save to persistent storage with metadata + if (currentChapter && window.addMasteredItem) { + try { + const metadata = { + exerciseType: 'grammar', + sessionId: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + moduleType: 'GrammarModule', + sequenceIndex: data.sessionState.sequenceIndex + }; + await window.addMasteredItem(currentChapter.bookId, currentChapter.chapterId, 'grammar', item, metadata); + } catch (error) { + console.warn('Failed to save grammar progress:', error); + } + } + } + + this._eventBus.emit('drs:progressUpdated', this.getSessionProgress(), this.name); + } + + async _handleExerciseCompleted(event) { + const data = privateData.get(this); + data.sessionState.sequenceIndex++; + + // Move to next exercise + await this._startNextExercise(); + } +} + +export default SmartPreviewOrchestrator; \ No newline at end of file diff --git a/src/DRS/UnifiedDRS.js b/src/DRS/UnifiedDRS.js new file mode 100644 index 0000000..9fe553e --- /dev/null +++ b/src/DRS/UnifiedDRS.js @@ -0,0 +1,690 @@ +/** + * UnifiedDRS - Modern DRS implementation using extracted UI components + * Replaces individual DRS modules with a unified, component-based approach + */ + +import Module from '../core/Module.js'; +import componentRegistry from '../components/ComponentRegistry.js'; + +class UnifiedDRS extends Module { + constructor(name, dependencies, config) { + super(name, ['eventBus', 'contentLoader']); + + // Validate dependencies + if (!dependencies.eventBus) { + throw new Error('UnifiedDRS requires EventBus dependency'); + } + if (!dependencies.contentLoader) { + throw new Error('UnifiedDRS requires ContentLoader dependency'); + } + + this._eventBus = dependencies.eventBus; + this._contentLoader = dependencies.contentLoader; + this._config = { + exerciseTypes: ['text', 'audio', 'image', 'grammar'], + defaultDifficulty: 'medium', + showProgress: true, + showHints: true, + autoNext: false, + ...config + }; + + // UI state + this._currentExercise = null; + this._currentStep = 0; + this._totalSteps = 0; + this._userProgress = { + correct: 0, + total: 0, + hints: 0, + timeSpent: 0 + }; + + // Component instances + this._components = { + mainCard: null, + progressBar: null, + nextButton: null, + hintButton: null, + hintPanel: null, + resultPanel: null + }; + + // Container and timing + this._container = null; + this._isActive = false; + this._startTime = null; + + Object.seal(this); + } + + async init() { + this._validateNotDestroyed(); + + console.log('🎓 Initializing Unified DRS...'); + + // Initialize component registry + await componentRegistry.init(); + + // Set up event listeners + this._eventBus.on('drs:start', this._handleStart.bind(this), this.name); + this._eventBus.on('drs:next', this._handleNext.bind(this), this.name); + this._eventBus.on('drs:hint', this._handleHint.bind(this), this.name); + this._eventBus.on('drs:submit', this._handleSubmit.bind(this), this.name); + this._eventBus.on('content:loaded', this._handleContentLoaded.bind(this), this.name); + + this._setInitialized(); + console.log('✅ Unified DRS initialized'); + } + + async destroy() { + this._validateNotDestroyed(); + + console.log('🧹 Destroying Unified DRS...'); + + // Clean up UI + this._cleanupUI(); + + // Clean up components + Object.values(this._components).forEach(component => { + if (component && componentRegistry) { + componentRegistry.destroy(component); + } + }); + + this._setDestroyed(); + console.log('✅ Unified DRS destroyed'); + } + + // Public API + + /** + * Start a new exercise session + * @param {HTMLElement} container - Container element + * @param {Object} exerciseConfig - Exercise configuration + */ + async start(container, exerciseConfig = {}) { + this._validateInitialized(); + + if (!container) { + throw new Error('Container element is required'); + } + + console.log('🎯 Starting Unified DRS exercise...'); + + this._container = container; + this._currentStep = 0; + this._totalSteps = 0; + this._userProgress = { correct: 0, total: 0, hints: 0, timeSpent: 0 }; + this._isActive = true; + + // Load exercise content + const exerciseType = exerciseConfig.type || this._config.exerciseTypes[0]; + const content = await this._loadExerciseContent(exerciseType, exerciseConfig); + + if (!content) { + throw new Error(`Failed to load exercise content for type: ${exerciseType}`); + } + + this._currentExercise = content; + this._totalSteps = content.steps ? content.steps.length : 1; + + // Create UI + await this._createUI(); + + // Start first step + this._showCurrentStep(); + + // Start timing + this._startTime = Date.now(); + + // Emit started event + this._eventBus.emit('drs:started', { + type: exerciseType, + steps: this._totalSteps, + config: exerciseConfig + }, this.name); + + console.log(`✅ Exercise started: ${exerciseType} (${this._totalSteps} steps)`); + } + + /** + * Get current progress + */ + getProgress() { + return { + currentStep: this._currentStep + 1, + totalSteps: this._totalSteps, + percentage: this._totalSteps > 0 ? Math.round(((this._currentStep + 1) / this._totalSteps) * 100) : 0, + userStats: { ...this._userProgress }, + timeSpent: this._startTime ? Date.now() - this._startTime : 0 + }; + } + + /** + * Check if exercise is currently active + */ + isActive() { + return this._isActive; + } + + // Private methods + + /** + * Load exercise content based on type + */ + async _loadExerciseContent(type, config) { + try { + console.log(`📚 Loading ${type} exercise content...`); + + // Request content from ContentLoader + const contentRequest = { + type: 'exercise', + subtype: type, + difficulty: config.difficulty || this._config.defaultDifficulty, + bookId: config.bookId, + chapterId: config.chapterId, + ...config + }; + + console.log('📋 UnifiedDRS content request:', contentRequest); + const content = await this._contentLoader.loadExercise(contentRequest); + + console.log(`✅ UnifiedDRS content loaded:`, { + title: content.title, + stepsCount: content.steps ? content.steps.length : 0, + hasSteps: !!content.steps, + contentKeys: Object.keys(content), + firstStep: content.steps ? content.steps[0] : null + }); + + return content; + } catch (error) { + console.error(`❌ Failed to load ${type} exercise:`, error); + return null; + } + } + + /** + * Create the main UI using components + */ + async _createUI() { + if (!this._container) return; + + // Clear container + this._container.innerHTML = ''; + + // Create main exercise card + this._components.mainCard = componentRegistry.create('Card', { + title: this._currentExercise.title || 'Exercise', + type: 'exercise', + className: 'drs-main-card' + }); + + // Create progress bar if enabled + if (this._config.showProgress) { + this._components.progressBar = componentRegistry.create('ProgressBar', { + value: 0, + max: this._totalSteps, + color: 'primary', + showLabel: true, + className: 'drs-progress' + }); + } + + // Create navigation buttons + this._components.nextButton = componentRegistry.create('Button', { + text: this._currentStep === this._totalSteps - 1 ? 'Finish' : 'Next', + type: 'primary', + onClick: () => this._handleNext(), + className: 'drs-next-btn' + }); + + // Create hint button if enabled + if (this._config.showHints) { + this._components.hintButton = componentRegistry.create('Button', { + text: 'Hint', + icon: '💡', + type: 'outline', + onClick: () => this._handleHint(), + className: 'drs-hint-btn' + }); + } + + // Create hint panel (initially hidden) + this._components.hintPanel = componentRegistry.create('Panel', { + title: 'Hint', + type: 'hint', + collapsible: true, + collapsed: true, + className: 'drs-hint-panel' + }); + + // Create result panel (initially hidden) + this._components.resultPanel = componentRegistry.create('Panel', { + title: 'Result', + type: 'success', + className: 'drs-result-panel' + }); + + // Assemble UI + this._assembleUI(); + } + + /** + * Assemble all UI components into the container + */ + _assembleUI() { + // Progress bar at top + if (this._components.progressBar) { + this._container.appendChild(this._components.progressBar.getElement()); + } + + // Main card + this._container.appendChild(this._components.mainCard.getElement()); + + // Action buttons container + const actionsDiv = document.createElement('div'); + actionsDiv.className = 'drs-actions'; + actionsDiv.style.cssText = ` + display: flex; + gap: 12px; + margin-top: 16px; + justify-content: space-between; + `; + + // Left side: hint button + if (this._components.hintButton) { + actionsDiv.appendChild(this._components.hintButton.getElement()); + } + + // Right side: next button + const rightActions = document.createElement('div'); + rightActions.style.cssText = 'margin-left: auto;'; + rightActions.appendChild(this._components.nextButton.getElement()); + actionsDiv.appendChild(rightActions); + + this._container.appendChild(actionsDiv); + + // Panels at bottom + this._container.appendChild(this._components.hintPanel.getElement()); + this._container.appendChild(this._components.resultPanel.getElement()); + + // Initially hide panels + this._components.hintPanel.hide(); + this._components.resultPanel.hide(); + } + + /** + * Show current exercise step + */ + _showCurrentStep() { + if (!this._currentExercise || !this._components.mainCard) return; + + const step = this._currentExercise.steps?.[this._currentStep] || this._currentExercise; + console.log(`📖 Showing step ${this._currentStep + 1}/${this._totalSteps}:`, { + id: step.id, + type: step.type, + hasContent: !!step.content, + contentKeys: step.content ? Object.keys(step.content) : [], + step: step + }); + + const progress = this.getProgress(); + + // Update progress bar + if (this._components.progressBar) { + this._components.progressBar.setValue(this._currentStep + 1); + this._components.progressBar.setLabel(`Step ${progress.currentStep} of ${progress.totalSteps}`); + } + + // Update main card content + const stepContent = this._generateStepContent(step); + this._components.mainCard.setContent(stepContent); + + // Update next button text + if (this._components.nextButton) { + const isLast = this._currentStep === this._totalSteps - 1; + this._components.nextButton.setText(isLast ? 'Finish' : 'Next'); + } + + // Hide result panel for new step + if (this._components.resultPanel) { + this._components.resultPanel.hide(); + } + } + + /** + * Generate HTML content for current step + */ + _generateStepContent(step) { + let content = ''; + + // Step instruction + if (step.instruction) { + content += `
${step.instruction}
`; + } + + // Content based on type + if (step.type === 'text' || step.type === 'multiple-choice' || !step.type) { + content += this._generateTextContent(step); + } else if (step.type === 'audio') { + content += this._generateAudioContent(step); + } else if (step.type === 'image') { + content += this._generateImageContent(step); + } else if (step.type === 'grammar' || step.type === 'fill-blank') { + content += this._generateGrammarContent(step); + } else { + // Fallback for unknown types + console.warn(`⚠️ Unknown step type: ${step.type}, falling back to text`); + content += this._generateTextContent(step); + } + + return content; + } + + /** + * Generate text exercise content + */ + _generateTextContent(step) { + let content = `
`; + + // Use step.content if available (new format from ContentLoader) + const stepData = step.content || step; + + // Show passage/text + if (stepData.passage) { + content += `
${stepData.passage}
`; + } else if (stepData.text) { + content += `
${stepData.text}
`; + } + + // Show question + if (stepData.question) { + content += `
${stepData.question}
`; + } + + // Show options (multiple choice) + const options = stepData.options || step.options; + if (options && options.length > 0) { + console.log('🎯 Generating radio options:', options); + content += `
`; + options.forEach((option, index) => { + content += ` + + `; + }); + content += `
`; + } else { + console.warn('⚠️ No options found for multiple choice step:', step); + } + + content += `
`; + return content; + } + + /** + * Generate audio exercise content + */ + _generateAudioContent(step) { + let content = `
`; + + if (step.audioUrl) { + content += ` +
+ +
+ `; + } + + if (step.transcript) { + content += ``; + content += ``; + } + + if (step.question) { + content += `
${step.question}
`; + } + + content += `
`; + return content; + } + + /** + * Generate image exercise content + */ + _generateImageContent(step) { + let content = `
`; + + if (step.imageUrl) { + content += ` +
+ ${step.description || 'Exercise image'} +
+ `; + } + + if (step.question) { + content += `
${step.question}
`; + } + + content += `
`; + return content; + } + + /** + * Generate grammar exercise content + */ + _generateGrammarContent(step) { + let content = `
`; + + if (step.sentence) { + // Fill-in-the-blank style + const processedSentence = step.sentence.replace(/_+/g, ''); + content += `
${processedSentence}
`; + } + + if (step.explanation) { + content += `
${step.explanation}
`; + } + + content += `
`; + return content; + } + + /** + * Event handlers + */ + _handleStart() { + console.log('📢 DRS start event received'); + } + + _handleNext() { + console.log('📋 Next button clicked'); + + if (!this._isActive) { + console.log('⚠️ DRS not active, ignoring next'); + return; + } + + console.log(`Current step: ${this._currentStep}/${this._totalSteps}`); + + // Validate current step + const isValid = this._validateCurrentStep(); + if (!isValid) { + console.log('❌ Validation failed, showing error'); + this._showValidationError(); + return; + } + + console.log('✅ Validation passed, moving to next step'); + + // Move to next step or finish + if (this._currentStep < this._totalSteps - 1) { + // Emit step completed (for the step we just finished) + this._eventBus.emit('drs:step-completed', { + step: this._currentStep, // The step we just completed + total: this._totalSteps + }, this.name); + + this._currentStep++; + this._showCurrentStep(); + } else { + // Emit final step completed + this._eventBus.emit('drs:step-completed', { + step: this._currentStep, + total: this._totalSteps + }, this.name); + + this._finishExercise(); + } + } + + _handleHint() { + if (!this._components.hintPanel) return; + + const step = this._currentExercise.steps?.[this._currentStep] || this._currentExercise; + + if (step.hint) { + this._components.hintPanel.setContent(step.hint); + this._components.hintPanel.show(); + this._components.hintPanel.expand(); + + // Track hint usage + this._userProgress.hints++; + + // Emit hint used event + this._eventBus.emit('drs:hint-used', { + step: this._currentStep, + hint: step.hint + }, this.name); + } + } + + _handleSubmit(event) { + console.log('📝 Submit event:', event); + this._handleNext(); + } + + _handleContentLoaded(event) { + console.log('📚 Content loaded event:', event); + } + + /** + * Validate current step input + */ + _validateCurrentStep() { + console.log('🔍 Validating current step...'); + + // Basic validation - check for required inputs + const inputs = this._container.querySelectorAll('input[required], input[type="radio"]'); + console.log(`Found ${inputs.length} inputs to validate`); + + if (inputs.length === 0) { + console.log('✅ No validation needed'); + return true; // No validation needed + } + + // Check radio buttons + const radioGroups = this._container.querySelectorAll('input[type="radio"]'); + if (radioGroups.length > 0) { + console.log(`Checking ${radioGroups.length} radio buttons...`); + const groupNames = new Set(); + radioGroups.forEach(radio => groupNames.add(radio.name)); + + for (const groupName of groupNames) { + const checked = this._container.querySelector(`input[name="${groupName}"]:checked`); + if (!checked) { + console.log(`❌ No radio button selected for group: ${groupName}`); + return false; + } + console.log(`✅ Radio group "${groupName}" has selection: ${checked.value}`); + } + } + + // Check text inputs + const textInputs = this._container.querySelectorAll('input[required]:not([type="radio"])'); + if (textInputs.length > 0) { + console.log(`Checking ${textInputs.length} text inputs...`); + for (const input of textInputs) { + if (!input.value.trim()) { + console.log(`❌ Required text input is empty: ${input.name || input.id}`); + return false; + } + console.log(`✅ Text input has value: ${input.value}`); + } + } + + console.log('✅ All validations passed'); + return true; + } + + /** + * Show validation error + */ + _showValidationError() { + if (this._components.resultPanel) { + this._components.resultPanel.setType('warning'); + this._components.resultPanel.setContent('Please answer all questions before continuing.'); + this._components.resultPanel.show(); + + setTimeout(() => { + this._components.resultPanel.hide(); + }, 3000); + } + } + + /** + * Finish exercise + */ + _finishExercise() { + this._isActive = false; + + // Calculate final stats + this._userProgress.timeSpent = Date.now() - this._startTime; + const finalStats = this.getProgress(); + + // Show completion message + if (this._components.resultPanel) { + this._components.resultPanel.setType('success'); + this._components.resultPanel.setContent(` +

Exercise Complete!

+

Time spent: ${Math.round(finalStats.timeSpent / 1000)}s

+

Hints used: ${this._userProgress.hints}

+ `); + this._components.resultPanel.show(); + } + + // Hide next button + if (this._components.nextButton) { + this._components.nextButton.hide(); + } + + // Emit completion event + this._eventBus.emit('drs:completed', { + stats: finalStats, + exercise: this._currentExercise + }, this.name); + + console.log('🎉 Exercise completed!', finalStats); + } + + /** + * Clean up UI + */ + _cleanupUI() { + if (this._container) { + this._container.innerHTML = ''; + this._container = null; + } + this._isActive = false; + } +} + +export default UnifiedDRS; \ No newline at end of file diff --git a/src/DRS/exercise-modules/AudioModule.js b/src/DRS/exercise-modules/AudioModule.js new file mode 100644 index 0000000..c00019c --- /dev/null +++ b/src/DRS/exercise-modules/AudioModule.js @@ -0,0 +1,1884 @@ +/** + * AudioModule - Listening comprehension exercises with AI validation + * Handles audio passages with listening questions and pronunciation practice + */ + +import ExerciseModuleInterface from '../interfaces/ExerciseModuleInterface.js'; + +class AudioModule extends ExerciseModuleInterface { + constructor(orchestrator, llmValidator, prerequisiteEngine, contextMemory) { + super(); + + // Validate dependencies + if (!orchestrator || !llmValidator || !prerequisiteEngine || !contextMemory) { + throw new Error('AudioModule requires all service dependencies'); + } + + this.orchestrator = orchestrator; + this.llmValidator = llmValidator; + this.prerequisiteEngine = prerequisiteEngine; + this.contextMemory = contextMemory; + + // Module state + this.initialized = false; + this.container = null; + this.currentExerciseData = null; + this.currentAudio = null; + this.currentQuestion = null; + this.questionIndex = 0; + this.questionResults = []; + this.validationInProgress = false; + this.lastValidationResult = null; + this.aiAvailable = false; + this.audioElement = null; + this.audioProgress = 0; + this.playCount = 0; + + // Configuration + this.config = { + requiredProvider: 'openai', // Prefer OpenAI for audio analysis + model: 'gpt-4o-mini', + temperature: 0.2, + maxTokens: 800, + timeout: 45000, // Longer timeout for complex audio analysis + questionsPerAudio: 3, // Default number of questions per audio + maxPlaybacks: 5, // Maximum playbacks before penalty + showTranscriptAfter: 3, // Show transcript after N playbacks + allowReplay: true // Allow replaying the audio + }; + + // Languages configuration + this.languages = { + userLanguage: 'English', + targetLanguage: 'French' + }; + + // Bind methods + this._handleUserInput = this._handleUserInput.bind(this); + this._handleNextQuestion = this._handleNextQuestion.bind(this); + this._handleRetry = this._handleRetry.bind(this); + this._handlePlayAudio = this._handlePlayAudio.bind(this); + this._handleAudioProgress = this._handleAudioProgress.bind(this); + this._handleAudioEnded = this._handleAudioEnded.bind(this); + } + + async init() { + if (this.initialized) return; + + console.log('🎧 Initializing AudioModule...'); + + // Test AI connectivity - recommended for audio comprehension + try { + const testResult = await this.llmValidator.testConnectivity(); + if (testResult.success) { + console.log(`✅ AI connectivity verified for audio analysis (providers: ${testResult.availableProviders?.join(', ') || testResult.provider})`); + this.aiAvailable = true; + } else { + console.warn('⚠️ AI connection failed - audio comprehension will be limited:', testResult.error); + this.aiAvailable = false; + } + } catch (error) { + console.warn('⚠️ AI connectivity test failed - using basic audio analysis:', error.message); + this.aiAvailable = false; + } + + this.initialized = true; + console.log(`✅ AudioModule initialized (AI: ${this.aiAvailable ? 'available for deep analysis' : 'limited - basic analysis only'})`); + } + + /** + * Check if module can run with current prerequisites + * @param {Array} prerequisites - List of learned vocabulary/concepts + * @param {Object} chapterContent - Full chapter content + * @returns {boolean} - True if module can run + */ + canRun(prerequisites, chapterContent) { + // Check if there are audio files and if prerequisites allow them + const audios = chapterContent?.audios || []; + if (audios.length === 0) return false; + + // Find audio files that can be unlocked with current prerequisites + const availableAudios = audios.filter(audio => { + const unlockStatus = this.prerequisiteEngine.canUnlock('audio', audio); + return unlockStatus.canUnlock; + }); + + return availableAudios.length > 0; + } + + /** + * Present exercise UI and content + * @param {HTMLElement} container - DOM container to render into + * @param {Object} exerciseData - Specific exercise data to present + * @returns {Promise} + */ + async present(container, exerciseData) { + if (!this.initialized) { + throw new Error('AudioModule must be initialized before use'); + } + + this.container = container; + this.currentExerciseData = exerciseData; + this.currentAudio = exerciseData.audio; + this.questionIndex = 0; + this.questionResults = []; + this.validationInProgress = false; + this.lastValidationResult = null; + this.audioProgress = 0; + this.playCount = 0; + + // Detect languages from chapter content + this._detectLanguages(exerciseData); + + // Generate or extract questions + this.questions = await this._prepareQuestions(this.currentAudio); + + console.log(`🎧 Presenting audio comprehension: "${this.currentAudio.title || 'Listening Exercise'}" (${this.questions.length} questions)`); + + // Render initial UI + await this._renderAudioExercise(); + + // Start with audio listening phase + this._showAudioListening(); + } + + /** + * Validate user input with AI for deep audio comprehension + * @param {string} userInput - User's response + * @param {Object} context - Exercise context + * @returns {Promise} - Validation result with score and feedback + */ + async validate(userInput, context) { + if (!userInput || !userInput.trim()) { + throw new Error('Please provide an answer'); + } + + if (!this.currentAudio || !this.currentQuestion) { + throw new Error('No audio or question loaded for validation'); + } + + console.log(`🎧 Validating audio comprehension answer for question ${this.questionIndex + 1}`); + + // Build comprehensive prompt for audio comprehension + const prompt = this._buildAudioComprehensionPrompt(userInput); + + try { + // Use AI validation with structured response + const aiResponse = await this.llmValidator.iaEngine.validateEducationalContent(prompt, { + preferredProvider: this.config.requiredProvider, + temperature: this.config.temperature, + maxTokens: this.config.maxTokens, + timeout: this.config.timeout, + systemPrompt: `You are an expert listening comprehension evaluator. Focus on understanding audio content and critical listening skills. ALWAYS respond in the exact format: [answer]yes/no [explanation]your detailed analysis here` + }); + + // Parse structured response + const parsedResult = this._parseStructuredResponse(aiResponse); + + // Apply playback penalty if too many replays + if (this.playCount > this.config.maxPlaybacks) { + parsedResult.score = Math.max(parsedResult.score - 10, 40); + parsedResult.feedback += ` (Note: Score reduced due to excessive playbacks - practice active listening on first attempts)`; + } + + // Record interaction in context memory + this.contextMemory.recordInteraction({ + type: 'audio', + subtype: 'comprehension', + content: { + audio: this.currentAudio, + question: this.currentQuestion, + audioTitle: this.currentAudio.title || 'Listening Exercise', + audioDuration: this.currentAudio.duration || 0, + playCount: this.playCount + }, + userResponse: userInput.trim(), + validation: parsedResult, + context: { + languages: this.languages, + questionIndex: this.questionIndex, + totalQuestions: this.questions.length, + playbacks: this.playCount + } + }); + + return parsedResult; + + } catch (error) { + console.error('❌ AI audio comprehension validation failed:', error); + + // Fallback to basic keyword analysis if AI fails + if (!this.aiAvailable) { + return this._performBasicAudioValidation(userInput); + } + + throw new Error(`Audio comprehension validation failed: ${error.message}. Please check your answer and try again.`); + } + } + + /** + * Get current progress data + * @returns {ProgressData} - Progress information for this module + */ + getProgress() { + const totalQuestions = this.questions ? this.questions.length : 0; + const completedQuestions = this.questionResults.length; + const correctAnswers = this.questionResults.filter(result => result.correct).length; + + return { + type: 'audio', + audioTitle: this.currentAudio?.title || 'Listening Exercise', + totalQuestions, + completedQuestions, + correctAnswers, + currentQuestionIndex: this.questionIndex, + questionResults: this.questionResults, + progressPercentage: totalQuestions > 0 ? Math.round((completedQuestions / totalQuestions) * 100) : 0, + comprehensionRate: completedQuestions > 0 ? Math.round((correctAnswers / completedQuestions) * 100) : 0, + playbackCount: this.playCount, + aiAnalysisAvailable: this.aiAvailable + }; + } + + /** + * Clean up and prepare for unloading + */ + cleanup() { + console.log('🧹 Cleaning up AudioModule...'); + + // Stop audio playback + if (this.audioElement) { + this.audioElement.pause(); + this.audioElement.currentTime = 0; + } + + // Remove event listeners + if (this.container) { + this.container.innerHTML = ''; + } + + // Reset state + this.container = null; + this.currentExerciseData = null; + this.currentAudio = null; + this.currentQuestion = null; + this.questionIndex = 0; + this.questionResults = []; + this.questions = null; + this.validationInProgress = false; + this.lastValidationResult = null; + this.audioElement = null; + this.audioProgress = 0; + this.playCount = 0; + + console.log('✅ AudioModule cleaned up'); + } + + /** + * Get module metadata + * @returns {Object} - Module information + */ + getMetadata() { + return { + name: 'AudioModule', + type: 'audio', + version: '1.0.0', + description: 'Listening comprehension exercises with AI-powered audio analysis', + capabilities: ['audio_comprehension', 'active_listening', 'pronunciation_feedback', 'ai_feedback'], + aiRequired: false, // Can work without AI but limited + config: this.config + }; + } + + // Private Methods + + /** + * Detect languages from exercise data + * @private + */ + _detectLanguages(exerciseData) { + const chapterContent = this.currentExerciseData?.chapterContent; + + if (chapterContent?.metadata?.userLanguage) { + this.languages.userLanguage = chapterContent.metadata.userLanguage; + } + + if (chapterContent?.metadata?.targetLanguage) { + this.languages.targetLanguage = chapterContent.metadata.targetLanguage; + } + + console.log(`🌍 Audio languages detected: ${this.languages.userLanguage} -> ${this.languages.targetLanguage}`); + } + + /** + * Prepare questions for the audio + * @private + */ + async _prepareQuestions(audio) { + // If audio already has questions, use them + if (audio.questions && audio.questions.length > 0) { + return audio.questions.map((q, index) => ({ + id: `q${index + 1}`, + question: q.question || q.text || q, + type: q.type || 'open', + expectedAnswer: q.answer || q.expectedAnswer, + keywords: q.keywords || [], + difficulty: q.difficulty || 'medium', + requiresTranscript: q.requiresTranscript || false + })); + } + + // Generate default listening comprehension questions + const defaultQuestions = [ + { + id: 'main_content', + question: `What is the main topic or content of this audio?`, + type: 'open', + keywords: ['main', 'topic', 'about', 'content'], + difficulty: 'medium', + requiresTranscript: false + }, + { + id: 'specific_details', + question: `What specific details or information did you hear?`, + type: 'open', + keywords: ['details', 'specific', 'information', 'mentioned'], + difficulty: 'easy', + requiresTranscript: false + }, + { + id: 'comprehension', + question: `What can you understand or infer from the speaker's tone and context?`, + type: 'open', + keywords: ['tone', 'context', 'infer', 'understand', 'meaning'], + difficulty: 'hard', + requiresTranscript: false + } + ]; + + // Limit to configured number of questions + return defaultQuestions.slice(0, this.config.questionsPerAudio); + } + + /** + * Build comprehensive prompt for audio comprehension validation + * @private + */ + _buildAudioComprehensionPrompt(userAnswer) { + const audioTitle = this.currentAudio.title || 'Listening Exercise'; + const audioTranscript = this.currentAudio.transcript || ''; + const audioDuration = this.currentAudio.duration || 'Unknown'; + + return `You are evaluating listening comprehension for a language learning exercise. + +CRITICAL: You MUST respond in this EXACT format: [answer]yes/no [explanation]your detailed analysis here + +AUDIO CONTENT: +Title: "${audioTitle}" +Duration: ${audioDuration} +${audioTranscript ? `Transcript: "${audioTranscript}"` : 'Transcript: Not available'} + +QUESTION: ${this.currentQuestion.question} + +STUDENT RESPONSE: "${userAnswer}" + +EVALUATION CONTEXT: +- Exercise Type: Listening comprehension +- Languages: ${this.languages.userLanguage} -> ${this.languages.targetLanguage} +- Question Type: ${this.currentQuestion.type} +- Question Difficulty: ${this.currentQuestion.difficulty} +- Question ${this.questionIndex + 1} of ${this.questions.length} +- Audio Playbacks: ${this.playCount} + +EVALUATION CRITERIA: +- [answer]yes if the student demonstrates understanding of the audio content in relation to the question +- [answer]no if the response shows lack of comprehension or is unrelated to the audio +- Focus on LISTENING COMPREHENSION and UNDERSTANDING, not perfect language +- Accept different interpretations if they show understanding of the audio +- Consider that students may hear different details or focus on different aspects +- Reward active listening and attention to audio-specific elements (tone, emphasis, pronunciation) + +[explanation] should provide: +1. What the student understood correctly from the audio +2. What they might have missed or misunderstood +3. Encouragement and specific improvement suggestions for listening skills +4. Connection to broader audio meaning and context +5. Tips for better listening comprehension + +Format: [answer]yes/no [explanation]your comprehensive educational feedback here`; + } + + /** + * Parse structured AI response for audio comprehension + * @private + */ + _parseStructuredResponse(aiResponse) { + try { + let responseText = ''; + + // Extract text from AI response + if (typeof aiResponse === 'string') { + responseText = aiResponse; + } else if (aiResponse.content) { + responseText = aiResponse.content; + } else if (aiResponse.text) { + responseText = aiResponse.text; + } else { + responseText = JSON.stringify(aiResponse); + } + + console.log('🔍 Parsing AI audio comprehension response:', responseText.substring(0, 150) + '...'); + + // Extract [answer] - case insensitive + const answerMatch = responseText.match(/\[answer\](yes|no)/i); + if (!answerMatch) { + throw new Error('AI response missing [answer] format'); + } + + // Extract [explanation] - multiline support + const explanationMatch = responseText.match(/\[explanation\](.+)/s); + if (!explanationMatch) { + throw new Error('AI response missing [explanation] format'); + } + + const isCorrect = answerMatch[1].toLowerCase() === 'yes'; + const explanation = explanationMatch[1].trim(); + + // Higher scores for audio comprehension to encourage listening + const result = { + score: isCorrect ? 85 : 55, // Slightly lower than text due to listening difficulty + correct: isCorrect, + feedback: explanation, + answer: answerMatch[1].toLowerCase(), + explanation: explanation, + timestamp: new Date().toISOString(), + provider: this.config.requiredProvider, + model: this.config.model, + cached: false, + formatValid: true, + audioComprehension: true + }; + + console.log(`✅ AI audio comprehension parsed: ${result.answer} - Score: ${result.score}`); + return result; + + } catch (error) { + console.error('❌ Failed to parse AI audio comprehension response:', error); + console.error('Raw response:', aiResponse); + + throw new Error(`AI response format invalid: ${error.message}`); + } + } + + /** + * Perform basic audio validation when AI is unavailable + * @private + */ + _performBasicAudioValidation(userAnswer) { + console.log('🔍 Performing basic audio validation (AI unavailable)'); + + const answerLength = userAnswer.trim().length; + const hasKeywords = this.currentQuestion.keywords?.some(keyword => + userAnswer.toLowerCase().includes(keyword.toLowerCase()) + ); + + // Basic scoring based on answer length and keyword presence + let score = 35; // Lower base score for audio (harder without AI) + + if (answerLength > 15) score += 15; // Substantial answer + if (answerLength > 40) score += 10; // Detailed answer + if (hasKeywords) score += 20; // Contains relevant keywords + if (answerLength > 80) score += 10; // Very detailed + if (this.playCount <= 2) score += 10; // Bonus for fewer playbacks + + const isCorrect = score >= 65; + + return { + score: Math.min(score, 100), + correct: isCorrect, + feedback: isCorrect + ? "Good listening comprehension demonstrated! Your answer shows understanding of the audio content." + : "Your answer could be more detailed. Try to listen for specific information and include more details from what you heard.", + timestamp: new Date().toISOString(), + provider: 'basic_audio_analysis', + model: 'keyword_length_analysis', + cached: false, + mockGenerated: true, + audioComprehension: true + }; + } + + /** + * Render the audio exercise interface + * @private + */ + async _renderAudioExercise() { + if (!this.container || !this.currentAudio) return; + + const audioTitle = this.currentAudio.title || 'Listening Exercise'; + const audioDuration = this.currentAudio.duration ? `${this.currentAudio.duration}s` : 'Unknown'; + const audioUrl = this.currentAudio.url || this.currentAudio.src || ''; + + this.container.innerHTML = ` +
+
+

🎧 Listening Comprehension

+
+ + ${this.questions?.length || 0} questions • ${audioDuration} + ${!this.aiAvailable ? ' • ⚠️ Limited analysis mode' : ' • 🧠 AI analysis'} + +
+
+ +
+
+
+
+

${audioTitle}

+
+ Plays: 0 + Duration: ${audioDuration} +
+
+ +
+ ${audioUrl ? ` + + ` : ` +
+ 🎵 Audio file placeholder (${audioTitle}) +
+ `} + +
+ + +
+
+
+
+
+ 0:00 / ${audioDuration} +
+
+
+
+ + + +
+ +
+
+
+ + + + + + +
+
+ `; + + // Add CSS styles + this._addStyles(); + + // Setup audio element + this._setupAudioElement(); + + // Add event listeners + this._setupEventListeners(); + } + + /** + * Setup audio element and controls + * @private + */ + _setupAudioElement() { + this.audioElement = document.getElementById('audio-element'); + + if (this.audioElement) { + this.audioElement.addEventListener('loadedmetadata', () => { + const totalTime = document.getElementById('total-time'); + if (totalTime && this.audioElement.duration) { + const minutes = Math.floor(this.audioElement.duration / 60); + const seconds = Math.floor(this.audioElement.duration % 60); + totalTime.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; + } + }); + + this.audioElement.addEventListener('timeupdate', this._handleAudioProgress); + this.audioElement.addEventListener('ended', this._handleAudioEnded); + } + } + + /** + * Setup event listeners for audio exercise + * @private + */ + _setupEventListeners() { + const playAudioBtn = document.getElementById('play-audio-btn'); + const replayAudioBtn = document.getElementById('replay-audio-btn'); + const startQuestionsBtn = document.getElementById('start-questions-btn'); + const answerInput = document.getElementById('answer-input'); + const validateBtn = document.getElementById('validate-answer-btn'); + const retryBtn = document.getElementById('retry-answer-btn'); + const nextBtn = document.getElementById('next-question-btn'); + const finishBtn = document.getElementById('finish-audio-btn'); + + // Audio control buttons + if (playAudioBtn) { + playAudioBtn.onclick = this._handlePlayAudio; + } + + if (replayAudioBtn) { + replayAudioBtn.onclick = this._handlePlayAudio; + } + + // Start questions button + if (startQuestionsBtn) { + startQuestionsBtn.onclick = () => this._startQuestions(); + } + + // Answer input validation + if (answerInput) { + answerInput.addEventListener('input', () => { + const hasText = answerInput.value.trim().length > 0; + if (validateBtn) { + validateBtn.disabled = !hasText || this.validationInProgress; + } + }); + + // Allow Ctrl+Enter to validate + answerInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !validateBtn.disabled) { + e.preventDefault(); + this._handleUserInput(); + } + }); + } + + // Validate button + if (validateBtn) { + validateBtn.onclick = this._handleUserInput; + } + + // Action buttons + if (retryBtn) retryBtn.onclick = this._handleRetry; + if (nextBtn) nextBtn.onclick = this._handleNextQuestion; + if (finishBtn) finishBtn.onclick = () => this._completeAudioExercise(); + } + + /** + * Handle audio playback + * @private + */ + _handlePlayAudio() { + if (!this.audioElement) { + // For demo mode without actual audio + this._simulateAudioPlayback(); + return; + } + + const playBtn = document.getElementById('play-audio-btn'); + const replayBtn = document.getElementById('replay-audio-btn'); + const playIcon = document.getElementById('play-icon'); + const playText = document.getElementById('play-text'); + + if (this.audioElement.paused) { + this.audioElement.play(); + this.playCount++; + + // Update play counter + const playCounter = document.getElementById('play-counter'); + if (playCounter) playCounter.textContent = this.playCount; + + // Update button states + if (playIcon) playIcon.textContent = '⏸️'; + if (playText) playText.textContent = 'Pause'; + + // Show transcript after enough plays + if (this.playCount >= this.config.showTranscriptAfter) { + const transcriptSection = document.getElementById('transcript-section'); + if (transcriptSection) transcriptSection.style.display = 'block'; + } + + // Show questions button after first play + if (this.playCount === 1) { + const startQuestionsBtn = document.getElementById('start-questions-btn'); + if (startQuestionsBtn) startQuestionsBtn.style.display = 'inline-block'; + } + + } else { + this.audioElement.pause(); + + if (playIcon) playIcon.textContent = '▶️'; + if (playText) playText.textContent = 'Resume'; + } + } + + /** + * Simulate audio playback for demo mode + * @private + */ + _simulateAudioPlayback() { + this.playCount++; + + const playCounter = document.getElementById('play-counter'); + if (playCounter) playCounter.textContent = this.playCount; + + // Simulate progress + let progress = 0; + const progressFill = document.getElementById('audio-progress'); + const currentTime = document.getElementById('current-time'); + + const interval = setInterval(() => { + progress += 2; + if (progressFill) progressFill.style.width = `${progress}%`; + if (currentTime) { + const seconds = Math.floor((progress / 100) * 30); // Assume 30s demo + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + currentTime.textContent = `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; + } + + if (progress >= 100) { + clearInterval(interval); + this._handleAudioEnded(); + } + }, 100); + + // Show transcript after enough plays + if (this.playCount >= this.config.showTranscriptAfter) { + const transcriptSection = document.getElementById('transcript-section'); + if (transcriptSection) transcriptSection.style.display = 'block'; + } + + // Show questions button after first play + if (this.playCount === 1) { + const startQuestionsBtn = document.getElementById('start-questions-btn'); + if (startQuestionsBtn) startQuestionsBtn.style.display = 'inline-block'; + } + } + + /** + * Handle audio progress updates + * @private + */ + _handleAudioProgress() { + if (!this.audioElement) return; + + const progress = (this.audioElement.currentTime / this.audioElement.duration) * 100; + const progressFill = document.getElementById('audio-progress'); + const currentTime = document.getElementById('current-time'); + + if (progressFill) progressFill.style.width = `${progress}%`; + + if (currentTime && this.audioElement.currentTime) { + const minutes = Math.floor(this.audioElement.currentTime / 60); + const seconds = Math.floor(this.audioElement.currentTime % 60); + currentTime.textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; + } + } + + /** + * Handle audio playback end + * @private + */ + _handleAudioEnded() { + const playIcon = document.getElementById('play-icon'); + const playText = document.getElementById('play-text'); + + if (playIcon) playIcon.textContent = '▶️'; + if (playText) playText.textContent = 'Play Again'; + + // Reset audio to beginning + if (this.audioElement) { + this.audioElement.currentTime = 0; + } + } + + /** + * Show audio listening phase + * @private + */ + _showAudioListening() { + const audioSection = document.getElementById('audio-player-section'); + const questionsSection = document.getElementById('questions-section'); + + if (audioSection) audioSection.style.display = 'block'; + if (questionsSection) questionsSection.style.display = 'none'; + } + + /** + * Start questions phase + * @private + */ + _startQuestions() { + const audioSection = document.getElementById('audio-player-section'); + const questionsSection = document.getElementById('questions-section'); + + // Keep audio player visible but smaller + if (audioSection) { + audioSection.style.display = 'block'; + audioSection.classList.add('minimized'); + } + if (questionsSection) questionsSection.style.display = 'block'; + + this._presentCurrentQuestion(); + } + + /** + * Present current question + * @private + */ + _presentCurrentQuestion() { + if (this.questionIndex >= this.questions.length) { + this._showAudioResults(); + return; + } + + this.currentQuestion = this.questions[this.questionIndex]; + const questionContent = document.getElementById('question-content'); + const questionCounter = document.getElementById('question-counter'); + const progressFill = document.getElementById('progress-fill'); + + if (!questionContent || !this.currentQuestion) return; + + // Update progress + const progressPercentage = ((this.questionIndex + 1) / this.questions.length) * 100; + if (progressFill) progressFill.style.width = `${progressPercentage}%`; + if (questionCounter) questionCounter.textContent = `Question ${this.questionIndex + 1} of ${this.questions.length}`; + + // Display question + questionContent.innerHTML = ` +
+
${this.currentQuestion.question}
+
+ ${this.currentQuestion.type} + + ${this.currentQuestion.difficulty} + + ${this.currentQuestion.requiresTranscript ? '📄 Transcript helpful' : ''} +
+
+ `; + + // Clear previous answer and focus + const answerInput = document.getElementById('answer-input'); + if (answerInput) { + answerInput.value = ''; + answerInput.focus(); + } + + // Hide explanation panel + const explanationPanel = document.getElementById('explanation-panel'); + if (explanationPanel) explanationPanel.style.display = 'none'; + } + + /** + * Handle user input validation + * @private + */ + async _handleUserInput() { + const answerInput = document.getElementById('answer-input'); + const validateBtn = document.getElementById('validate-answer-btn'); + const statusDiv = document.getElementById('validation-status'); + + if (!answerInput || !validateBtn || !statusDiv) return; + + const userAnswer = answerInput.value.trim(); + if (!userAnswer) return; + + try { + // Set validation in progress + this.validationInProgress = true; + validateBtn.disabled = true; + answerInput.disabled = true; + + // Show loading status + statusDiv.innerHTML = ` +
+
🔍
+ ${this.aiAvailable ? 'AI is analyzing your comprehension...' : 'Analyzing your answer...'} +
+ `; + + // Call validation + const result = await this.validate(userAnswer, {}); + this.lastValidationResult = result; + + // Store result + this.questionResults[this.questionIndex] = { + question: this.currentQuestion.question, + userAnswer: userAnswer, + correct: result.correct, + score: result.score, + feedback: result.feedback, + timestamp: new Date().toISOString(), + playbackCount: this.playCount + }; + + // Show result + this._showValidationResult(result); + + // Update status + statusDiv.innerHTML = ` +
+ ${result.correct ? '✅' : '👂'} + Analysis complete +
+ `; + + } catch (error) { + console.error('❌ Audio validation error:', error); + + // Show error status + statusDiv.innerHTML = ` +
+ ⚠️ + Error: ${error.message} +
+ `; + + // Re-enable input for retry + this._enableRetry(); + } + } + + /** + * Show validation result in explanation panel + * @private + */ + _showValidationResult(result) { + const explanationPanel = document.getElementById('explanation-panel'); + const explanationContent = document.getElementById('explanation-content'); + const nextBtn = document.getElementById('next-question-btn'); + const retryBtn = document.getElementById('retry-answer-btn'); + const finishBtn = document.getElementById('finish-audio-btn'); + + if (!explanationPanel || !explanationContent) return; + + // Show panel + explanationPanel.style.display = 'block'; + + // Set explanation content + explanationContent.innerHTML = ` +
+
+ + ${result.correct ? '✅ Good Listening!' : '👂 Keep Practicing!'} + + Score: ${result.score}/100 +
+
${result.explanation || result.feedback}
+ ${result.audioComprehension ? '
🎧 This analysis focuses on your listening skills and understanding of audio content.
' : ''} + ${this.playCount > this.config.maxPlaybacks ? '
💡 Try to listen actively on the first few attempts for better comprehension scores.
' : ''} +
+ `; + + // Show appropriate buttons + const isLastQuestion = this.questionIndex >= this.questions.length - 1; + + if (nextBtn) nextBtn.style.display = isLastQuestion ? 'none' : 'inline-block'; + if (finishBtn) finishBtn.style.display = isLastQuestion ? 'inline-block' : 'none'; + if (retryBtn) retryBtn.style.display = result.correct ? 'none' : 'inline-block'; + + // Scroll to explanation + explanationPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + + /** + * Handle next question + * @private + */ + _handleNextQuestion() { + this.questionIndex++; + this._presentCurrentQuestion(); + } + + /** + * Handle retry + * @private + */ + _handleRetry() { + // Hide explanation and enable new input + const explanationPanel = document.getElementById('explanation-panel'); + const statusDiv = document.getElementById('validation-status'); + + if (explanationPanel) explanationPanel.style.display = 'none'; + if (statusDiv) statusDiv.innerHTML = ''; + + this._enableRetry(); + } + + /** + * Enable retry after error + * @private + */ + _enableRetry() { + this.validationInProgress = false; + + const answerInput = document.getElementById('answer-input'); + const validateBtn = document.getElementById('validate-answer-btn'); + + if (answerInput) { + answerInput.disabled = false; + answerInput.focus(); + } + + if (validateBtn) { + validateBtn.disabled = false; + } + } + + /** + * Show final audio results + * @private + */ + _showAudioResults() { + const resultsContainer = document.getElementById('audio-results'); + const questionsSection = document.getElementById('questions-section'); + + if (!resultsContainer) return; + + const correctCount = this.questionResults.filter(result => result.correct).length; + const totalCount = this.questionResults.length; + const comprehensionRate = totalCount > 0 ? Math.round((correctCount / totalCount) * 100) : 0; + + let resultClass = 'results-poor'; + if (comprehensionRate >= 80) resultClass = 'results-excellent'; + else if (comprehensionRate >= 60) resultClass = 'results-good'; + + const resultsHTML = ` +
+

📊 Listening Comprehension Results

+
+
+ ${comprehensionRate}% + Comprehension Rate +
+
+ ${correctCount} / ${totalCount} questions understood well +
+
+
+ ${this.playCount} + Total Plays +
+
+ ${this.playCount <= this.config.maxPlaybacks ? '👍' : '⚠️'} + ${this.playCount <= this.config.maxPlaybacks ? 'Good listening' : 'Practice active listening'} +
+
+
+ +
+ ${this.questionResults.map((result, index) => ` +
+
+ Q${index + 1} + ${result.correct ? '✅' : '👂'} + Score: ${result.score}/100 + Plays: ${result.playbackCount || this.playCount} +
+
+ `).join('')} +
+ +
+ + +
+
+ `; + + resultsContainer.innerHTML = resultsHTML; + resultsContainer.style.display = 'block'; + + // Hide other sections + if (questionsSection) questionsSection.style.display = 'none'; + + // Add action listeners + document.getElementById('complete-audio-btn').onclick = () => this._completeAudioExercise(); + document.getElementById('replay-audio-btn').onclick = () => this._replayAudio(); + } + + /** + * Complete audio exercise + * @private + */ + _completeAudioExercise() { + // Mark audio as comprehended if performance is good + const correctCount = this.questionResults.filter(result => result.correct).length; + const comprehensionRate = correctCount / this.questionResults.length; + + if (comprehensionRate >= 0.6) { // 60% comprehension threshold + const audioId = this.currentAudio.id || this.currentAudio.title || 'audio_exercise'; + const metadata = { + comprehensionRate: Math.round(comprehensionRate * 100), + questionsAnswered: this.questionResults.length, + correctAnswers: correctCount, + totalPlaybacks: this.playCount, + sessionId: this.orchestrator?.sessionId || 'unknown', + moduleType: 'audio', + aiAnalysisUsed: this.aiAvailable, + listeningEfficiency: this.playCount <= this.config.maxPlaybacks ? 'good' : 'needs_improvement' + }; + + this.prerequisiteEngine.markPhraseMastered(audioId, metadata); + + // Also save to persistent storage + if (window.addMasteredItem && this.orchestrator?.bookId && this.orchestrator?.chapterId) { + window.addMasteredItem( + this.orchestrator.bookId, + this.orchestrator.chapterId, + 'audios', + audioId, + metadata + ); + } + } + + // Emit completion event + this.orchestrator._eventBus.emit('drs:exerciseCompleted', { + moduleType: 'audio', + results: this.questionResults, + progress: this.getProgress() + }, 'AudioModule'); + } + + /** + * Replay audio exercise + * @private + */ + _replayAudio() { + this.questionIndex = 0; + this.questionResults = []; + this.playCount = 0; + this._showAudioListening(); + + const resultsContainer = document.getElementById('audio-results'); + if (resultsContainer) resultsContainer.style.display = 'none'; + + // Reset UI elements + const playCounter = document.getElementById('play-counter'); + if (playCounter) playCounter.textContent = '0'; + + const startQuestionsBtn = document.getElementById('start-questions-btn'); + if (startQuestionsBtn) startQuestionsBtn.style.display = 'none'; + + const transcriptSection = document.getElementById('transcript-section'); + if (transcriptSection) transcriptSection.style.display = 'none'; + } + + /** + * Add CSS styles for audio exercise + * @private + */ + _addStyles() { + if (document.getElementById('audio-module-styles')) return; + + const styles = document.createElement('style'); + styles.id = 'audio-module-styles'; + styles.textContent = ` + .audio-exercise { + max-width: 900px; + margin: 0 auto; + padding: 20px; + display: grid; + gap: 20px; + } + + .exercise-header { + text-align: center; + margin-bottom: 20px; + } + + .audio-info { + margin-top: 10px; + } + + .audio-meta { + color: #666; + font-size: 0.9em; + } + + .audio-content { + display: grid; + gap: 20px; + } + + .audio-player-card, .question-card { + background: white; + border-radius: 12px; + padding: 30px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + } + + .audio-player-section.minimized .audio-player-card { + padding: 20px; + background: #f8f9fa; + border: 2px solid #e9ecef; + } + + .player-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 2px solid #eee; + } + + .player-header h3 { + margin: 0; + color: #333; + font-size: 1.5em; + } + + .player-stats { + display: flex; + gap: 15px; + font-size: 0.9em; + color: #666; + } + + .play-count { + font-weight: 600; + } + + .audio-player { + margin-bottom: 25px; + } + + .audio-placeholder { + text-align: center; + padding: 40px; + background: linear-gradient(135deg, #f8f9ff, #e8f4fd); + border-radius: 10px; + color: #666; + font-size: 1.1em; + margin-bottom: 20px; + } + + .player-controls { + display: flex; + flex-direction: column; + gap: 15px; + align-items: center; + } + + .play-btn { + font-size: 1.1em; + min-width: 140px; + } + + .progress-container { + width: 100%; + max-width: 400px; + text-align: center; + } + + .progress-bar { + width: 100%; + height: 8px; + background-color: #e0e0e0; + border-radius: 4px; + margin-bottom: 10px; + overflow: hidden; + } + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, #28a745, #20c997); + transition: width 0.3s ease; + } + + .time-display { + font-size: 0.9em; + color: #666; + font-family: monospace; + } + + .transcript-section { + margin-top: 20px; + padding: 20px; + background: linear-gradient(135deg, #fff9e6, #fff3cd); + border-radius: 10px; + border-left: 4px solid #ffc107; + } + + .transcript-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + } + + .transcript-header h4 { + margin: 0; + color: #333; + } + + .transcript-note { + font-size: 0.8em; + color: #666; + background: rgba(255,255,255,0.7); + padding: 4px 8px; + border-radius: 12px; + } + + .transcript-content { + line-height: 1.6; + color: #444; + font-style: italic; + } + + .listening-actions { + text-align: center; + margin-top: 20px; + } + + .question-progress { + margin-bottom: 25px; + } + + .progress-indicator { + text-align: center; + } + + .question-display { + margin-bottom: 25px; + padding: 20px; + background: linear-gradient(135deg, #f8f9ff, #e8f4fd); + border-radius: 10px; + border-left: 4px solid #6c5ce7; + } + + .question-text { + font-size: 1.3em; + font-weight: 600; + color: #333; + margin-bottom: 15px; + line-height: 1.4; + } + + .question-meta { + display: flex; + gap: 15px; + align-items: center; + flex-wrap: wrap; + } + + .question-type { + background: #e3f2fd; + color: #1976d2; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .requires-transcript { + background: #fff3e0; + color: #f57c00; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .difficulty-easy { + background: #e8f5e8; + color: #2e7d32; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .difficulty-medium { + background: #fff3e0; + color: #f57c00; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .difficulty-hard { + background: #ffebee; + color: #c62828; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .answer-input-section { + margin-bottom: 20px; + } + + .answer-input-section label { + display: block; + margin-bottom: 10px; + font-weight: 600; + color: #555; + } + + .answer-input-section textarea { + width: 100%; + padding: 15px; + font-size: 1.05em; + border: 2px solid #ddd; + border-radius: 8px; + resize: vertical; + min-height: 100px; + box-sizing: border-box; + transition: border-color 0.3s ease; + font-family: inherit; + line-height: 1.5; + } + + .answer-input-section textarea:focus { + outline: none; + border-color: #6c5ce7; + } + + .question-controls { + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; + } + + .question-controls > div:first-child { + display: flex; + gap: 15px; + flex-wrap: wrap; + justify-content: center; + } + + .validation-status { + min-height: 30px; + display: flex; + align-items: center; + justify-content: center; + } + + .status-loading, .status-complete, .status-error { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 20px; + border-radius: 25px; + font-weight: 500; + } + + .status-loading { + background: #e3f2fd; + color: #1976d2; + } + + .status-complete { + background: #e8f5e8; + color: #2e7d32; + } + + .status-error { + background: #ffebee; + color: #c62828; + } + + .loading-spinner { + animation: spin 1s linear infinite; + } + + .explanation-panel { + background: white; + border-radius: 12px; + padding: 25px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + border-left: 4px solid #6c5ce7; + } + + .panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid #eee; + } + + .panel-header h3 { + margin: 0; + color: #333; + } + + .analysis-model { + font-size: 0.9em; + color: #666; + background: #f5f5f5; + padding: 4px 8px; + border-radius: 4px; + } + + .explanation-result { + padding: 20px; + border-radius: 8px; + margin-bottom: 15px; + } + + .explanation-result.correct { + border-left: 4px solid #4caf50; + background: linear-gradient(135deg, #f1f8e9, #e8f5e8); + } + + .explanation-result.needs-improvement { + border-left: 4px solid #ff9800; + background: linear-gradient(135deg, #fff8e1, #fff3e0); + } + + .result-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + font-weight: 600; + } + + .result-indicator { + font-size: 1.1em; + } + + .comprehension-score { + font-size: 0.9em; + color: #666; + } + + .explanation-text { + line-height: 1.6; + color: #333; + font-size: 1.05em; + margin-bottom: 10px; + } + + .analysis-note, .playback-note { + font-size: 0.9em; + color: #666; + font-style: italic; + padding-top: 10px; + border-top: 1px solid #eee; + margin-top: 10px; + } + + .panel-actions { + display: flex; + gap: 15px; + justify-content: center; + flex-wrap: wrap; + } + + .audio-results-content { + background: white; + border-radius: 12px; + padding: 30px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + text-align: center; + } + + .results-summary { + margin-bottom: 30px; + } + + .comprehension-display { + margin-bottom: 15px; + } + + .comprehension-rate { + font-size: 3em; + font-weight: bold; + display: block; + } + + .results-excellent .comprehension-rate { color: #4caf50; } + .results-good .comprehension-rate { color: #ff9800; } + .results-poor .comprehension-rate { color: #f44336; } + + .comprehension-label { + font-size: 1.2em; + color: #666; + } + + .questions-summary { + font-size: 1.1em; + color: #555; + margin-bottom: 20px; + } + + .listening-stats { + display: flex; + justify-content: center; + gap: 40px; + margin-top: 20px; + padding: 20px; + background: #f8f9fa; + border-radius: 10px; + } + + .stat-item { + text-align: center; + } + + .stat-value { + display: block; + font-size: 1.5em; + font-weight: bold; + color: #333; + } + + .stat-label { + font-size: 0.9em; + color: #666; + } + + .question-breakdown { + display: grid; + gap: 10px; + margin-bottom: 30px; + } + + .question-result { + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px; + border-radius: 8px; + background: #f8f9fa; + } + + .question-result.understood { + background: linear-gradient(135deg, #e8f5e8, #f1f8e9); + border-left: 4px solid #4caf50; + } + + .question-result.needs-work { + background: linear-gradient(135deg, #fff8e1, #fff3e0); + border-left: 4px solid #ff9800; + } + + .question-summary { + display: flex; + align-items: center; + gap: 15px; + } + + .question-num { + font-weight: bold; + color: #333; + } + + .playback-info { + font-size: 0.85em; + color: #666; + background: rgba(255,255,255,0.7); + padding: 4px 8px; + border-radius: 10px; + } + + .results-actions { + display: flex; + gap: 15px; + justify-content: center; + flex-wrap: wrap; + } + + .btn { + padding: 12px 24px; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 1em; + font-weight: 500; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + gap: 8px; + text-decoration: none; + } + + .btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .btn-primary { + background: linear-gradient(135deg, #6c5ce7, #a29bfe); + color: white; + } + + .btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(108, 92, 231, 0.3); + } + + .btn-secondary { + background: #6c757d; + color: white; + } + + .btn-secondary:hover:not(:disabled) { + background: #5a6268; + } + + .btn-outline { + background: transparent; + border: 2px solid #6c5ce7; + color: #6c5ce7; + } + + .btn-outline:hover:not(:disabled) { + background: #6c5ce7; + color: white; + } + + .btn-success { + background: linear-gradient(135deg, #00b894, #00cec9); + color: white; + } + + .btn-success:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(0, 184, 148, 0.3); + } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + + @media (max-width: 768px) { + .audio-exercise { + padding: 15px; + } + + .audio-player-card, .question-card, .explanation-panel { + padding: 20px; + } + + .question-text { + font-size: 1.2em; + } + + .comprehension-rate { + font-size: 2.5em; + } + + .panel-actions, .results-actions { + flex-direction: column; + } + + .player-header { + flex-direction: column; + gap: 15px; + align-items: flex-start; + } + + .listening-stats { + flex-direction: column; + gap: 20px; + } + + .question-controls > div:first-child { + flex-direction: column; + align-items: center; + } + } + `; + + document.head.appendChild(styles); + } +} + +export default AudioModule; \ No newline at end of file diff --git a/src/DRS/exercise-modules/GrammarModule.js b/src/DRS/exercise-modules/GrammarModule.js new file mode 100644 index 0000000..72c29e1 --- /dev/null +++ b/src/DRS/exercise-modules/GrammarModule.js @@ -0,0 +1,2060 @@ +/** + * GrammarModule - Grammar exercises with AI validation + * Handles grammar rules, sentence construction, and correction exercises + */ + +import ExerciseModuleInterface from '../interfaces/ExerciseModuleInterface.js'; + +class GrammarModule extends ExerciseModuleInterface { + constructor(orchestrator, llmValidator, prerequisiteEngine, contextMemory) { + super(); + + // Validate dependencies + if (!orchestrator || !llmValidator || !prerequisiteEngine || !contextMemory) { + throw new Error('GrammarModule requires all service dependencies'); + } + + this.orchestrator = orchestrator; + this.llmValidator = llmValidator; + this.prerequisiteEngine = prerequisiteEngine; + this.contextMemory = contextMemory; + + // Module state + this.initialized = false; + this.container = null; + this.currentExerciseData = null; + this.currentGrammarRule = null; + this.currentExercise = null; + this.exerciseIndex = 0; + this.exerciseResults = []; + this.validationInProgress = false; + this.lastValidationResult = null; + this.aiAvailable = false; + this.hintUsed = false; + this.attempts = 0; + + // Configuration + this.config = { + requiredProvider: 'openai', // Prefer OpenAI for grammar analysis + model: 'gpt-4o-mini', + temperature: 0.1, // Lower temperature for grammar accuracy + maxTokens: 600, + timeout: 30000, // Standard timeout for grammar + exercisesPerRule: 5, // Default number of exercises per grammar rule + maxAttempts: 3, // Maximum attempts per exercise + showHints: true, // Allow hints for grammar rules + showExplanations: true // Show rule explanations + }; + + // Languages configuration + this.languages = { + userLanguage: 'English', + targetLanguage: 'French' + }; + + // Grammar exercise types + this.exerciseTypes = { + 'fill_blank': 'Fill in the blank', + 'correction': 'Error correction', + 'transformation': 'Sentence transformation', + 'multiple_choice': 'Multiple choice', + 'conjugation': 'Verb conjugation', + 'construction': 'Sentence construction' + }; + + // Bind methods + this._handleUserInput = this._handleUserInput.bind(this); + this._handleNextExercise = this._handleNextExercise.bind(this); + this._handleRetry = this._handleRetry.bind(this); + this._handleShowHint = this._handleShowHint.bind(this); + this._handleShowRule = this._handleShowRule.bind(this); + } + + async init() { + if (this.initialized) return; + + console.log('📚 Initializing GrammarModule...'); + + // Test AI connectivity - highly recommended for grammar + try { + const testResult = await this.llmValidator.testConnectivity(); + if (testResult.success) { + console.log(`✅ AI connectivity verified for grammar analysis (providers: ${testResult.availableProviders?.join(', ') || testResult.provider})`); + this.aiAvailable = true; + } else { + console.warn('⚠️ AI connection failed - grammar validation will be limited:', testResult.error); + this.aiAvailable = false; + } + } catch (error) { + console.warn('⚠️ AI connectivity test failed - using basic grammar validation:', error.message); + this.aiAvailable = false; + } + + this.initialized = true; + console.log(`✅ GrammarModule initialized (AI: ${this.aiAvailable ? 'available for deep analysis' : 'limited - basic validation only'})`); + } + + /** + * Check if module can run with current prerequisites + * @param {Array} prerequisites - List of learned vocabulary/concepts + * @param {Object} chapterContent - Full chapter content + * @returns {boolean} - True if module can run + */ + canRun(prerequisites, chapterContent) { + // Check if there are grammar rules and if prerequisites allow them + const grammarRules = chapterContent?.grammar || []; + if (grammarRules.length === 0) return false; + + // Find grammar rules that can be unlocked with current prerequisites + const availableRules = grammarRules.filter(rule => { + const unlockStatus = this.prerequisiteEngine.canUnlock('grammar', rule); + return unlockStatus.canUnlock; + }); + + return availableRules.length > 0; + } + + /** + * Present exercise UI and content + * @param {HTMLElement} container - DOM container to render into + * @param {Object} exerciseData - Specific exercise data to present + * @returns {Promise} + */ + async present(container, exerciseData) { + if (!this.initialized) { + throw new Error('GrammarModule must be initialized before use'); + } + + this.container = container; + this.currentExerciseData = exerciseData; + this.currentGrammarRule = exerciseData.grammar; + this.exerciseIndex = 0; + this.exerciseResults = []; + this.validationInProgress = false; + this.lastValidationResult = null; + this.hintUsed = false; + this.attempts = 0; + + // Detect languages from chapter content + this._detectLanguages(exerciseData); + + // Generate or extract exercises + this.exercises = await this._prepareExercises(this.currentGrammarRule); + + console.log(`📚 Presenting grammar exercises: "${this.currentGrammarRule.title || 'Grammar Practice'}" (${this.exercises.length} exercises)`); + + // Render initial UI + await this._renderGrammarExercise(); + + // Start with rule explanation + this._showRuleExplanation(); + } + + /** + * Validate user input with AI for deep grammar analysis + * @param {string} userInput - User's response + * @param {Object} context - Exercise context + * @returns {Promise} - Validation result with score and feedback + */ + async validate(userInput, context) { + if (!userInput || !userInput.trim()) { + throw new Error('Please provide an answer'); + } + + if (!this.currentGrammarRule || !this.currentExercise) { + throw new Error('No grammar rule or exercise loaded for validation'); + } + + console.log(`📚 Validating grammar answer for exercise ${this.exerciseIndex + 1}`); + + // Build comprehensive prompt for grammar validation + const prompt = this._buildGrammarPrompt(userInput); + + try { + // Use AI validation with structured response + const aiResponse = await this.llmValidator.iaEngine.validateEducationalContent(prompt, { + preferredProvider: this.config.requiredProvider, + temperature: this.config.temperature, + maxTokens: this.config.maxTokens, + timeout: this.config.timeout, + systemPrompt: `You are an expert grammar teacher. Focus on grammatical accuracy, rule application, and language structure. ALWAYS respond in the exact format: [answer]yes/no [explanation]your detailed grammar analysis here` + }); + + // Parse structured response + const parsedResult = this._parseStructuredResponse(aiResponse); + + // Apply penalties and bonuses + if (this.hintUsed) { + parsedResult.score = Math.max(parsedResult.score - 10, 30); + parsedResult.feedback += ` (Note: -10 points for using hint - try to apply grammar rules independently)`; + } + + if (this.attempts > 1) { + const penalty = (this.attempts - 1) * 5; + parsedResult.score = Math.max(parsedResult.score - penalty, 20); + parsedResult.feedback += ` (Note: -${penalty} points for multiple attempts - review grammar rules carefully)`; + } + + // Record interaction in context memory + this.contextMemory.recordInteraction({ + type: 'grammar', + subtype: this.currentExercise.type, + content: { + rule: this.currentGrammarRule, + exercise: this.currentExercise, + ruleTitle: this.currentGrammarRule.title || 'Grammar Rule', + exerciseType: this.currentExercise.type, + difficulty: this.currentExercise.difficulty + }, + userResponse: userInput.trim(), + validation: parsedResult, + context: { + languages: this.languages, + exerciseIndex: this.exerciseIndex, + totalExercises: this.exercises.length, + attempts: this.attempts, + hintUsed: this.hintUsed + } + }); + + return parsedResult; + + } catch (error) { + console.error('❌ AI grammar validation failed:', error); + + // Fallback to basic grammar validation if AI fails + if (!this.aiAvailable) { + return this._performBasicGrammarValidation(userInput); + } + + throw new Error(`Grammar validation failed: ${error.message}. Please check your answer and try again.`); + } + } + + /** + * Get current progress data + * @returns {ProgressData} - Progress information for this module + */ + getProgress() { + const totalExercises = this.exercises ? this.exercises.length : 0; + const completedExercises = this.exerciseResults.length; + const correctAnswers = this.exerciseResults.filter(result => result.correct).length; + + return { + type: 'grammar', + ruleTitle: this.currentGrammarRule?.title || 'Grammar Rule', + totalExercises, + completedExercises, + correctAnswers, + currentExerciseIndex: this.exerciseIndex, + exerciseResults: this.exerciseResults, + progressPercentage: totalExercises > 0 ? Math.round((completedExercises / totalExercises) * 100) : 0, + accuracyRate: completedExercises > 0 ? Math.round((correctAnswers / completedExercises) * 100) : 0, + currentAttempts: this.attempts, + hintUsed: this.hintUsed, + aiAnalysisAvailable: this.aiAvailable + }; + } + + /** + * Clean up and prepare for unloading + */ + cleanup() { + console.log('🧹 Cleaning up GrammarModule...'); + + // Remove event listeners + if (this.container) { + this.container.innerHTML = ''; + } + + // Reset state + this.container = null; + this.currentExerciseData = null; + this.currentGrammarRule = null; + this.currentExercise = null; + this.exerciseIndex = 0; + this.exerciseResults = []; + this.exercises = null; + this.validationInProgress = false; + this.lastValidationResult = null; + this.hintUsed = false; + this.attempts = 0; + + console.log('✅ GrammarModule cleaned up'); + } + + /** + * Get module metadata + * @returns {Object} - Module information + */ + getMetadata() { + return { + name: 'GrammarModule', + type: 'grammar', + version: '1.0.0', + description: 'Grammar exercises with AI-powered linguistic analysis', + capabilities: ['grammar_rules', 'sentence_construction', 'error_correction', 'linguistic_analysis', 'ai_feedback'], + aiRequired: false, // Can work without AI but limited + config: this.config + }; + } + + // Private Methods + + /** + * Detect languages from exercise data + * @private + */ + _detectLanguages(exerciseData) { + const chapterContent = this.currentExerciseData?.chapterContent; + + if (chapterContent?.metadata?.userLanguage) { + this.languages.userLanguage = chapterContent.metadata.userLanguage; + } + + if (chapterContent?.metadata?.targetLanguage) { + this.languages.targetLanguage = chapterContent.metadata.targetLanguage; + } + + console.log(`🌍 Grammar languages detected: ${this.languages.userLanguage} -> ${this.languages.targetLanguage}`); + } + + /** + * Prepare exercises for the grammar rule + * @private + */ + async _prepareExercises(grammarRule) { + // If grammar rule already has exercises, use them + if (grammarRule.exercises && grammarRule.exercises.length > 0) { + return grammarRule.exercises.map((ex, index) => ({ + id: `ex${index + 1}`, + type: ex.type || 'fill_blank', + question: ex.question || ex.text || ex, + correctAnswer: ex.answer || ex.correctAnswer || '', + options: ex.options || [], + hint: ex.hint || '', + difficulty: ex.difficulty || 'medium', + points: ex.points || 10 + })); + } + + // Generate default grammar exercises based on rule type + const ruleType = grammarRule.type || 'general'; + const defaultExercises = this._generateDefaultExercises(grammarRule, ruleType); + + // Limit to configured number of exercises + return defaultExercises.slice(0, this.config.exercisesPerRule); + } + + /** + * Generate default exercises for a grammar rule + * @private + */ + _generateDefaultExercises(grammarRule, ruleType) { + const ruleTitle = grammarRule.title || 'Grammar Rule'; + const examples = grammarRule.examples || []; + + const defaultExercises = [ + { + id: 'fill1', + type: 'fill_blank', + question: `Complete the sentence following the ${ruleTitle} rule: "The student ____ to school every day."`, + correctAnswer: 'goes', + hint: `Use the correct form of the verb according to ${ruleTitle}`, + difficulty: 'easy', + points: 10 + }, + { + id: 'correction1', + type: 'correction', + question: `Correct the grammar error in this sentence: "He don't like coffee."`, + correctAnswer: `He doesn't like coffee.`, + hint: 'Check subject-verb agreement', + difficulty: 'medium', + points: 15 + }, + { + id: 'transform1', + type: 'transformation', + question: `Transform this sentence using ${ruleTitle}: "She is reading a book."`, + correctAnswer: 'She reads a book.', + hint: `Apply ${ruleTitle} to change the tense or form`, + difficulty: 'medium', + points: 15 + }, + { + id: 'choice1', + type: 'multiple_choice', + question: `Choose the correct option that follows ${ruleTitle}:`, + options: ['Option A', 'Option B', 'Option C'], + correctAnswer: 'Option A', + hint: `Remember the ${ruleTitle} rule`, + difficulty: 'easy', + points: 10 + }, + { + id: 'construction1', + type: 'construction', + question: `Create a sentence using ${ruleTitle} with the words: [student, study, library]`, + correctAnswer: 'The student studies in the library.', + hint: `Follow ${ruleTitle} for proper sentence construction`, + difficulty: 'hard', + points: 20 + } + ]; + + return defaultExercises; + } + + /** + * Build comprehensive prompt for grammar validation + * @private + */ + _buildGrammarPrompt(userAnswer) { + const ruleTitle = this.currentGrammarRule.title || 'Grammar Rule'; + const ruleDescription = this.currentGrammarRule.description || 'No description available'; + const ruleExamples = this.currentGrammarRule.examples || []; + + return `You are evaluating grammar for a language learning exercise. + +CRITICAL: You MUST respond in this EXACT format: [answer]yes/no [explanation]your detailed grammar analysis here + +GRAMMAR RULE: +Title: "${ruleTitle}" +Description: "${ruleDescription}" +Examples: ${ruleExamples.length > 0 ? ruleExamples.join(', ') : 'No examples provided'} + +EXERCISE: +Type: ${this.currentExercise.type} (${this.exerciseTypes[this.currentExercise.type] || 'Grammar exercise'}) +Question: "${this.currentExercise.question}" +Expected Answer: "${this.currentExercise.correctAnswer}" +${this.currentExercise.options.length > 0 ? `Options: ${this.currentExercise.options.join(', ')}` : ''} + +STUDENT RESPONSE: "${userAnswer}" + +EVALUATION CONTEXT: +- Exercise Type: Grammar practice +- Languages: ${this.languages.userLanguage} -> ${this.languages.targetLanguage} +- Exercise Difficulty: ${this.currentExercise.difficulty} +- Exercise ${this.exerciseIndex + 1} of ${this.exercises.length} +- Attempts: ${this.attempts} +- Hint Used: ${this.hintUsed} + +EVALUATION CRITERIA: +- [answer]yes if the student's answer demonstrates correct grammar rule application +- [answer]no if the answer shows grammatical errors or rule misapplication +- Focus on GRAMMATICAL ACCURACY and RULE COMPREHENSION +- Accept alternative correct forms if they follow the grammar rule +- Be strict about grammar but consider language learning level +- For fill-in-the-blank: exact or grammatically equivalent answers +- For corrections: proper error identification and correction +- For transformations: accurate structural changes +- For multiple choice: exact match with correct option +- For construction: proper sentence structure following the rule + +[explanation] should provide: +1. Whether the grammar rule was applied correctly +2. Specific grammatical analysis of the response +3. What errors were made (if any) and why they're incorrect +4. The correct grammatical form and rule explanation +5. Tips for remembering and applying this grammar rule +6. Encouragement and constructive feedback + +Format: [answer]yes/no [explanation]your comprehensive grammar analysis here`; + } + + /** + * Parse structured AI response for grammar validation + * @private + */ + _parseStructuredResponse(aiResponse) { + try { + let responseText = ''; + + // Extract text from AI response + if (typeof aiResponse === 'string') { + responseText = aiResponse; + } else if (aiResponse.content) { + responseText = aiResponse.content; + } else if (aiResponse.text) { + responseText = aiResponse.text; + } else { + responseText = JSON.stringify(aiResponse); + } + + console.log('🔍 Parsing AI grammar response:', responseText.substring(0, 150) + '...'); + + // Extract [answer] - case insensitive + const answerMatch = responseText.match(/\[answer\](yes|no)/i); + if (!answerMatch) { + throw new Error('AI response missing [answer] format'); + } + + // Extract [explanation] - multiline support + const explanationMatch = responseText.match(/\[explanation\](.+)/s); + if (!explanationMatch) { + throw new Error('AI response missing [explanation] format'); + } + + const isCorrect = answerMatch[1].toLowerCase() === 'yes'; + const explanation = explanationMatch[1].trim(); + + // Grammar-specific scoring + const baseScore = isCorrect ? 92 : 45; // High standards for grammar + const result = { + score: baseScore, + correct: isCorrect, + feedback: explanation, + answer: answerMatch[1].toLowerCase(), + explanation: explanation, + timestamp: new Date().toISOString(), + provider: this.config.requiredProvider, + model: this.config.model, + cached: false, + formatValid: true, + grammarAnalysis: true + }; + + console.log(`✅ AI grammar parsed: ${result.answer} - Score: ${result.score}`); + return result; + + } catch (error) { + console.error('❌ Failed to parse AI grammar response:', error); + console.error('Raw response:', aiResponse); + + throw new Error(`AI response format invalid: ${error.message}`); + } + } + + /** + * Perform basic grammar validation when AI is unavailable + * @private + */ + _performBasicGrammarValidation(userAnswer) { + console.log('🔍 Performing basic grammar validation (AI unavailable)'); + + const correctAnswer = this.currentExercise.correctAnswer.trim().toLowerCase(); + const userAnswerClean = userAnswer.trim().toLowerCase(); + + // Basic exact matching for grammar + const isExactMatch = userAnswerClean === correctAnswer; + + // Simple similarity check + const similarity = this._calculateStringSimilarity(userAnswerClean, correctAnswer); + const isClose = similarity > 0.8; + + let score = 20; // Base score for attempt + let feedback = ''; + + if (isExactMatch) { + score = 80; + feedback = "Correct! Your answer matches the expected grammar form."; + } else if (isClose) { + score = 60; + feedback = "Close! Your answer is similar to the correct form but may have minor grammar issues. The correct answer is: " + this.currentExercise.correctAnswer; + } else { + score = 30; + feedback = "Not quite right. The correct answer is: " + this.currentExercise.correctAnswer + ". Please review the grammar rule and try again."; + } + + return { + score: score, + correct: isExactMatch, + feedback: feedback, + timestamp: new Date().toISOString(), + provider: 'basic_grammar_analysis', + model: 'string_matching', + cached: false, + mockGenerated: true, + grammarAnalysis: true, + limitedAnalysis: true + }; + } + + /** + * Calculate string similarity (basic implementation) + * @private + */ + _calculateStringSimilarity(str1, str2) { + const longer = str1.length > str2.length ? str1 : str2; + const shorter = str1.length > str2.length ? str2 : str1; + + if (longer.length === 0) return 1.0; + + const editDistance = this._levenshteinDistance(longer, shorter); + return (longer.length - editDistance) / longer.length; + } + + /** + * Calculate Levenshtein distance + * @private + */ + _levenshteinDistance(str1, str2) { + const matrix = []; + + for (let i = 0; i <= str2.length; i++) { + matrix[i] = [i]; + } + + for (let j = 0; j <= str1.length; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= str2.length; i++) { + for (let j = 1; j <= str1.length; j++) { + if (str2.charAt(i - 1) === str1.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1]; + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j] + 1 + ); + } + } + } + + return matrix[str2.length][str1.length]; + } + + /** + * Render the grammar exercise interface + * @private + */ + async _renderGrammarExercise() { + if (!this.container || !this.currentGrammarRule) return; + + const ruleTitle = this.currentGrammarRule.title || 'Grammar Practice'; + const ruleType = this.currentGrammarRule.type || 'general'; + + this.container.innerHTML = ` +
+
+

📚 Grammar Practice

+
+ + ${this.exercises?.length || 0} exercises • ${ruleType} grammar + ${!this.aiAvailable ? ' • ⚠️ Limited analysis mode' : ' • 🧠 AI grammar analysis'} + +
+
+ +
+
+
+
+

📖 ${ruleTitle}

+
+ ${ruleType} + ${this.currentGrammarRule.difficulty || 'medium'} +
+
+ +
+
+ ${this.currentGrammarRule.description || 'Grammar rule description not available.'} +
+ + ${this.currentGrammarRule.examples && this.currentGrammarRule.examples.length > 0 ? ` +
+

📝 Examples:

+
    + ${this.currentGrammarRule.examples.map(example => + `
  • ${example}
  • ` + ).join('')} +
+
+ ` : ''} + + ${this.currentGrammarRule.notes ? ` +
+

💡 Important Notes:

+

${this.currentGrammarRule.notes}

+
+ ` : ''} +
+ +
+ +
+
+
+ + + + + + +
+
+ `; + + // Add CSS styles + this._addStyles(); + + // Add event listeners + this._setupEventListeners(); + } + + /** + * Setup event listeners for grammar exercise + * @private + */ + _setupEventListeners() { + const startExercisesBtn = document.getElementById('start-exercises-btn'); + const showRuleBtn = document.getElementById('show-rule-btn'); + const showHintBtn = document.getElementById('show-hint-btn'); + const answerInput = document.getElementById('answer-input'); + const validateBtn = document.getElementById('validate-answer-btn'); + const retryBtn = document.getElementById('retry-exercise-btn'); + const nextBtn = document.getElementById('next-exercise-btn'); + const finishBtn = document.getElementById('finish-grammar-btn'); + + // Start exercises button + if (startExercisesBtn) { + startExercisesBtn.onclick = () => this._startExercises(); + } + + // Rule and hint buttons + if (showRuleBtn) { + showRuleBtn.onclick = this._handleShowRule; + } + + if (showHintBtn) { + showHintBtn.onclick = this._handleShowHint; + } + + // Answer input validation + if (answerInput) { + answerInput.addEventListener('input', () => { + const hasText = answerInput.value.trim().length > 0; + if (validateBtn) { + validateBtn.disabled = !hasText || this.validationInProgress; + } + }); + + // Allow Enter to validate + answerInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && !validateBtn.disabled) { + e.preventDefault(); + this._handleUserInput(); + } + }); + } + + // Validate button + if (validateBtn) { + validateBtn.onclick = this._handleUserInput; + } + + // Action buttons + if (retryBtn) retryBtn.onclick = this._handleRetry; + if (nextBtn) nextBtn.onclick = this._handleNextExercise; + if (finishBtn) finishBtn.onclick = () => this._completeGrammarExercise(); + } + + /** + * Show rule explanation phase + * @private + */ + _showRuleExplanation() { + const ruleSection = document.getElementById('rule-explanation-section'); + const exercisesSection = document.getElementById('exercises-section'); + + if (ruleSection) ruleSection.style.display = 'block'; + if (exercisesSection) exercisesSection.style.display = 'none'; + } + + /** + * Start exercises phase + * @private + */ + _startExercises() { + const ruleSection = document.getElementById('rule-explanation-section'); + const exercisesSection = document.getElementById('exercises-section'); + + if (ruleSection) ruleSection.style.display = 'none'; + if (exercisesSection) exercisesSection.style.display = 'block'; + + this._presentCurrentExercise(); + } + + /** + * Present current exercise + * @private + */ + _presentCurrentExercise() { + if (this.exerciseIndex >= this.exercises.length) { + this._showGrammarResults(); + return; + } + + this.currentExercise = this.exercises[this.exerciseIndex]; + this.attempts = 0; + this.hintUsed = false; + + const exerciseContent = document.getElementById('exercise-content'); + const exerciseCounter = document.getElementById('exercise-counter'); + const attemptCounter = document.getElementById('attempt-counter'); + const progressFill = document.getElementById('progress-fill'); + + if (!exerciseContent || !this.currentExercise) return; + + // Update progress + const progressPercentage = ((this.exerciseIndex + 1) / this.exercises.length) * 100; + if (progressFill) progressFill.style.width = `${progressPercentage}%`; + if (exerciseCounter) exerciseCounter.textContent = `Exercise ${this.exerciseIndex + 1} of ${this.exercises.length}`; + if (attemptCounter) attemptCounter.textContent = `Attempt 1 of ${this.config.maxAttempts}`; + + // Display exercise based on type + this._displayExerciseByType(this.currentExercise); + + // Clear previous answer and focus + const answerInput = document.getElementById('answer-input'); + if (answerInput) { + answerInput.value = ''; + answerInput.focus(); + } + + // Hide panels + const explanationPanel = document.getElementById('explanation-panel'); + const hintPanel = document.getElementById('hint-panel'); + if (explanationPanel) explanationPanel.style.display = 'none'; + if (hintPanel) hintPanel.style.display = 'none'; + } + + /** + * Display exercise content by type + * @private + */ + _displayExerciseByType(exercise) { + const exerciseContent = document.getElementById('exercise-content'); + const answerInputSection = document.getElementById('answer-input-section'); + + let content = ''; + let inputType = 'text'; + + switch (exercise.type) { + case 'multiple_choice': + content = ` +
+
${exercise.question}
+
+ ${exercise.options.map((option, index) => ` + + `).join('')} +
+
+ `; + // Hide text input for multiple choice + if (answerInputSection) answerInputSection.style.display = 'none'; + break; + + case 'fill_blank': + content = ` +
+
${exercise.question}
+
Fill in the blank with the correct word or phrase.
+
+ `; + break; + + case 'correction': + content = ` +
+
${exercise.question}
+
Identify and correct the grammar error.
+
+ `; + break; + + case 'transformation': + content = ` +
+
${exercise.question}
+
Transform the sentence according to the grammar rule.
+
+ `; + break; + + case 'conjugation': + content = ` +
+
${exercise.question}
+
Conjugate the verb correctly.
+
+ `; + break; + + case 'construction': + content = ` +
+
${exercise.question}
+
Build a grammatically correct sentence.
+
+ `; + break; + + default: + content = ` +
+
${exercise.question}
+
+ `; + break; + } + + content += ` +
+ ${this.exerciseTypes[exercise.type] || exercise.type} + + ${exercise.difficulty} + + ${exercise.points} points +
+ `; + + exerciseContent.innerHTML = content; + + // Show/hide input section based on exercise type + if (answerInputSection) { + answerInputSection.style.display = exercise.type === 'multiple_choice' ? 'none' : 'block'; + } + + // Add event listeners for multiple choice + if (exercise.type === 'multiple_choice') { + const radioInputs = document.querySelectorAll('input[name="grammar-option"]'); + radioInputs.forEach(input => { + input.addEventListener('change', () => { + const validateBtn = document.getElementById('validate-answer-btn'); + if (validateBtn) { + validateBtn.disabled = !input.checked || this.validationInProgress; + } + }); + }); + } + } + + /** + * Handle show hint + * @private + */ + _handleShowHint() { + const hintPanel = document.getElementById('hint-panel'); + const hintText = document.getElementById('hint-text'); + const showHintBtn = document.getElementById('show-hint-btn'); + + if (!hintPanel || !hintText || !this.currentExercise.hint) return; + + hintText.textContent = this.currentExercise.hint; + hintPanel.style.display = 'block'; + this.hintUsed = true; + + // Disable hint button after use + if (showHintBtn) { + showHintBtn.disabled = true; + showHintBtn.innerHTML = ` + + Hint Used + `; + } + } + + /** + * Handle show rule + * @private + */ + _handleShowRule() { + const ruleSection = document.getElementById('rule-explanation-section'); + const exercisesSection = document.getElementById('exercises-section'); + + // Toggle visibility + if (ruleSection && exercisesSection) { + const isRuleVisible = ruleSection.style.display !== 'none'; + ruleSection.style.display = isRuleVisible ? 'none' : 'block'; + exercisesSection.style.display = isRuleVisible ? 'block' : 'none'; + + const showRuleBtn = document.getElementById('show-rule-btn'); + if (showRuleBtn) { + showRuleBtn.innerHTML = isRuleVisible ? ` + 📖 + Review Rule + ` : ` + ✏️ + Back to Exercise + `; + } + } + } + + /** + * Handle user input validation + * @private + */ + async _handleUserInput() { + let userAnswer = ''; + + // Get answer based on exercise type + if (this.currentExercise.type === 'multiple_choice') { + const selectedOption = document.querySelector('input[name="grammar-option"]:checked'); + if (!selectedOption) return; + userAnswer = selectedOption.value; + } else { + const answerInput = document.getElementById('answer-input'); + if (!answerInput) return; + userAnswer = answerInput.value.trim(); + if (!userAnswer) return; + } + + const validateBtn = document.getElementById('validate-answer-btn'); + const statusDiv = document.getElementById('validation-status'); + + if (!validateBtn || !statusDiv) return; + + try { + // Increment attempts + this.attempts++; + const attemptCounter = document.getElementById('attempt-counter'); + if (attemptCounter) { + attemptCounter.textContent = `Attempt ${this.attempts} of ${this.config.maxAttempts}`; + } + + // Set validation in progress + this.validationInProgress = true; + validateBtn.disabled = true; + + // Disable inputs + const answerInput = document.getElementById('answer-input'); + const radioInputs = document.querySelectorAll('input[name="grammar-option"]'); + if (answerInput) answerInput.disabled = true; + radioInputs.forEach(input => input.disabled = true); + + // Show loading status + statusDiv.innerHTML = ` +
+
🔍
+ ${this.aiAvailable ? 'AI is analyzing your grammar...' : 'Checking your answer...'} +
+ `; + + // Call validation + const result = await this.validate(userAnswer, {}); + this.lastValidationResult = result; + + // Store result if correct or max attempts reached + if (result.correct || this.attempts >= this.config.maxAttempts) { + this.exerciseResults[this.exerciseIndex] = { + exercise: this.currentExercise.question, + userAnswer: userAnswer, + correctAnswer: this.currentExercise.correctAnswer, + correct: result.correct, + score: result.score, + feedback: result.feedback, + attempts: this.attempts, + hintUsed: this.hintUsed, + timestamp: new Date().toISOString() + }; + } + + // Show result + this._showValidationResult(result); + + // Update status + statusDiv.innerHTML = ` +
+ ${result.correct ? '✅' : '📚'} + Analysis complete +
+ `; + + } catch (error) { + console.error('❌ Grammar validation error:', error); + + // Show error status + statusDiv.innerHTML = ` +
+ ⚠️ + Error: ${error.message} +
+ `; + + // Re-enable input for retry + this._enableRetry(); + } + } + + /** + * Show validation result in explanation panel + * @private + */ + _showValidationResult(result) { + const explanationPanel = document.getElementById('explanation-panel'); + const explanationContent = document.getElementById('explanation-content'); + const nextBtn = document.getElementById('next-exercise-btn'); + const retryBtn = document.getElementById('retry-exercise-btn'); + const finishBtn = document.getElementById('finish-grammar-btn'); + + if (!explanationPanel || !explanationContent) return; + + // Show panel + explanationPanel.style.display = 'block'; + + // Set explanation content + explanationContent.innerHTML = ` +
+
+ + ${result.correct ? '✅ Excellent Grammar!' : '📚 Keep Studying!'} + + Score: ${result.score}/100 +
+
+ Correct Answer: ${this.currentExercise.correctAnswer} +
+
${result.explanation || result.feedback}
+ ${result.grammarAnalysis ? '
📚 This analysis focuses on grammatical accuracy and rule application.
' : ''} + ${this.hintUsed ? '
💡 Remember: Using hints helps learning but reduces scores. Try to apply grammar rules independently next time!
' : ''} + ${this.attempts > 1 ? '
🎯 Multiple attempts help reinforce learning. Review the rule and practice more!
' : ''} +
+ `; + + // Show appropriate buttons + const isLastExercise = this.exerciseIndex >= this.exercises.length - 1; + const canRetry = !result.correct && this.attempts < this.config.maxAttempts; + + if (nextBtn) nextBtn.style.display = (result.correct || this.attempts >= this.config.maxAttempts) && !isLastExercise ? 'inline-block' : 'none'; + if (finishBtn) finishBtn.style.display = (result.correct || this.attempts >= this.config.maxAttempts) && isLastExercise ? 'inline-block' : 'none'; + if (retryBtn) retryBtn.style.display = canRetry ? 'inline-block' : 'none'; + + // Scroll to explanation + explanationPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + + /** + * Handle next exercise + * @private + */ + _handleNextExercise() { + this.exerciseIndex++; + this._presentCurrentExercise(); + } + + /** + * Handle retry + * @private + */ + _handleRetry() { + // Hide explanation and enable new input + const explanationPanel = document.getElementById('explanation-panel'); + const statusDiv = document.getElementById('validation-status'); + + if (explanationPanel) explanationPanel.style.display = 'none'; + if (statusDiv) statusDiv.innerHTML = ''; + + this._enableRetry(); + } + + /** + * Enable retry after error or incorrect answer + * @private + */ + _enableRetry() { + this.validationInProgress = false; + + const answerInput = document.getElementById('answer-input'); + const validateBtn = document.getElementById('validate-answer-btn'); + const radioInputs = document.querySelectorAll('input[name="grammar-option"]'); + + if (answerInput) { + answerInput.disabled = false; + answerInput.focus(); + } + + radioInputs.forEach(input => input.disabled = false); + + if (validateBtn) { + validateBtn.disabled = false; + } + } + + /** + * Show final grammar results + * @private + */ + _showGrammarResults() { + const resultsContainer = document.getElementById('grammar-results'); + const exercisesSection = document.getElementById('exercises-section'); + + if (!resultsContainer) return; + + const correctCount = this.exerciseResults.filter(result => result.correct).length; + const totalCount = this.exerciseResults.length; + const accuracyRate = totalCount > 0 ? Math.round((correctCount / totalCount) * 100) : 0; + const totalScore = this.exerciseResults.reduce((sum, result) => sum + result.score, 0); + const avgScore = totalCount > 0 ? Math.round(totalScore / totalCount) : 0; + + let resultClass = 'results-poor'; + if (accuracyRate >= 80) resultClass = 'results-excellent'; + else if (accuracyRate >= 60) resultClass = 'results-good'; + + const resultsHTML = ` +
+

📊 Grammar Practice Results

+
+
+ ${accuracyRate}% + Accuracy Rate +
+
+ ${correctCount} / ${totalCount} exercises completed correctly +
+
+ Average Score: ${avgScore}/100 points +
+
+ +
+ ${this.exerciseResults.map((result, index) => ` +
+
+ Ex${index + 1} + ${result.correct ? '✅' : '📚'} + ${this.exerciseTypes[this.exercises[index]?.type] || 'Grammar'} + Score: ${result.score}/100 + Attempts: ${result.attempts} + ${result.hintUsed ? '💡 Hint' : ''} +
+
+ `).join('')} +
+ +
+ + +
+
+ `; + + resultsContainer.innerHTML = resultsHTML; + resultsContainer.style.display = 'block'; + + // Hide other sections + if (exercisesSection) exercisesSection.style.display = 'none'; + + // Add action listeners + document.getElementById('complete-grammar-btn').onclick = () => this._completeGrammarExercise(); + document.getElementById('practice-again-btn').onclick = () => this._practiceAgain(); + } + + /** + * Complete grammar exercise + * @private + */ + _completeGrammarExercise() { + // Mark grammar rule as mastered if performance is good + const correctCount = this.exerciseResults.filter(result => result.correct).length; + const accuracyRate = correctCount / this.exerciseResults.length; + + if (accuracyRate >= 0.7) { // 70% accuracy threshold for grammar + const ruleId = this.currentGrammarRule.id || this.currentGrammarRule.title || 'grammar_rule'; + const metadata = { + accuracyRate: Math.round(accuracyRate * 100), + exercisesCompleted: this.exerciseResults.length, + correctExercises: correctCount, + totalAttempts: this.exerciseResults.reduce((sum, result) => sum + result.attempts, 0), + avgScore: Math.round(this.exerciseResults.reduce((sum, result) => sum + result.score, 0) / this.exerciseResults.length), + sessionId: this.orchestrator?.sessionId || 'unknown', + moduleType: 'grammar', + aiAnalysisUsed: this.aiAvailable, + ruleType: this.currentGrammarRule.type || 'general' + }; + + this.prerequisiteEngine.markGrammarMastered(ruleId, metadata); + + // Also save to persistent storage + if (window.addMasteredItem && this.orchestrator?.bookId && this.orchestrator?.chapterId) { + window.addMasteredItem( + this.orchestrator.bookId, + this.orchestrator.chapterId, + 'grammar', + ruleId, + metadata + ); + } + } + + // Emit completion event + this.orchestrator._eventBus.emit('drs:exerciseCompleted', { + moduleType: 'grammar', + results: this.exerciseResults, + progress: this.getProgress() + }, 'GrammarModule'); + } + + /** + * Practice grammar rule again + * @private + */ + _practiceAgain() { + this.exerciseIndex = 0; + this.exerciseResults = []; + this.attempts = 0; + this.hintUsed = false; + this._showRuleExplanation(); + + const resultsContainer = document.getElementById('grammar-results'); + if (resultsContainer) resultsContainer.style.display = 'none'; + } + + /** + * Add CSS styles for grammar exercise + * @private + */ + _addStyles() { + if (document.getElementById('grammar-module-styles')) return; + + const styles = document.createElement('style'); + styles.id = 'grammar-module-styles'; + styles.textContent = ` + .grammar-exercise { + max-width: 900px; + margin: 0 auto; + padding: 20px; + display: grid; + gap: 20px; + } + + .exercise-header { + text-align: center; + margin-bottom: 20px; + } + + .grammar-info { + margin-top: 10px; + } + + .grammar-meta { + color: #666; + font-size: 0.9em; + } + + .grammar-content { + display: grid; + gap: 20px; + } + + .rule-explanation-card, .exercise-card { + background: white; + border-radius: 12px; + padding: 30px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + } + + .rule-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 2px solid #eee; + } + + .rule-header h3 { + margin: 0; + color: #333; + font-size: 1.5em; + } + + .rule-stats { + display: flex; + gap: 15px; + font-size: 0.9em; + } + + .rule-type { + background: #e3f2fd; + color: #1976d2; + padding: 4px 12px; + border-radius: 20px; + font-weight: 500; + } + + .rule-difficulty { + background: #f3e5f5; + color: #7b1fa2; + padding: 4px 12px; + border-radius: 20px; + font-weight: 500; + } + + .rule-content { + line-height: 1.6; + color: #444; + } + + .rule-description { + font-size: 1.1em; + margin-bottom: 20px; + } + + .rule-examples { + margin-bottom: 20px; + padding: 20px; + background: linear-gradient(135deg, #f8f9fa, #e9ecef); + border-radius: 10px; + border-left: 4px solid #28a745; + } + + .rule-examples h4 { + margin: 0 0 15px 0; + color: #333; + } + + .rule-examples ul { + margin: 0; + padding-left: 20px; + } + + .rule-examples li { + margin-bottom: 8px; + font-weight: 500; + color: #555; + } + + .rule-notes { + padding: 20px; + background: linear-gradient(135deg, #fff3cd, #ffeaa7); + border-radius: 10px; + border-left: 4px solid #ffc107; + } + + .rule-notes h4 { + margin: 0 0 10px 0; + color: #333; + } + + .rule-notes p { + margin: 0; + color: #555; + } + + .rule-actions { + text-align: center; + margin-top: 25px; + } + + .exercise-progress { + margin-bottom: 25px; + } + + .progress-indicator { + text-align: center; + margin-bottom: 10px; + } + + .attempt-info { + text-align: center; + color: #666; + font-size: 0.9em; + } + + .progress-bar { + width: 100%; + height: 8px; + background-color: #e0e0e0; + border-radius: 4px; + margin-top: 10px; + overflow: hidden; + } + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, #6c5ce7, #a29bfe); + transition: width 0.5s ease; + } + + .exercise-display { + margin-bottom: 25px; + padding: 20px; + background: linear-gradient(135deg, #f8f9ff, #e8f4fd); + border-radius: 10px; + border-left: 4px solid #6c5ce7; + } + + .exercise-question { + font-size: 1.3em; + font-weight: 600; + color: #333; + margin-bottom: 15px; + line-height: 1.4; + } + + .exercise-instruction { + font-style: italic; + color: #666; + margin-bottom: 15px; + } + + .exercise-options { + display: grid; + gap: 10px; + margin-top: 15px; + } + + .option-label { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 15px; + background: white; + border-radius: 8px; + border: 2px solid #eee; + cursor: pointer; + transition: all 0.3s ease; + } + + .option-label:hover { + border-color: #6c5ce7; + background: #f8f9ff; + } + + .option-label input[type="radio"] { + margin: 0; + } + + .option-text { + font-weight: 500; + color: #333; + } + + .exercise-meta { + display: flex; + gap: 15px; + align-items: center; + flex-wrap: wrap; + margin-top: 15px; + } + + .exercise-type { + background: #e3f2fd; + color: #1976d2; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .exercise-points { + background: #e8f5e8; + color: #2e7d32; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .difficulty-easy { + background: #e8f5e8; + color: #2e7d32; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .difficulty-medium { + background: #fff3e0; + color: #f57c00; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .difficulty-hard { + background: #ffebee; + color: #c62828; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .answer-input-section { + margin-bottom: 20px; + } + + .answer-input-section label { + display: block; + margin-bottom: 10px; + font-weight: 600; + color: #555; + } + + .answer-input-section input[type="text"] { + width: 100%; + padding: 15px; + font-size: 1.05em; + border: 2px solid #ddd; + border-radius: 8px; + box-sizing: border-box; + transition: border-color 0.3s ease; + font-family: inherit; + } + + .answer-input-section input[type="text"]:focus { + outline: none; + border-color: #6c5ce7; + } + + .exercise-controls { + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; + } + + .exercise-controls > div:first-child { + display: flex; + gap: 15px; + flex-wrap: wrap; + justify-content: center; + } + + .hint-panel { + margin-top: 20px; + padding: 20px; + background: linear-gradient(135deg, #fff3cd, #ffeaa7); + border-radius: 10px; + border-left: 4px solid #ffc107; + } + + .hint-content h4 { + margin: 0 0 10px 0; + color: #333; + } + + .hint-content p { + margin: 0; + color: #555; + line-height: 1.6; + } + + .validation-status { + min-height: 30px; + display: flex; + align-items: center; + justify-content: center; + } + + .status-loading, .status-complete, .status-error { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 20px; + border-radius: 25px; + font-weight: 500; + } + + .status-loading { + background: #e3f2fd; + color: #1976d2; + } + + .status-complete { + background: #e8f5e8; + color: #2e7d32; + } + + .status-error { + background: #ffebee; + color: #c62828; + } + + .loading-spinner { + animation: spin 1s linear infinite; + } + + .explanation-panel { + background: white; + border-radius: 12px; + padding: 25px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + border-left: 4px solid #6c5ce7; + } + + .panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid #eee; + } + + .panel-header h3 { + margin: 0; + color: #333; + } + + .analysis-model { + font-size: 0.9em; + color: #666; + background: #f5f5f5; + padding: 4px 8px; + border-radius: 4px; + } + + .explanation-result { + padding: 20px; + border-radius: 8px; + margin-bottom: 15px; + } + + .explanation-result.correct { + border-left: 4px solid #4caf50; + background: linear-gradient(135deg, #f1f8e9, #e8f5e8); + } + + .explanation-result.needs-improvement { + border-left: 4px solid #ff9800; + background: linear-gradient(135deg, #fff8e1, #fff3e0); + } + + .result-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + font-weight: 600; + } + + .result-indicator { + font-size: 1.1em; + } + + .grammar-score { + font-size: 0.9em; + color: #666; + } + + .correct-answer-display { + background: rgba(108, 92, 231, 0.1); + padding: 10px 15px; + border-radius: 8px; + margin-bottom: 15px; + font-weight: 500; + color: #333; + } + + .explanation-text { + line-height: 1.6; + color: #333; + font-size: 1.05em; + margin-bottom: 10px; + } + + .analysis-note, .hint-note, .attempt-note { + font-size: 0.9em; + color: #666; + font-style: italic; + padding-top: 10px; + border-top: 1px solid #eee; + margin-top: 10px; + } + + .hint-note { + color: #f57c00; + } + + .attempt-note { + color: #1976d2; + } + + .panel-actions { + display: flex; + gap: 15px; + justify-content: center; + flex-wrap: wrap; + } + + .grammar-results-content { + background: white; + border-radius: 12px; + padding: 30px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + text-align: center; + } + + .results-summary { + margin-bottom: 30px; + } + + .accuracy-display { + margin-bottom: 15px; + } + + .accuracy-rate { + font-size: 3em; + font-weight: bold; + display: block; + } + + .results-excellent .accuracy-rate { color: #4caf50; } + .results-good .accuracy-rate { color: #ff9800; } + .results-poor .accuracy-rate { color: #f44336; } + + .accuracy-label { + font-size: 1.2em; + color: #666; + } + + .exercises-summary, .score-summary { + font-size: 1.1em; + color: #555; + margin-bottom: 10px; + } + + .exercise-breakdown { + display: grid; + gap: 10px; + margin-bottom: 30px; + } + + .exercise-result { + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px; + border-radius: 8px; + background: #f8f9fa; + } + + .exercise-result.correct { + background: linear-gradient(135deg, #e8f5e8, #f1f8e9); + border-left: 4px solid #4caf50; + } + + .exercise-result.incorrect { + background: linear-gradient(135deg, #fff8e1, #fff3e0); + border-left: 4px solid #ff9800; + } + + .exercise-summary { + display: flex; + align-items: center; + gap: 15px; + } + + .exercise-num { + font-weight: bold; + color: #333; + } + + .attempts-info, .hint-used { + font-size: 0.85em; + color: #666; + background: rgba(255,255,255,0.7); + padding: 4px 8px; + border-radius: 10px; + } + + .hint-used { + color: #f57c00; + } + + .results-actions { + display: flex; + gap: 15px; + justify-content: center; + flex-wrap: wrap; + } + + .btn { + padding: 12px 24px; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 1em; + font-weight: 500; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + gap: 8px; + text-decoration: none; + } + + .btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .btn-primary { + background: linear-gradient(135deg, #6c5ce7, #a29bfe); + color: white; + } + + .btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(108, 92, 231, 0.3); + } + + .btn-secondary { + background: #6c757d; + color: white; + } + + .btn-secondary:hover:not(:disabled) { + background: #5a6268; + } + + .btn-outline { + background: transparent; + border: 2px solid #6c5ce7; + color: #6c5ce7; + } + + .btn-outline:hover:not(:disabled) { + background: #6c5ce7; + color: white; + } + + .btn-success { + background: linear-gradient(135deg, #00b894, #00cec9); + color: white; + } + + .btn-success:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(0, 184, 148, 0.3); + } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + + @media (max-width: 768px) { + .grammar-exercise { + padding: 15px; + } + + .rule-explanation-card, .exercise-card, .explanation-panel { + padding: 20px; + } + + .exercise-question { + font-size: 1.2em; + } + + .accuracy-rate { + font-size: 2.5em; + } + + .panel-actions, .results-actions { + flex-direction: column; + } + + .rule-header { + flex-direction: column; + gap: 15px; + align-items: flex-start; + } + + .exercise-controls > div:first-child { + flex-direction: column; + align-items: center; + } + + .exercise-summary { + flex-wrap: wrap; + gap: 8px; + } + } + `; + + document.head.appendChild(styles); + } +} + +export default GrammarModule; \ No newline at end of file diff --git a/src/DRS/exercise-modules/ImageModule.js b/src/DRS/exercise-modules/ImageModule.js new file mode 100644 index 0000000..2c5d0ab --- /dev/null +++ b/src/DRS/exercise-modules/ImageModule.js @@ -0,0 +1,1949 @@ +/** + * ImageModule - Visual comprehension exercises with AI validation + * Handles image content with visual analysis questions and description exercises + */ + +import ExerciseModuleInterface from '../interfaces/ExerciseModuleInterface.js'; + +class ImageModule extends ExerciseModuleInterface { + constructor(orchestrator, llmValidator, prerequisiteEngine, contextMemory) { + super(); + + // Validate dependencies + if (!orchestrator || !llmValidator || !prerequisiteEngine || !contextMemory) { + throw new Error('ImageModule requires all service dependencies'); + } + + this.orchestrator = orchestrator; + this.llmValidator = llmValidator; + this.prerequisiteEngine = prerequisiteEngine; + this.contextMemory = contextMemory; + + // Module state + this.initialized = false; + this.container = null; + this.currentExerciseData = null; + this.currentImage = null; + this.currentQuestion = null; + this.questionIndex = 0; + this.questionResults = []; + this.validationInProgress = false; + this.lastValidationResult = null; + this.aiAvailable = false; + this.imageLoaded = false; + this.viewingTime = 0; + this.startViewTime = null; + + // Configuration + this.config = { + requiredProvider: 'openai', // Prefer OpenAI for image analysis + model: 'gpt-4o-mini', // Model that supports vision + temperature: 0.2, + maxTokens: 800, + timeout: 45000, // Longer timeout for image analysis + questionsPerImage: 3, // Default number of questions per image + minViewTime: 5, // Minimum viewing time in seconds for bonus + showImageDuringQuestions: true, // Keep image visible during questions + allowZoom: true // Allow image zooming + }; + + // Languages configuration + this.languages = { + userLanguage: 'English', + targetLanguage: 'French' + }; + + // Bind methods + this._handleUserInput = this._handleUserInput.bind(this); + this._handleNextQuestion = this._handleNextQuestion.bind(this); + this._handleRetry = this._handleRetry.bind(this); + this._handleImageLoad = this._handleImageLoad.bind(this); + this._handleImageZoom = this._handleImageZoom.bind(this); + this._startViewTimer = this._startViewTimer.bind(this); + this._stopViewTimer = this._stopViewTimer.bind(this); + } + + async init() { + if (this.initialized) return; + + console.log('🖼️ Initializing ImageModule...'); + + // Test AI connectivity - required for image analysis + try { + const testResult = await this.llmValidator.testConnectivity(); + if (testResult.success) { + console.log(`✅ AI connectivity verified for image analysis (providers: ${testResult.availableProviders?.join(', ') || testResult.provider})`); + this.aiAvailable = true; + } else { + console.warn('⚠️ AI connection failed - image comprehension will be very limited:', testResult.error); + this.aiAvailable = false; + } + } catch (error) { + console.warn('⚠️ AI connectivity test failed - using basic image analysis:', error.message); + this.aiAvailable = false; + } + + this.initialized = true; + console.log(`✅ ImageModule initialized (AI: ${this.aiAvailable ? 'available for vision analysis' : 'limited - basic analysis only'})`); + } + + /** + * Check if module can run with current prerequisites + * @param {Array} prerequisites - List of learned vocabulary/concepts + * @param {Object} chapterContent - Full chapter content + * @returns {boolean} - True if module can run + */ + canRun(prerequisites, chapterContent) { + // Check if there are images and if prerequisites allow them + const images = chapterContent?.images || []; + if (images.length === 0) return false; + + // Find images that can be unlocked with current prerequisites + const availableImages = images.filter(image => { + const unlockStatus = this.prerequisiteEngine.canUnlock('image', image); + return unlockStatus.canUnlock; + }); + + return availableImages.length > 0; + } + + /** + * Present exercise UI and content + * @param {HTMLElement} container - DOM container to render into + * @param {Object} exerciseData - Specific exercise data to present + * @returns {Promise} + */ + async present(container, exerciseData) { + if (!this.initialized) { + throw new Error('ImageModule must be initialized before use'); + } + + this.container = container; + this.currentExerciseData = exerciseData; + this.currentImage = exerciseData.image; + this.questionIndex = 0; + this.questionResults = []; + this.validationInProgress = false; + this.lastValidationResult = null; + this.imageLoaded = false; + this.viewingTime = 0; + this.startViewTime = null; + + // Detect languages from chapter content + this._detectLanguages(exerciseData); + + // Generate or extract questions + this.questions = await this._prepareQuestions(this.currentImage); + + console.log(`🖼️ Presenting image comprehension: "${this.currentImage.title || 'Visual Exercise'}" (${this.questions.length} questions)`); + + // Render initial UI + await this._renderImageExercise(); + + // Start with image viewing phase + this._showImageViewing(); + } + + /** + * Validate user input with AI for deep image comprehension + * @param {string} userInput - User's response + * @param {Object} context - Exercise context + * @returns {Promise} - Validation result with score and feedback + */ + async validate(userInput, context) { + if (!userInput || !userInput.trim()) { + throw new Error('Please provide an answer'); + } + + if (!this.currentImage || !this.currentQuestion) { + throw new Error('No image or question loaded for validation'); + } + + console.log(`🖼️ Validating image comprehension answer for question ${this.questionIndex + 1}`); + + // Build comprehensive prompt for image comprehension + const prompt = this._buildImageComprehensionPrompt(userInput); + + try { + // Use AI validation with structured response - includes image if available + const aiResponse = await this.llmValidator.iaEngine.validateEducationalContent(prompt, { + preferredProvider: this.config.requiredProvider, + temperature: this.config.temperature, + maxTokens: this.config.maxTokens, + timeout: this.config.timeout, + imageUrl: this.currentImage.url || this.currentImage.src, // Include image for vision analysis + systemPrompt: `You are an expert visual comprehension evaluator. Analyze both the image content and the student's response. Focus on visual observation skills and interpretation. ALWAYS respond in the exact format: [answer]yes/no [explanation]your detailed analysis here` + }); + + // Parse structured response + const parsedResult = this._parseStructuredResponse(aiResponse); + + // Apply viewing time bonus + if (this.viewingTime >= this.config.minViewTime) { + parsedResult.score = Math.min(parsedResult.score + 5, 100); + parsedResult.feedback += ` (Bonus: +5 points for thorough image observation)`; + } + + // Record interaction in context memory + this.contextMemory.recordInteraction({ + type: 'image', + subtype: 'comprehension', + content: { + image: this.currentImage, + question: this.currentQuestion, + imageTitle: this.currentImage.title || 'Visual Exercise', + imageDescription: this.currentImage.description || '', + viewingTime: this.viewingTime + }, + userResponse: userInput.trim(), + validation: parsedResult, + context: { + languages: this.languages, + questionIndex: this.questionIndex, + totalQuestions: this.questions.length, + observationTime: this.viewingTime + } + }); + + return parsedResult; + + } catch (error) { + console.error('❌ AI image comprehension validation failed:', error); + + // Fallback to basic keyword analysis if AI fails + if (!this.aiAvailable) { + return this._performBasicImageValidation(userInput); + } + + throw new Error(`Image comprehension validation failed: ${error.message}. Please check your answer and try again.`); + } + } + + /** + * Get current progress data + * @returns {ProgressData} - Progress information for this module + */ + getProgress() { + const totalQuestions = this.questions ? this.questions.length : 0; + const completedQuestions = this.questionResults.length; + const correctAnswers = this.questionResults.filter(result => result.correct).length; + + return { + type: 'image', + imageTitle: this.currentImage?.title || 'Visual Exercise', + totalQuestions, + completedQuestions, + correctAnswers, + currentQuestionIndex: this.questionIndex, + questionResults: this.questionResults, + progressPercentage: totalQuestions > 0 ? Math.round((completedQuestions / totalQuestions) * 100) : 0, + comprehensionRate: completedQuestions > 0 ? Math.round((correctAnswers / completedQuestions) * 100) : 0, + observationTime: this.viewingTime, + aiAnalysisAvailable: this.aiAvailable + }; + } + + /** + * Clean up and prepare for unloading + */ + cleanup() { + console.log('🧹 Cleaning up ImageModule...'); + + // Stop view timer + this._stopViewTimer(); + + // Remove event listeners + if (this.container) { + this.container.innerHTML = ''; + } + + // Reset state + this.container = null; + this.currentExerciseData = null; + this.currentImage = null; + this.currentQuestion = null; + this.questionIndex = 0; + this.questionResults = []; + this.questions = null; + this.validationInProgress = false; + this.lastValidationResult = null; + this.imageLoaded = false; + this.viewingTime = 0; + this.startViewTime = null; + + console.log('✅ ImageModule cleaned up'); + } + + /** + * Get module metadata + * @returns {Object} - Module information + */ + getMetadata() { + return { + name: 'ImageModule', + type: 'image', + version: '1.0.0', + description: 'Visual comprehension exercises with AI-powered image analysis', + capabilities: ['image_comprehension', 'visual_analysis', 'description_skills', 'ai_vision', 'ai_feedback'], + aiRequired: true, // Highly recommended for image analysis + config: this.config + }; + } + + // Private Methods + + /** + * Detect languages from exercise data + * @private + */ + _detectLanguages(exerciseData) { + const chapterContent = this.currentExerciseData?.chapterContent; + + if (chapterContent?.metadata?.userLanguage) { + this.languages.userLanguage = chapterContent.metadata.userLanguage; + } + + if (chapterContent?.metadata?.targetLanguage) { + this.languages.targetLanguage = chapterContent.metadata.targetLanguage; + } + + console.log(`🌍 Image languages detected: ${this.languages.userLanguage} -> ${this.languages.targetLanguage}`); + } + + /** + * Prepare questions for the image + * @private + */ + async _prepareQuestions(image) { + // If image already has questions, use them + if (image.questions && image.questions.length > 0) { + return image.questions.map((q, index) => ({ + id: `q${index + 1}`, + question: q.question || q.text || q, + type: q.type || 'open', + expectedAnswer: q.answer || q.expectedAnswer, + keywords: q.keywords || [], + difficulty: q.difficulty || 'medium', + focusArea: q.focusArea || 'general' // general, details, interpretation, description + })); + } + + // Generate default visual comprehension questions + const defaultQuestions = [ + { + id: 'description', + question: `Describe what you see in this image in detail.`, + type: 'open', + keywords: ['see', 'image', 'shows', 'contains', 'depicts'], + difficulty: 'easy', + focusArea: 'description' + }, + { + id: 'details', + question: `What specific details or objects can you identify in the image?`, + type: 'open', + keywords: ['details', 'objects', 'specific', 'identify', 'notice'], + difficulty: 'medium', + focusArea: 'details' + }, + { + id: 'interpretation', + question: `What do you think is happening in this image? What story does it tell?`, + type: 'open', + keywords: ['happening', 'story', 'context', 'situation', 'interpret'], + difficulty: 'hard', + focusArea: 'interpretation' + } + ]; + + // Limit to configured number of questions + return defaultQuestions.slice(0, this.config.questionsPerImage); + } + + /** + * Build comprehensive prompt for image comprehension validation + * @private + */ + _buildImageComprehensionPrompt(userAnswer) { + const imageTitle = this.currentImage.title || 'Visual Exercise'; + const imageDescription = this.currentImage.description || 'No description provided'; + const imageUrl = this.currentImage.url || this.currentImage.src || ''; + + return `You are evaluating visual comprehension for a language learning exercise. + +CRITICAL: You MUST respond in this EXACT format: [answer]yes/no [explanation]your detailed analysis here + +IMAGE CONTENT: +Title: "${imageTitle}" +Description: "${imageDescription}" +${imageUrl ? `Image URL: ${imageUrl}` : 'Image: Available for visual analysis'} + +QUESTION: ${this.currentQuestion.question} + +STUDENT RESPONSE: "${userAnswer}" + +EVALUATION CONTEXT: +- Exercise Type: Visual comprehension +- Languages: ${this.languages.userLanguage} -> ${this.languages.targetLanguage} +- Question Type: ${this.currentQuestion.type} +- Question Difficulty: ${this.currentQuestion.difficulty} +- Focus Area: ${this.currentQuestion.focusArea} +- Question ${this.questionIndex + 1} of ${this.questions.length} +- Observation Time: ${this.viewingTime}s + +EVALUATION CRITERIA: +- [answer]yes if the student demonstrates good visual observation and understanding +- [answer]no if the response shows poor observation or is unrelated to the image +- Focus on VISUAL COMPREHENSION and OBSERVATION SKILLS, not perfect language +- Accept different perspectives and interpretations if they show visual understanding +- Reward specific details, accurate observations, and thoughtful interpretation +- Consider the student's language learning level and cultural context +- For description questions: reward comprehensive and accurate descriptions +- For detail questions: reward specific and accurate observations +- For interpretation questions: reward thoughtful analysis and context understanding + +[explanation] should provide: +1. What the student observed correctly from the image +2. What important visual elements they might have missed +3. How well their interpretation matches the image content +4. Encouragement and specific suggestions for better visual observation +5. Tips for developing visual comprehension skills +6. Recognition of cultural or contextual insights if present + +Format: [answer]yes/no [explanation]your comprehensive educational feedback here`; + } + + /** + * Parse structured AI response for image comprehension + * @private + */ + _parseStructuredResponse(aiResponse) { + try { + let responseText = ''; + + // Extract text from AI response + if (typeof aiResponse === 'string') { + responseText = aiResponse; + } else if (aiResponse.content) { + responseText = aiResponse.content; + } else if (aiResponse.text) { + responseText = aiResponse.text; + } else { + responseText = JSON.stringify(aiResponse); + } + + console.log('🔍 Parsing AI image comprehension response:', responseText.substring(0, 150) + '...'); + + // Extract [answer] - case insensitive + const answerMatch = responseText.match(/\[answer\](yes|no)/i); + if (!answerMatch) { + throw new Error('AI response missing [answer] format'); + } + + // Extract [explanation] - multiline support + const explanationMatch = responseText.match(/\[explanation\](.+)/s); + if (!explanationMatch) { + throw new Error('AI response missing [explanation] format'); + } + + const isCorrect = answerMatch[1].toLowerCase() === 'yes'; + const explanation = explanationMatch[1].trim(); + + // Standard scores for image comprehension + const result = { + score: isCorrect ? 88 : 58, // Good scores for visual comprehension + correct: isCorrect, + feedback: explanation, + answer: answerMatch[1].toLowerCase(), + explanation: explanation, + timestamp: new Date().toISOString(), + provider: this.config.requiredProvider, + model: this.config.model, + cached: false, + formatValid: true, + imageComprehension: true, + visionAnalysis: true + }; + + console.log(`✅ AI image comprehension parsed: ${result.answer} - Score: ${result.score}`); + return result; + + } catch (error) { + console.error('❌ Failed to parse AI image comprehension response:', error); + console.error('Raw response:', aiResponse); + + throw new Error(`AI response format invalid: ${error.message}`); + } + } + + /** + * Perform basic image validation when AI is unavailable + * @private + */ + _performBasicImageValidation(userAnswer) { + console.log('🔍 Performing basic image validation (AI unavailable)'); + + const answerLength = userAnswer.trim().length; + const hasKeywords = this.currentQuestion.keywords?.some(keyword => + userAnswer.toLowerCase().includes(keyword.toLowerCase()) + ); + + // Basic scoring based on answer length and keyword presence + let score = 30; // Lower base score for images (much harder without AI vision) + + if (answerLength > 20) score += 15; // Substantial answer + if (answerLength > 50) score += 10; // Detailed answer + if (hasKeywords) score += 20; // Contains relevant keywords + if (answerLength > 100) score += 10; // Very detailed + if (this.viewingTime >= this.config.minViewTime) score += 10; // Good observation time + + const isCorrect = score >= 60; + + return { + score: Math.min(score, 100), + correct: isCorrect, + feedback: isCorrect + ? "Good visual observation! Your description shows attention to detail and understanding of the image." + : "Try to observe the image more carefully and include more specific details about what you see. Look for objects, people, actions, colors, and settings.", + timestamp: new Date().toISOString(), + provider: 'basic_image_analysis', + model: 'keyword_length_analysis', + cached: false, + mockGenerated: true, + imageComprehension: true, + limitedAnalysis: true + }; + } + + /** + * Render the image exercise interface + * @private + */ + async _renderImageExercise() { + if (!this.container || !this.currentImage) return; + + const imageTitle = this.currentImage.title || 'Visual Exercise'; + const imageDescription = this.currentImage.description || ''; + const imageUrl = this.currentImage.url || this.currentImage.src || ''; + + this.container.innerHTML = ` +
+
+

🖼️ Visual Comprehension

+
+ + ${this.questions?.length || 0} questions + ${!this.aiAvailable ? ' • ⚠️ Limited analysis mode' : ' • 🧠 AI vision analysis'} + +
+
+ +
+
+
+
+

${imageTitle}

+
+ Viewing: 0s + ${this.config.allowZoom ? '' : ''} +
+
+ +
+ ${imageUrl ? ` + ${imageTitle} + ` : ` +
+ 🖼️ Image placeholder (${imageTitle}) + ${imageDescription ? `

${imageDescription}

` : ''} +
+ `} + + +
+ + ${imageDescription ? ` +
+

📝 Context

+

${imageDescription}

+
+ ` : ''} + +
+ + +
+
+
+ + + + + + +
+
+ `; + + // Add CSS styles + this._addStyles(); + + // Add event listeners + this._setupEventListeners(); + + // Start observation timer after a brief delay + setTimeout(() => { + this._startViewTimer(); + const observeBtn = document.getElementById('observe-more-btn'); + if (observeBtn) observeBtn.style.display = 'none'; + + const startQuestionsBtn = document.getElementById('start-questions-btn'); + if (startQuestionsBtn) startQuestionsBtn.style.display = 'inline-block'; + }, 2000); + } + + /** + * Setup event listeners for image exercise + * @private + */ + _setupEventListeners() { + const startQuestionsBtn = document.getElementById('start-questions-btn'); + const observeMoreBtn = document.getElementById('observe-more-btn'); + const lookAgainBtn = document.getElementById('look-again-btn'); + const zoomBtn = document.getElementById('zoom-btn'); + const closeOverlay = document.getElementById('close-overlay'); + const answerInput = document.getElementById('answer-input'); + const validateBtn = document.getElementById('validate-answer-btn'); + const retryBtn = document.getElementById('retry-answer-btn'); + const nextBtn = document.getElementById('next-question-btn'); + const finishBtn = document.getElementById('finish-image-btn'); + + // Navigation buttons + if (startQuestionsBtn) { + startQuestionsBtn.onclick = () => this._startQuestions(); + } + + if (observeMoreBtn) { + observeMoreBtn.onclick = () => this._continueObservation(); + } + + if (lookAgainBtn) { + lookAgainBtn.onclick = () => this._showImageViewing(); + } + + // Image interaction + if (zoomBtn) { + zoomBtn.onclick = this._handleImageZoom; + } + + if (closeOverlay) { + closeOverlay.onclick = () => { + const overlay = document.getElementById('image-overlay'); + if (overlay) overlay.style.display = 'none'; + }; + } + + // Handle image load + const mainImage = document.getElementById('main-image'); + if (mainImage) { + mainImage.addEventListener('load', this._handleImageLoad); + } + + // Answer input validation + if (answerInput) { + answerInput.addEventListener('input', () => { + const hasText = answerInput.value.trim().length > 0; + if (validateBtn) { + validateBtn.disabled = !hasText || this.validationInProgress; + } + }); + + // Allow Ctrl+Enter to validate + answerInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !validateBtn.disabled) { + e.preventDefault(); + this._handleUserInput(); + } + }); + } + + // Validate button + if (validateBtn) { + validateBtn.onclick = this._handleUserInput; + } + + // Action buttons + if (retryBtn) retryBtn.onclick = this._handleRetry; + if (nextBtn) nextBtn.onclick = this._handleNextQuestion; + if (finishBtn) finishBtn.onclick = () => this._completeImageExercise(); + } + + /** + * Handle image load event + * @private + */ + _handleImageLoad() { + this.imageLoaded = true; + console.log('🖼️ Image loaded successfully'); + } + + /** + * Handle image zoom functionality + * @private + */ + _handleImageZoom() { + const overlay = document.getElementById('image-overlay'); + const mainImage = document.getElementById('main-image'); + + if (!overlay || !mainImage) return; + + const overlayContent = overlay.querySelector('.overlay-content'); + const zoomedImage = mainImage.cloneNode(true); + zoomedImage.className = 'zoomed-image'; + zoomedImage.id = 'zoomed-image'; + + // Clear previous content and add zoomed image + overlayContent.innerHTML = ` + +
+ `; + + overlayContent.querySelector('.zoomed-container').appendChild(zoomedImage); + overlay.style.display = 'flex'; + + // Re-attach close event + overlayContent.querySelector('#close-overlay').onclick = () => { + overlay.style.display = 'none'; + }; + } + + /** + * Start view timer + * @private + */ + _startViewTimer() { + if (this.startViewTime) return; // Already started + + this.startViewTime = Date.now(); + const timer = document.getElementById('view-timer'); + + const updateTimer = () => { + if (this.startViewTime) { + this.viewingTime = Math.floor((Date.now() - this.startViewTime) / 1000); + if (timer) timer.textContent = `${this.viewingTime}s`; + setTimeout(updateTimer, 1000); + } + }; + + updateTimer(); + } + + /** + * Stop view timer + * @private + */ + _stopViewTimer() { + if (this.startViewTime) { + this.viewingTime = Math.floor((Date.now() - this.startViewTime) / 1000); + this.startViewTime = null; + } + } + + /** + * Continue observation phase + * @private + */ + _continueObservation() { + // Just hide the button and show start questions + const observeBtn = document.getElementById('observe-more-btn'); + const startQuestionsBtn = document.getElementById('start-questions-btn'); + + if (observeBtn) observeBtn.style.display = 'none'; + if (startQuestionsBtn) startQuestionsBtn.style.display = 'inline-block'; + } + + /** + * Show image viewing phase + * @private + */ + _showImageViewing() { + const imageSection = document.getElementById('image-viewer-section'); + const questionsSection = document.getElementById('questions-section'); + + if (imageSection) imageSection.style.display = 'block'; + if (questionsSection) questionsSection.style.display = 'none'; + + // Resume timer if needed + if (!this.startViewTime) { + this._startViewTimer(); + } + } + + /** + * Start questions phase + * @private + */ + _startQuestions() { + const imageSection = document.getElementById('image-viewer-section'); + const questionsSection = document.getElementById('questions-section'); + + // Keep image visible but minimized if configured + if (imageSection) { + imageSection.style.display = this.config.showImageDuringQuestions ? 'block' : 'none'; + if (this.config.showImageDuringQuestions) { + imageSection.classList.add('minimized'); + } + } + + if (questionsSection) questionsSection.style.display = 'block'; + + // Stop continuous timer - we'll track total time + this._stopViewTimer(); + + this._presentCurrentQuestion(); + } + + /** + * Present current question + * @private + */ + _presentCurrentQuestion() { + if (this.questionIndex >= this.questions.length) { + this._showImageResults(); + return; + } + + this.currentQuestion = this.questions[this.questionIndex]; + const questionContent = document.getElementById('question-content'); + const questionCounter = document.getElementById('question-counter'); + const progressFill = document.getElementById('progress-fill'); + + if (!questionContent || !this.currentQuestion) return; + + // Update progress + const progressPercentage = ((this.questionIndex + 1) / this.questions.length) * 100; + if (progressFill) progressFill.style.width = `${progressPercentage}%`; + if (questionCounter) questionCounter.textContent = `Question ${this.questionIndex + 1} of ${this.questions.length}`; + + // Display question + questionContent.innerHTML = ` +
+
${this.currentQuestion.question}
+
+ ${this.currentQuestion.type} + + ${this.currentQuestion.difficulty} + + + ${this.currentQuestion.focusArea} + +
+
+ `; + + // Clear previous answer and focus + const answerInput = document.getElementById('answer-input'); + if (answerInput) { + answerInput.value = ''; + answerInput.focus(); + } + + // Hide explanation panel + const explanationPanel = document.getElementById('explanation-panel'); + if (explanationPanel) explanationPanel.style.display = 'none'; + } + + /** + * Handle user input validation + * @private + */ + async _handleUserInput() { + const answerInput = document.getElementById('answer-input'); + const validateBtn = document.getElementById('validate-answer-btn'); + const statusDiv = document.getElementById('validation-status'); + + if (!answerInput || !validateBtn || !statusDiv) return; + + const userAnswer = answerInput.value.trim(); + if (!userAnswer) return; + + try { + // Set validation in progress + this.validationInProgress = true; + validateBtn.disabled = true; + answerInput.disabled = true; + + // Show loading status + statusDiv.innerHTML = ` +
+
🔍
+ ${this.aiAvailable ? 'AI is analyzing the image and your response...' : 'Analyzing your answer...'} +
+ `; + + // Call validation + const result = await this.validate(userAnswer, {}); + this.lastValidationResult = result; + + // Store result + this.questionResults[this.questionIndex] = { + question: this.currentQuestion.question, + userAnswer: userAnswer, + correct: result.correct, + score: result.score, + feedback: result.feedback, + timestamp: new Date().toISOString(), + observationTime: this.viewingTime + }; + + // Show result + this._showValidationResult(result); + + // Update status + statusDiv.innerHTML = ` +
+ ${result.correct ? '✅' : '👁️'} + Analysis complete +
+ `; + + } catch (error) { + console.error('❌ Image validation error:', error); + + // Show error status + statusDiv.innerHTML = ` +
+ ⚠️ + Error: ${error.message} +
+ `; + + // Re-enable input for retry + this._enableRetry(); + } + } + + /** + * Show validation result in explanation panel + * @private + */ + _showValidationResult(result) { + const explanationPanel = document.getElementById('explanation-panel'); + const explanationContent = document.getElementById('explanation-content'); + const nextBtn = document.getElementById('next-question-btn'); + const retryBtn = document.getElementById('retry-answer-btn'); + const finishBtn = document.getElementById('finish-image-btn'); + + if (!explanationPanel || !explanationContent) return; + + // Show panel + explanationPanel.style.display = 'block'; + + // Set explanation content + explanationContent.innerHTML = ` +
+
+ + ${result.correct ? '✅ Excellent Observation!' : '👁️ Keep Looking!'} + + Score: ${result.score}/100 +
+
${result.explanation || result.feedback}
+ ${result.imageComprehension ? '
🖼️ This analysis evaluates your visual observation skills and image interpretation abilities.
' : ''} + ${result.visionAnalysis ? '
🤖 AI Vision analyzed both the image content and your response for comprehensive feedback.
' : ''} + ${this.viewingTime >= this.config.minViewTime ? '
⏱️ Observation time bonus applied for thorough viewing!
' : ''} +
+ `; + + // Show appropriate buttons + const isLastQuestion = this.questionIndex >= this.questions.length - 1; + + if (nextBtn) nextBtn.style.display = isLastQuestion ? 'none' : 'inline-block'; + if (finishBtn) finishBtn.style.display = isLastQuestion ? 'inline-block' : 'none'; + if (retryBtn) retryBtn.style.display = result.correct ? 'none' : 'inline-block'; + + // Scroll to explanation + explanationPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + + /** + * Handle next question + * @private + */ + _handleNextQuestion() { + this.questionIndex++; + this._presentCurrentQuestion(); + } + + /** + * Handle retry + * @private + */ + _handleRetry() { + // Hide explanation and enable new input + const explanationPanel = document.getElementById('explanation-panel'); + const statusDiv = document.getElementById('validation-status'); + + if (explanationPanel) explanationPanel.style.display = 'none'; + if (statusDiv) statusDiv.innerHTML = ''; + + this._enableRetry(); + } + + /** + * Enable retry after error + * @private + */ + _enableRetry() { + this.validationInProgress = false; + + const answerInput = document.getElementById('answer-input'); + const validateBtn = document.getElementById('validate-answer-btn'); + + if (answerInput) { + answerInput.disabled = false; + answerInput.focus(); + } + + if (validateBtn) { + validateBtn.disabled = false; + } + } + + /** + * Show final image results + * @private + */ + _showImageResults() { + const resultsContainer = document.getElementById('image-results'); + const questionsSection = document.getElementById('questions-section'); + + if (!resultsContainer) return; + + const correctCount = this.questionResults.filter(result => result.correct).length; + const totalCount = this.questionResults.length; + const comprehensionRate = totalCount > 0 ? Math.round((correctCount / totalCount) * 100) : 0; + const avgObservationTime = this.questionResults.length > 0 ? + Math.round(this.questionResults.reduce((sum, r) => sum + (r.observationTime || 0), 0) / this.questionResults.length) : 0; + + let resultClass = 'results-poor'; + if (comprehensionRate >= 80) resultClass = 'results-excellent'; + else if (comprehensionRate >= 60) resultClass = 'results-good'; + + const resultsHTML = ` +
+

📊 Visual Comprehension Results

+
+
+ ${comprehensionRate}% + Comprehension Rate +
+
+ ${correctCount} / ${totalCount} visual observations understood well +
+
+
+ ${this.viewingTime}s + Total Viewing Time +
+
+ ${this.viewingTime >= this.config.minViewTime ? '👍' : '⚡'} + ${this.viewingTime >= this.config.minViewTime ? 'Thorough observation' : 'Quick observation'} +
+ ${this.aiAvailable ? ` +
+ 🤖 + AI Vision Analysis +
+ ` : ''} +
+
+ +
+ ${this.questionResults.map((result, index) => ` +
+
+ Q${index + 1} + ${result.correct ? '✅' : '👁️'} + Score: ${result.score}/100 + ${this.questions[index]?.focusArea || 'general'} +
+
+ `).join('')} +
+ +
+ + +
+
+ `; + + resultsContainer.innerHTML = resultsHTML; + resultsContainer.style.display = 'block'; + + // Hide other sections + if (questionsSection) questionsSection.style.display = 'none'; + + // Add action listeners + document.getElementById('complete-image-btn').onclick = () => this._completeImageExercise(); + document.getElementById('review-image-btn').onclick = () => this._reviewImage(); + } + + /** + * Complete image exercise + * @private + */ + _completeImageExercise() { + // Mark image as comprehended if performance is good + const correctCount = this.questionResults.filter(result => result.correct).length; + const comprehensionRate = correctCount / this.questionResults.length; + + if (comprehensionRate >= 0.6) { // 60% comprehension threshold + const imageId = this.currentImage.id || this.currentImage.title || 'image_exercise'; + const metadata = { + comprehensionRate: Math.round(comprehensionRate * 100), + questionsAnswered: this.questionResults.length, + correctAnswers: correctCount, + observationTime: this.viewingTime, + sessionId: this.orchestrator?.sessionId || 'unknown', + moduleType: 'image', + aiAnalysisUsed: this.aiAvailable, + visionAnalysisUsed: this.aiAvailable, + observationQuality: this.viewingTime >= this.config.minViewTime ? 'thorough' : 'quick' + }; + + this.prerequisiteEngine.markPhraseMastered(imageId, metadata); + + // Also save to persistent storage + if (window.addMasteredItem && this.orchestrator?.bookId && this.orchestrator?.chapterId) { + window.addMasteredItem( + this.orchestrator.bookId, + this.orchestrator.chapterId, + 'images', + imageId, + metadata + ); + } + } + + // Emit completion event + this.orchestrator._eventBus.emit('drs:exerciseCompleted', { + moduleType: 'image', + results: this.questionResults, + progress: this.getProgress() + }, 'ImageModule'); + } + + /** + * Review image again + * @private + */ + _reviewImage() { + this.questionIndex = 0; + this.questionResults = []; + this.viewingTime = 0; + this.startViewTime = null; + this._showImageViewing(); + + const resultsContainer = document.getElementById('image-results'); + if (resultsContainer) resultsContainer.style.display = 'none'; + + // Reset UI elements + const viewTimer = document.getElementById('view-timer'); + if (viewTimer) viewTimer.textContent = '0s'; + + const startQuestionsBtn = document.getElementById('start-questions-btn'); + if (startQuestionsBtn) startQuestionsBtn.style.display = 'none'; + + const observeBtn = document.getElementById('observe-more-btn'); + if (observeBtn) observeBtn.style.display = 'inline-block'; + } + + /** + * Add CSS styles for image exercise + * @private + */ + _addStyles() { + if (document.getElementById('image-module-styles')) return; + + const styles = document.createElement('style'); + styles.id = 'image-module-styles'; + styles.textContent = ` + .image-exercise { + max-width: 1000px; + margin: 0 auto; + padding: 20px; + display: grid; + gap: 20px; + } + + .exercise-header { + text-align: center; + margin-bottom: 20px; + } + + .image-info { + margin-top: 10px; + } + + .image-meta { + color: #666; + font-size: 0.9em; + } + + .image-content { + display: grid; + gap: 20px; + } + + .image-viewer-card, .question-card { + background: white; + border-radius: 12px; + padding: 30px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + } + + .image-viewer-section.minimized .image-viewer-card { + padding: 20px; + background: #f8f9fa; + border: 2px solid #e9ecef; + } + + .viewer-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 2px solid #eee; + } + + .viewer-header h3 { + margin: 0; + color: #333; + font-size: 1.5em; + } + + .viewer-stats { + display: flex; + gap: 15px; + align-items: center; + font-size: 0.9em; + color: #666; + } + + .view-time { + font-weight: 600; + color: #17a2b8; + } + + .image-container { + position: relative; + margin-bottom: 25px; + text-align: center; + } + + .exercise-image { + max-width: 100%; + max-height: 400px; + border-radius: 10px; + box-shadow: 0 4px 15px rgba(0,0,0,0.1); + cursor: pointer; + transition: transform 0.3s ease; + } + + .exercise-image:hover { + transform: scale(1.02); + } + + .image-placeholder { + text-align: center; + padding: 60px 40px; + background: linear-gradient(135deg, #f8f9ff, #e8f4fd); + border-radius: 10px; + color: #666; + font-size: 1.2em; + border: 2px dashed #ddd; + margin-bottom: 20px; + } + + .placeholder-desc { + margin-top: 15px; + font-size: 0.9em; + color: #888; + font-style: italic; + } + + .image-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0,0,0,0.9); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + } + + .overlay-content { + position: relative; + max-width: 90vw; + max-height: 90vh; + display: flex; + flex-direction: column; + align-items: center; + } + + .close-btn { + position: absolute; + top: -50px; + right: 0; + z-index: 1001; + background: rgba(255,255,255,0.9); + border: 2px solid #ddd; + } + + .zoomed-container { + max-width: 100%; + max-height: 100%; + overflow: auto; + } + + .zoomed-image { + max-width: none; + max-height: 80vh; + border-radius: 10px; + } + + .image-description { + margin-top: 20px; + padding: 20px; + background: linear-gradient(135deg, #f8f9fa, #e9ecef); + border-radius: 10px; + border-left: 4px solid #6c757d; + } + + .image-description h4 { + margin: 0 0 10px 0; + color: #333; + } + + .image-description p { + margin: 0; + line-height: 1.6; + color: #555; + } + + .viewing-actions { + text-align: center; + margin-top: 20px; + display: flex; + gap: 15px; + justify-content: center; + flex-wrap: wrap; + } + + .question-progress { + margin-bottom: 25px; + } + + .progress-indicator { + text-align: center; + } + + .progress-bar { + width: 100%; + height: 8px; + background-color: #e0e0e0; + border-radius: 4px; + margin-top: 10px; + overflow: hidden; + } + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, #fd79a8, #e84393); + transition: width 0.5s ease; + } + + .question-display { + margin-bottom: 25px; + padding: 20px; + background: linear-gradient(135deg, #f8f9ff, #e8f4fd); + border-radius: 10px; + border-left: 4px solid #fd79a8; + } + + .question-text { + font-size: 1.3em; + font-weight: 600; + color: #333; + margin-bottom: 15px; + line-height: 1.4; + } + + .question-meta { + display: flex; + gap: 15px; + align-items: center; + flex-wrap: wrap; + } + + .question-type { + background: #e3f2fd; + color: #1976d2; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .focus-general { + background: #f3e5f5; + color: #7b1fa2; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .focus-description { + background: #e8f5e8; + color: #2e7d32; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .focus-details { + background: #fff3e0; + color: #f57c00; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .focus-interpretation { + background: #ffebee; + color: #c62828; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .difficulty-easy { + background: #e8f5e8; + color: #2e7d32; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .difficulty-medium { + background: #fff3e0; + color: #f57c00; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .difficulty-hard { + background: #ffebee; + color: #c62828; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .answer-input-section { + margin-bottom: 20px; + } + + .answer-input-section label { + display: block; + margin-bottom: 10px; + font-weight: 600; + color: #555; + } + + .answer-input-section textarea { + width: 100%; + padding: 15px; + font-size: 1.05em; + border: 2px solid #ddd; + border-radius: 8px; + resize: vertical; + min-height: 120px; + box-sizing: border-box; + transition: border-color 0.3s ease; + font-family: inherit; + line-height: 1.5; + } + + .answer-input-section textarea:focus { + outline: none; + border-color: #fd79a8; + } + + .question-controls { + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; + } + + .question-controls > div:first-child { + display: flex; + gap: 15px; + flex-wrap: wrap; + justify-content: center; + } + + .validation-status { + min-height: 30px; + display: flex; + align-items: center; + justify-content: center; + } + + .status-loading, .status-complete, .status-error { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 20px; + border-radius: 25px; + font-weight: 500; + } + + .status-loading { + background: #e3f2fd; + color: #1976d2; + } + + .status-complete { + background: #e8f5e8; + color: #2e7d32; + } + + .status-error { + background: #ffebee; + color: #c62828; + } + + .loading-spinner { + animation: spin 1s linear infinite; + } + + .explanation-panel { + background: white; + border-radius: 12px; + padding: 25px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + border-left: 4px solid #fd79a8; + } + + .panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid #eee; + } + + .panel-header h3 { + margin: 0; + color: #333; + } + + .analysis-model { + font-size: 0.9em; + color: #666; + background: #f5f5f5; + padding: 4px 8px; + border-radius: 4px; + } + + .explanation-result { + padding: 20px; + border-radius: 8px; + margin-bottom: 15px; + } + + .explanation-result.correct { + border-left: 4px solid #4caf50; + background: linear-gradient(135deg, #f1f8e9, #e8f5e8); + } + + .explanation-result.needs-improvement { + border-left: 4px solid #ff9800; + background: linear-gradient(135deg, #fff8e1, #fff3e0); + } + + .result-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + font-weight: 600; + } + + .result-indicator { + font-size: 1.1em; + } + + .comprehension-score { + font-size: 0.9em; + color: #666; + } + + .explanation-text { + line-height: 1.6; + color: #333; + font-size: 1.05em; + margin-bottom: 10px; + } + + .analysis-note, .vision-note, .time-bonus { + font-size: 0.9em; + color: #666; + font-style: italic; + padding-top: 10px; + border-top: 1px solid #eee; + margin-top: 10px; + } + + .vision-note { + color: #1976d2; + } + + .time-bonus { + color: #2e7d32; + } + + .panel-actions { + display: flex; + gap: 15px; + justify-content: center; + flex-wrap: wrap; + } + + .image-results-content { + background: white; + border-radius: 12px; + padding: 30px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + text-align: center; + } + + .results-summary { + margin-bottom: 30px; + } + + .comprehension-display { + margin-bottom: 15px; + } + + .comprehension-rate { + font-size: 3em; + font-weight: bold; + display: block; + } + + .results-excellent .comprehension-rate { color: #4caf50; } + .results-good .comprehension-rate { color: #ff9800; } + .results-poor .comprehension-rate { color: #f44336; } + + .comprehension-label { + font-size: 1.2em; + color: #666; + } + + .questions-summary { + font-size: 1.1em; + color: #555; + margin-bottom: 20px; + } + + .observation-stats { + display: flex; + justify-content: center; + gap: 40px; + margin-top: 20px; + padding: 20px; + background: #f8f9fa; + border-radius: 10px; + } + + .stat-item { + text-align: center; + } + + .stat-value { + display: block; + font-size: 1.5em; + font-weight: bold; + color: #333; + } + + .stat-label { + font-size: 0.9em; + color: #666; + } + + .question-breakdown { + display: grid; + gap: 10px; + margin-bottom: 30px; + } + + .question-result { + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px; + border-radius: 8px; + background: #f8f9fa; + } + + .question-result.understood { + background: linear-gradient(135deg, #e8f5e8, #f1f8e9); + border-left: 4px solid #4caf50; + } + + .question-result.needs-work { + background: linear-gradient(135deg, #fff8e1, #fff3e0); + border-left: 4px solid #ff9800; + } + + .question-summary { + display: flex; + align-items: center; + gap: 15px; + } + + .question-num { + font-weight: bold; + color: #333; + } + + .focus-area { + font-size: 0.85em; + color: #666; + background: rgba(255,255,255,0.7); + padding: 4px 8px; + border-radius: 10px; + } + + .results-actions { + display: flex; + gap: 15px; + justify-content: center; + flex-wrap: wrap; + } + + .btn { + padding: 12px 24px; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 1em; + font-weight: 500; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + gap: 8px; + text-decoration: none; + } + + .btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .btn-primary { + background: linear-gradient(135deg, #fd79a8, #e84393); + color: white; + } + + .btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(253, 121, 168, 0.3); + } + + .btn-secondary { + background: #6c757d; + color: white; + } + + .btn-secondary:hover:not(:disabled) { + background: #5a6268; + } + + .btn-outline { + background: transparent; + border: 2px solid #fd79a8; + color: #fd79a8; + } + + .btn-outline:hover:not(:disabled) { + background: #fd79a8; + color: white; + } + + .btn-success { + background: linear-gradient(135deg, #00b894, #00cec9); + color: white; + } + + .btn-success:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(0, 184, 148, 0.3); + } + + .btn-sm { + padding: 8px 16px; + font-size: 0.9em; + } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + + @media (max-width: 768px) { + .image-exercise { + padding: 15px; + } + + .image-viewer-card, .question-card, .explanation-panel { + padding: 20px; + } + + .exercise-image { + max-height: 300px; + } + + .question-text { + font-size: 1.2em; + } + + .comprehension-rate { + font-size: 2.5em; + } + + .panel-actions, .results-actions { + flex-direction: column; + } + + .viewer-header { + flex-direction: column; + gap: 15px; + align-items: flex-start; + } + + .observation-stats { + flex-direction: column; + gap: 20px; + } + + .question-controls > div:first-child { + flex-direction: column; + align-items: center; + } + + .viewing-actions { + flex-direction: column; + } + } + `; + + document.head.appendChild(styles); + } +} + +export default ImageModule; \ No newline at end of file diff --git a/src/DRS/exercise-modules/PhraseModule.js b/src/DRS/exercise-modules/PhraseModule.js new file mode 100644 index 0000000..a635070 --- /dev/null +++ b/src/DRS/exercise-modules/PhraseModule.js @@ -0,0 +1,915 @@ +/** + * PhraseModule - Individual phrase comprehension with mandatory AI validation + * Uses GPT-4-mini only, no fallbacks, structured response format + */ + +import ExerciseModuleInterface from '../interfaces/ExerciseModuleInterface.js'; + +class PhraseModule extends ExerciseModuleInterface { + constructor(orchestrator, llmValidator, prerequisiteEngine, contextMemory) { + super(); + + // Validate dependencies + if (!orchestrator || !llmValidator || !prerequisiteEngine || !contextMemory) { + throw new Error('PhraseModule requires all service dependencies'); + } + + this.orchestrator = orchestrator; + this.llmValidator = llmValidator; + this.prerequisiteEngine = prerequisiteEngine; + this.contextMemory = contextMemory; + + // Module state + this.initialized = false; + this.container = null; + this.currentExerciseData = null; + this.currentPhrase = null; + this.validationInProgress = false; + this.lastValidationResult = null; + + // Configuration - AI ONLY, no fallbacks + this.config = { + requiredProvider: 'openai', // GPT-4-mini only + model: 'gpt-4o-mini', + temperature: 0.1, // Very low for consistent evaluation + maxTokens: 500, + timeout: 30000, + noFallback: true // Critical: No mocks allowed + }; + + // Languages configuration + this.languages = { + userLanguage: 'English', // User's native language + targetLanguage: 'French' // Target learning language + }; + + // Bind methods + this._handleUserInput = this._handleUserInput.bind(this); + this._handleRetry = this._handleRetry.bind(this); + this._handleNextPhrase = this._handleNextPhrase.bind(this); + } + + async init() { + if (this.initialized) return; + + console.log('💬 Initializing PhraseModule...'); + + // Test AI connectivity - recommandé mais pas obligatoire + try { + const testResult = await this.llmValidator.testConnectivity(); + if (testResult.success) { + console.log(`✅ AI connectivity verified (providers: ${testResult.availableProviders?.join(', ') || testResult.provider})`); + this.aiAvailable = true; + } else { + console.warn('⚠️ AI connection failed - will use mock validation:', testResult.error); + this.aiAvailable = false; + } + } catch (error) { + console.warn('⚠️ AI connectivity test failed - will use mock validation:', error.message); + this.aiAvailable = false; + } + + this.initialized = true; + console.log(`✅ PhraseModule initialized (AI: ${this.aiAvailable ? 'available' : 'disabled - using mock mode'})`); + } + + /** + * Check if module can run with current prerequisites + * @param {Array} prerequisites - List of learned vocabulary/concepts + * @param {Object} chapterContent - Full chapter content + * @returns {boolean} - True if module can run + */ + canRun(prerequisites, chapterContent) { + // Check if there are phrases and if prerequisites allow them + const phrases = chapterContent?.phrases || []; + if (phrases.length === 0) return false; + + // Find phrases that can be unlocked with current prerequisites + const availablePhrases = phrases.filter(phrase => { + const unlockStatus = this.prerequisiteEngine.canUnlock('phrase', phrase); + return unlockStatus.canUnlock; + }); + + return availablePhrases.length > 0; + } + + /** + * Present exercise UI and content + * @param {HTMLElement} container - DOM container to render into + * @param {Object} exerciseData - Specific exercise data to present + * @returns {Promise} + */ + async present(container, exerciseData) { + if (!this.initialized) { + throw new Error('PhraseModule must be initialized before use'); + } + + this.container = container; + this.currentExerciseData = exerciseData; + this.currentPhrase = exerciseData.phrase; + this.validationInProgress = false; + this.lastValidationResult = null; + + // Detect languages from chapter content + this._detectLanguages(exerciseData); + + console.log(`💬 Presenting phrase exercise: "${this.currentPhrase?.english || this.currentPhrase?.text}"`); + + // Render initial UI + await this._renderPhraseExercise(); + } + + /** + * Validate user input with mandatory AI (GPT-4-mini) + * @param {string} userInput - User's response + * @param {Object} context - Exercise context + * @returns {Promise} - Structured validation result + */ + async validate(userInput, context) { + if (!userInput || !userInput.trim()) { + throw new Error('Please provide an answer'); + } + + if (!this.currentPhrase) { + throw new Error('No phrase loaded for validation'); + } + + console.log(`🧠 AI validation: "${this.currentPhrase.english}" -> "${userInput}"`); + + // Build structured prompt for GPT-4-mini + const prompt = this._buildStructuredPrompt(userInput); + + try { + // Direct call to IAEngine with strict parameters + const aiResponse = await this.llmValidator.iaEngine.validateEducationalContent(prompt, { + preferredProvider: this.config.requiredProvider, + temperature: this.config.temperature, + maxTokens: this.config.maxTokens, + timeout: this.config.timeout, + systemPrompt: `You are a language learning evaluator. ALWAYS respond in the exact format: [answer]yes/no [explanation]your explanation here` + }); + + // Parse structured response + const parsedResult = this._parseStructuredResponse(aiResponse); + + // Record interaction in context memory + this.contextMemory.recordInteraction({ + type: 'phrase', + subtype: 'comprehension', + content: { + phrase: this.currentPhrase, + originalText: this.currentPhrase.english || this.currentPhrase.text, + targetLanguage: this.languages.targetLanguage + }, + userResponse: userInput.trim(), + validation: parsedResult, + context: { languages: this.languages } + }); + + return parsedResult; + + } catch (error) { + console.error('❌ AI validation failed:', error); + + // No fallback allowed - throw error to user + throw new Error(`AI validation failed: ${error.message}. Please check connection and retry.`); + } + } + + /** + * Get current progress data + * @returns {ProgressData} - Progress information for this module + */ + getProgress() { + return { + type: 'phrase', + currentPhrase: this.currentPhrase?.english || 'None', + validationStatus: this.validationInProgress ? 'validating' : 'ready', + lastResult: this.lastValidationResult, + aiProvider: this.config.requiredProvider, + languages: this.languages + }; + } + + /** + * Clean up and prepare for unloading + */ + cleanup() { + console.log('🧹 Cleaning up PhraseModule...'); + + // Remove event listeners + if (this.container) { + this.container.innerHTML = ''; + } + + // Reset state + this.container = null; + this.currentExerciseData = null; + this.currentPhrase = null; + this.validationInProgress = false; + this.lastValidationResult = null; + + console.log('✅ PhraseModule cleaned up'); + } + + // Private Methods + + /** + * Detect languages from exercise data + * @private + */ + _detectLanguages(exerciseData) { + // Try to detect from chapter content or use defaults + const chapterContent = this.currentExerciseData?.chapterContent; + + if (chapterContent?.metadata?.userLanguage) { + this.languages.userLanguage = chapterContent.metadata.userLanguage; + } + + if (chapterContent?.metadata?.targetLanguage) { + this.languages.targetLanguage = chapterContent.metadata.targetLanguage; + } + + // Fallback detection from phrase content + if (this.currentPhrase?.user_language) { + this.languages.targetLanguage = this.currentPhrase.user_language; + } + + console.log(`🌍 Languages detected: ${this.languages.userLanguage} -> ${this.languages.targetLanguage}`); + } + + /** + * Build structured prompt for GPT-4-mini + * @private + */ + _buildStructuredPrompt(userAnswer) { + const originalText = this.currentPhrase.english || this.currentPhrase.text || ''; + const expectedTranslation = this.currentPhrase.user_language || this.currentPhrase.translation || ''; + + return `You are evaluating a ${this.languages.userLanguage}/${this.languages.targetLanguage} phrase comprehension exercise. + +CRITICAL: You MUST respond in this EXACT format: [answer]yes/no [explanation]your explanation here + +Evaluate this student response: +- Original phrase (${this.languages.userLanguage}): "${originalText}" +- Expected meaning (${this.languages.targetLanguage}): "${expectedTranslation}" +- Student answer: "${userAnswer}" +- Context: Individual phrase comprehension exercise + +Rules: +- [answer]yes if the student captured the essential meaning (even if not word-perfect) +- [answer]no if the meaning is wrong, missing, or completely off-topic +- [explanation] must be encouraging, educational, and constructive +- Focus on comprehension, not perfect translation + +Format: [answer]yes/no [explanation]your detailed feedback here`; + } + + /** + * Parse structured AI response + * @private + */ + _parseStructuredResponse(aiResponse) { + try { + let responseText = ''; + + // Extract text from AI response + if (typeof aiResponse === 'string') { + responseText = aiResponse; + } else if (aiResponse.content) { + responseText = aiResponse.content; + } else if (aiResponse.text) { + responseText = aiResponse.text; + } else { + responseText = JSON.stringify(aiResponse); + } + + console.log('🔍 Parsing AI response:', responseText.substring(0, 200) + '...'); + + // Extract [answer] - case insensitive + const answerMatch = responseText.match(/\[answer\](yes|no)/i); + if (!answerMatch) { + throw new Error('AI response missing [answer] format'); + } + + // Extract [explanation] - multiline support + const explanationMatch = responseText.match(/\[explanation\](.+)/s); + if (!explanationMatch) { + throw new Error('AI response missing [explanation] format'); + } + + const isCorrect = answerMatch[1].toLowerCase() === 'yes'; + const explanation = explanationMatch[1].trim(); + + const result = { + score: isCorrect ? 85 : 45, // High score for yes, low for no + correct: isCorrect, + feedback: explanation, + answer: answerMatch[1].toLowerCase(), + explanation: explanation, + timestamp: new Date().toISOString(), + provider: this.config.requiredProvider, + model: this.config.model, + cached: false, + formatValid: true + }; + + console.log(`✅ AI response parsed: ${result.answer} - "${result.explanation.substring(0, 50)}..."`); + return result; + + } catch (error) { + console.error('❌ Failed to parse AI response:', error); + console.error('Raw response:', aiResponse); + + throw new Error(`AI response format invalid: ${error.message}`); + } + } + + /** + * Render the phrase exercise interface + * @private + */ + async _renderPhraseExercise() { + if (!this.container || !this.currentPhrase) return; + + const originalText = this.currentPhrase.english || this.currentPhrase.text || 'No phrase text'; + const pronunciation = this.currentPhrase.pronunciation || ''; + + this.container.innerHTML = ` +
+
+

💬 Phrase Comprehension

+
+ ${this.languages.userLanguage} + + ${this.languages.targetLanguage} +
+
+ +
+
+
+
"${originalText}"
+ ${pronunciation ? `
[${pronunciation}]
` : ''} + ${!this.aiAvailable ? ` +
+ ⚠️ AI validation unavailable - using mock mode +
+ ` : ''} +
+ +
+ + +
+ +
+ +
+
+
+ + +
+
+ `; + + // Add CSS styles + this._addStyles(); + + // Add event listeners + this._setupEventListeners(); + } + + /** + * Setup event listeners + * @private + */ + _setupEventListeners() { + const input = document.getElementById('comprehension-input'); + const validateBtn = document.getElementById('validate-btn'); + const retryBtn = document.getElementById('retry-btn'); + const nextBtn = document.getElementById('next-phrase-btn'); + + // Enable validate button when input has text + if (input) { + input.addEventListener('input', () => { + const hasText = input.value.trim().length > 0; + if (validateBtn) { + validateBtn.disabled = !hasText || this.validationInProgress; + } + }); + + // Allow Enter to validate (with Shift+Enter for new line) + input.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && !e.shiftKey && !validateBtn.disabled) { + e.preventDefault(); + this._handleUserInput(); + } + }); + } + + // Validate button + if (validateBtn) { + validateBtn.onclick = this._handleUserInput; + } + + // Retry button + if (retryBtn) { + retryBtn.onclick = this._handleRetry; + } + + // Next button + if (nextBtn) { + nextBtn.onclick = this._handleNextPhrase; + } + } + + /** + * Handle user input validation + * @private + */ + async _handleUserInput() { + const input = document.getElementById('comprehension-input'); + const validateBtn = document.getElementById('validate-btn'); + const statusDiv = document.getElementById('validation-status'); + + if (!input || !validateBtn || !statusDiv) return; + + const userInput = input.value.trim(); + if (!userInput) return; + + try { + // Set validation in progress + this.validationInProgress = true; + validateBtn.disabled = true; + input.disabled = true; + + // Show loading status + statusDiv.innerHTML = ` +
+
🧠
+ AI is evaluating your answer... +
+ `; + + // Call AI validation + const result = await this.validate(userInput, {}); + this.lastValidationResult = result; + + // Show result in explanation panel + this._showExplanation(result); + + // Update status + statusDiv.innerHTML = ` +
+ ${result.correct ? '✅' : '❌'} + AI evaluation complete +
+ `; + + } catch (error) { + console.error('❌ Validation error:', error); + + // Show error status + statusDiv.innerHTML = ` +
+ ⚠️ + Error: ${error.message} +
+ `; + + // Re-enable input for retry + this._enableRetry(); + } + } + + /** + * Show AI explanation in dedicated panel + * @private + */ + _showExplanation(result) { + const explanationPanel = document.getElementById('explanation-panel'); + const explanationContent = document.getElementById('explanation-content'); + const nextBtn = document.getElementById('next-phrase-btn'); + const retryBtn = document.getElementById('retry-btn'); + + if (!explanationPanel || !explanationContent) return; + + // Show panel + explanationPanel.style.display = 'block'; + + // Set explanation content (read-only) + explanationContent.innerHTML = ` +
+
+ ${result.correct ? '✅ Correct!' : '❌ Not quite right'} + Score: ${result.score}/100 +
+
${result.explanation}
+
+ `; + + // Show appropriate buttons + if (nextBtn) nextBtn.style.display = result.correct ? 'inline-block' : 'none'; + if (retryBtn) retryBtn.style.display = result.correct ? 'none' : 'inline-block'; + + // Scroll to explanation + explanationPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + + /** + * Enable retry after error + * @private + */ + _enableRetry() { + this.validationInProgress = false; + + const input = document.getElementById('comprehension-input'); + const validateBtn = document.getElementById('validate-btn'); + + if (input) { + input.disabled = false; + input.focus(); + } + + if (validateBtn) { + validateBtn.disabled = false; + } + } + + /** + * Handle retry button + * @private + */ + _handleRetry() { + // Hide explanation panel and enable new input + const explanationPanel = document.getElementById('explanation-panel'); + const statusDiv = document.getElementById('validation-status'); + + if (explanationPanel) explanationPanel.style.display = 'none'; + if (statusDiv) statusDiv.innerHTML = ''; + + this._enableRetry(); + } + + /** + * Handle next phrase button + * @private + */ + _handleNextPhrase() { + // Mark phrase as completed and continue to next exercise + if (this.currentPhrase && this.lastValidationResult) { + const phraseId = this.currentPhrase.id || this.currentPhrase.english || 'unknown'; + const metadata = { + difficulty: this.lastValidationResult.correct ? 'easy' : 'hard', + sessionId: this.orchestrator?.sessionId || 'unknown', + moduleType: 'phrase', + aiScore: this.lastValidationResult.score, + correct: this.lastValidationResult.correct, + provider: this.lastValidationResult.provider || 'openai' + }; + + this.prerequisiteEngine.markPhraseMastered(phraseId, metadata); + + // Also save to persistent storage if phrase was correctly understood + if (this.lastValidationResult.correct && window.addMasteredItem && + this.orchestrator?.bookId && this.orchestrator?.chapterId) { + window.addMasteredItem( + this.orchestrator.bookId, + this.orchestrator.chapterId, + 'phrases', + phraseId, + metadata + ); + } + } + + // Emit completion event to orchestrator + this.orchestrator._eventBus.emit('drs:exerciseCompleted', { + moduleType: 'phrase', + result: this.lastValidationResult, + progress: this.getProgress() + }, 'PhraseModule'); + } + + /** + * Add CSS styles for phrase exercise + * @private + */ + _addStyles() { + if (document.getElementById('phrase-module-styles')) return; + + const styles = document.createElement('style'); + styles.id = 'phrase-module-styles'; + styles.textContent = ` + .phrase-exercise { + max-width: 800px; + margin: 0 auto; + padding: 20px; + display: grid; + gap: 20px; + } + + .exercise-header { + text-align: center; + margin-bottom: 20px; + } + + .language-info { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-top: 10px; + font-size: 0.9em; + color: #666; + } + + .phrase-content { + display: grid; + gap: 20px; + } + + .phrase-card { + background: white; + border-radius: 12px; + padding: 30px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + } + + .phrase-display { + text-align: center; + margin-bottom: 30px; + padding: 20px; + background: linear-gradient(135deg, #f8f9ff, #e8f4fd); + border-radius: 8px; + } + + .phrase-text { + font-size: 1.8em; + font-weight: 600; + color: #2c3e50; + margin-bottom: 10px; + line-height: 1.3; + } + + .phrase-pronunciation { + font-style: italic; + color: #666; + font-size: 1.1em; + } + + .comprehension-input { + margin-bottom: 20px; + } + + .comprehension-input label { + display: block; + margin-bottom: 10px; + font-weight: 600; + color: #555; + } + + .comprehension-input textarea { + width: 100%; + padding: 15px; + font-size: 1.1em; + border: 2px solid #ddd; + border-radius: 8px; + resize: vertical; + min-height: 80px; + box-sizing: border-box; + transition: border-color 0.3s ease; + } + + .comprehension-input textarea:focus { + outline: none; + border-color: #667eea; + } + + .phrase-controls { + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; + } + + .validation-status { + min-height: 30px; + display: flex; + align-items: center; + justify-content: center; + } + + .status-loading, .status-complete, .status-error { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + border-radius: 20px; + font-weight: 500; + } + + .status-loading { + background: #e3f2fd; + color: #1976d2; + } + + .status-complete { + background: #e8f5e8; + color: #2e7d32; + } + + .status-error { + background: #ffebee; + color: #c62828; + } + + .loading-spinner { + animation: spin 1s linear infinite; + } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + + .explanation-panel { + background: white; + border-radius: 12px; + padding: 25px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + border-left: 4px solid #667eea; + } + + .panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid #eee; + } + + .panel-header h3 { + margin: 0; + color: #333; + } + + .ai-model { + font-size: 0.9em; + color: #666; + background: #f5f5f5; + padding: 4px 8px; + border-radius: 4px; + } + + .explanation-content { + margin-bottom: 20px; + } + + .explanation-result.correct { + border-left: 4px solid #4caf50; + background: linear-gradient(135deg, #f1f8e9, #e8f5e8); + } + + .explanation-result.incorrect { + border-left: 4px solid #f44336; + background: linear-gradient(135deg, #fff3e0, #ffebee); + } + + .explanation-result { + padding: 20px; + border-radius: 8px; + margin-bottom: 15px; + } + + .result-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + font-weight: 600; + } + + .result-indicator { + font-size: 1.1em; + } + + .ai-confidence { + font-size: 0.9em; + color: #666; + } + + .explanation-text { + line-height: 1.6; + color: #333; + font-size: 1.05em; + } + + .panel-actions { + display: flex; + gap: 15px; + justify-content: center; + } + + .btn { + padding: 12px 24px; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 1em; + font-weight: 500; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + gap: 8px; + } + + .btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .btn-primary { + background: linear-gradient(135deg, #667eea, #764ba2); + color: white; + } + + .btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); + } + + .btn-secondary { + background: #6c757d; + color: white; + } + + .btn-secondary:hover:not(:disabled) { + background: #5a6268; + } + + .ai-status-warning { + background: linear-gradient(135deg, #fff3cd, #ffeaa7); + border: 1px solid #ffc107; + border-radius: 8px; + padding: 10px 15px; + margin: 10px 0; + text-align: center; + font-size: 0.9em; + color: #856404; + font-weight: 500; + } + + @media (max-width: 768px) { + .phrase-exercise { + padding: 15px; + } + + .phrase-card, .explanation-panel { + padding: 20px; + } + + .phrase-text { + font-size: 1.5em; + } + + .panel-actions { + flex-direction: column; + } + } + `; + + document.head.appendChild(styles); + } +} + +export default PhraseModule; \ No newline at end of file diff --git a/src/DRS/exercise-modules/TextModule.js b/src/DRS/exercise-modules/TextModule.js new file mode 100644 index 0000000..398d4d9 --- /dev/null +++ b/src/DRS/exercise-modules/TextModule.js @@ -0,0 +1,1510 @@ +/** + * TextModule - Reading comprehension exercises with AI validation + * Handles text passages with comprehension questions and contextual understanding + */ + +import ExerciseModuleInterface from '../interfaces/ExerciseModuleInterface.js'; + +class TextModule extends ExerciseModuleInterface { + constructor(orchestrator, llmValidator, prerequisiteEngine, contextMemory) { + super(); + + // Validate dependencies + if (!orchestrator || !llmValidator || !prerequisiteEngine || !contextMemory) { + throw new Error('TextModule requires all service dependencies'); + } + + this.orchestrator = orchestrator; + this.llmValidator = llmValidator; + this.prerequisiteEngine = prerequisiteEngine; + this.contextMemory = contextMemory; + + // Module state + this.initialized = false; + this.container = null; + this.currentExerciseData = null; + this.currentText = null; + this.currentQuestion = null; + this.questionIndex = 0; + this.questionResults = []; + this.validationInProgress = false; + this.lastValidationResult = null; + this.aiAvailable = false; + + // Configuration + this.config = { + requiredProvider: 'openai', // Prefer OpenAI for text analysis + model: 'gpt-4o-mini', + temperature: 0.2, // Slightly more creative for text comprehension + maxTokens: 800, + timeout: 45000, // Longer timeout for complex text analysis + questionsPerText: 3, // Default number of questions per text + showTextDuringQuestions: true, // Keep text visible during questions + allowReread: true // Allow re-reading the text + }; + + // Languages configuration + this.languages = { + userLanguage: 'English', + targetLanguage: 'French' + }; + + // Bind methods + this._handleUserInput = this._handleUserInput.bind(this); + this._handleNextQuestion = this._handleNextQuestion.bind(this); + this._handleRetry = this._handleRetry.bind(this); + this._handleRereadText = this._handleRereadText.bind(this); + } + + async init() { + if (this.initialized) return; + + console.log('📖 Initializing TextModule...'); + + // Test AI connectivity - recommended for text comprehension + try { + const testResult = await this.llmValidator.testConnectivity(); + if (testResult.success) { + console.log(`✅ AI connectivity verified for text analysis (providers: ${testResult.availableProviders?.join(', ') || testResult.provider})`); + this.aiAvailable = true; + } else { + console.warn('⚠️ AI connection failed - text comprehension will be limited:', testResult.error); + this.aiAvailable = false; + } + } catch (error) { + console.warn('⚠️ AI connectivity test failed - using basic text analysis:', error.message); + this.aiAvailable = false; + } + + this.initialized = true; + console.log(`✅ TextModule initialized (AI: ${this.aiAvailable ? 'available for deep analysis' : 'limited - basic analysis only'})`); + } + + /** + * Check if module can run with current prerequisites + * @param {Array} prerequisites - List of learned vocabulary/concepts + * @param {Object} chapterContent - Full chapter content + * @returns {boolean} - True if module can run + */ + canRun(prerequisites, chapterContent) { + // Check if there are texts and if prerequisites allow them + const texts = chapterContent?.texts || []; + if (texts.length === 0) return false; + + // Find texts that can be unlocked with current prerequisites + const availableTexts = texts.filter(text => { + const unlockStatus = this.prerequisiteEngine.canUnlock('text', text); + return unlockStatus.canUnlock; + }); + + return availableTexts.length > 0; + } + + /** + * Present exercise UI and content + * @param {HTMLElement} container - DOM container to render into + * @param {Object} exerciseData - Specific exercise data to present + * @returns {Promise} + */ + async present(container, exerciseData) { + if (!this.initialized) { + throw new Error('TextModule must be initialized before use'); + } + + this.container = container; + this.currentExerciseData = exerciseData; + this.currentText = exerciseData.text; + this.questionIndex = 0; + this.questionResults = []; + this.validationInProgress = false; + this.lastValidationResult = null; + + // Detect languages from chapter content + this._detectLanguages(exerciseData); + + // Generate or extract questions + this.questions = await this._prepareQuestions(this.currentText); + + console.log(`📖 Presenting text comprehension: "${this.currentText.title || 'Reading Exercise'}" (${this.questions.length} questions)`); + + // Render initial UI + await this._renderTextExercise(); + + // Start with text reading phase + this._showTextReading(); + } + + /** + * Validate user input with AI for deep text comprehension + * @param {string} userInput - User's response + * @param {Object} context - Exercise context + * @returns {Promise} - Validation result with score and feedback + */ + async validate(userInput, context) { + if (!userInput || !userInput.trim()) { + throw new Error('Please provide an answer'); + } + + if (!this.currentText || !this.currentQuestion) { + throw new Error('No text or question loaded for validation'); + } + + console.log(`📖 Validating text comprehension answer for question ${this.questionIndex + 1}`); + + // Build comprehensive prompt for text comprehension + const prompt = this._buildTextComprehensionPrompt(userInput); + + try { + // Use AI validation with structured response + const aiResponse = await this.llmValidator.iaEngine.validateEducationalContent(prompt, { + preferredProvider: this.config.requiredProvider, + temperature: this.config.temperature, + maxTokens: this.config.maxTokens, + timeout: this.config.timeout, + systemPrompt: `You are an expert reading comprehension evaluator. Focus on understanding and critical thinking, not just literal recall. ALWAYS respond in the exact format: [answer]yes/no [explanation]your detailed analysis here` + }); + + // Parse structured response + const parsedResult = this._parseStructuredResponse(aiResponse); + + // Record interaction in context memory + this.contextMemory.recordInteraction({ + type: 'text', + subtype: 'comprehension', + content: { + text: this.currentText, + question: this.currentQuestion, + textTitle: this.currentText.title || 'Reading Exercise', + textLength: this.currentText.content?.length || 0 + }, + userResponse: userInput.trim(), + validation: parsedResult, + context: { + languages: this.languages, + questionIndex: this.questionIndex, + totalQuestions: this.questions.length + } + }); + + return parsedResult; + + } catch (error) { + console.error('❌ AI text comprehension validation failed:', error); + + // Fallback to basic keyword analysis if AI fails + if (!this.aiAvailable) { + return this._performBasicTextValidation(userInput); + } + + throw new Error(`Text comprehension validation failed: ${error.message}. Please check your answer and try again.`); + } + } + + /** + * Get current progress data + * @returns {ProgressData} - Progress information for this module + */ + getProgress() { + const totalQuestions = this.questions ? this.questions.length : 0; + const completedQuestions = this.questionResults.length; + const correctAnswers = this.questionResults.filter(result => result.correct).length; + + return { + type: 'text', + textTitle: this.currentText?.title || 'Reading Exercise', + totalQuestions, + completedQuestions, + correctAnswers, + currentQuestionIndex: this.questionIndex, + questionResults: this.questionResults, + progressPercentage: totalQuestions > 0 ? Math.round((completedQuestions / totalQuestions) * 100) : 0, + comprehensionRate: completedQuestions > 0 ? Math.round((correctAnswers / completedQuestions) * 100) : 0, + aiAnalysisAvailable: this.aiAvailable + }; + } + + /** + * Clean up and prepare for unloading + */ + cleanup() { + console.log('🧹 Cleaning up TextModule...'); + + // Remove event listeners + if (this.container) { + this.container.innerHTML = ''; + } + + // Reset state + this.container = null; + this.currentExerciseData = null; + this.currentText = null; + this.currentQuestion = null; + this.questionIndex = 0; + this.questionResults = []; + this.questions = null; + this.validationInProgress = false; + this.lastValidationResult = null; + + console.log('✅ TextModule cleaned up'); + } + + /** + * Get module metadata + * @returns {Object} - Module information + */ + getMetadata() { + return { + name: 'TextModule', + type: 'text', + version: '1.0.0', + description: 'Reading comprehension exercises with AI-powered text analysis', + capabilities: ['text_comprehension', 'critical_thinking', 'contextual_analysis', 'ai_feedback'], + aiRequired: false, // Can work without AI but limited + config: this.config + }; + } + + // Private Methods + + /** + * Detect languages from exercise data + * @private + */ + _detectLanguages(exerciseData) { + const chapterContent = this.currentExerciseData?.chapterContent; + + if (chapterContent?.metadata?.userLanguage) { + this.languages.userLanguage = chapterContent.metadata.userLanguage; + } + + if (chapterContent?.metadata?.targetLanguage) { + this.languages.targetLanguage = chapterContent.metadata.targetLanguage; + } + + console.log(`🌍 Text languages detected: ${this.languages.userLanguage} -> ${this.languages.targetLanguage}`); + } + + /** + * Prepare questions for the text + * @private + */ + async _prepareQuestions(text) { + // If text already has questions, use them + if (text.questions && text.questions.length > 0) { + return text.questions.map((q, index) => ({ + id: `q${index + 1}`, + question: q.question || q.text || q, + type: q.type || 'open', + expectedAnswer: q.answer || q.expectedAnswer, + keywords: q.keywords || [], + difficulty: q.difficulty || 'medium' + })); + } + + // Generate default comprehension questions + const defaultQuestions = [ + { + id: 'main_idea', + question: `What is the main idea of this text?`, + type: 'open', + keywords: ['main', 'central', 'primary', 'key'], + difficulty: 'medium' + }, + { + id: 'details', + question: `What are the key details mentioned in the text?`, + type: 'open', + keywords: ['details', 'specific', 'mentioned'], + difficulty: 'easy' + }, + { + id: 'analysis', + question: `What can you infer or conclude from this text?`, + type: 'open', + keywords: ['infer', 'conclude', 'imply', 'suggest'], + difficulty: 'hard' + } + ]; + + // Limit to configured number of questions + return defaultQuestions.slice(0, this.config.questionsPerText); + } + + /** + * Build comprehensive prompt for text comprehension validation + * @private + */ + _buildTextComprehensionPrompt(userAnswer) { + const textContent = this.currentText.content || this.currentText.text || ''; + const textTitle = this.currentText.title || 'Reading Text'; + + return `You are evaluating reading comprehension for a language learning exercise. + +CRITICAL: You MUST respond in this EXACT format: [answer]yes/no [explanation]your detailed analysis here + +TEXT PASSAGE: +Title: "${textTitle}" +Content: "${textContent}" + +QUESTION: ${this.currentQuestion.question} + +STUDENT RESPONSE: "${userAnswer}" + +EVALUATION CONTEXT: +- Exercise Type: Reading comprehension +- Languages: ${this.languages.userLanguage} -> ${this.languages.targetLanguage} +- Question Type: ${this.currentQuestion.type} +- Question Difficulty: ${this.currentQuestion.difficulty} +- Question ${this.questionIndex + 1} of ${this.questions.length} + +EVALUATION CRITERIA: +- [answer]yes if the student demonstrates understanding of the text in relation to the question +- [answer]no if the response shows lack of comprehension or is unrelated +- Focus on COMPREHENSION and UNDERSTANDING, not perfect language +- Accept paraphrasing and different perspectives if they show understanding +- Consider cultural context and language learning level + +[explanation] should provide: +1. What the student understood correctly +2. What they might have missed or misunderstood +3. Encouragement and specific improvement suggestions +4. Connection to the broader text meaning + +Format: [answer]yes/no [explanation]your comprehensive educational feedback here`; + } + + /** + * Parse structured AI response for text comprehension + * @private + */ + _parseStructuredResponse(aiResponse) { + try { + let responseText = ''; + + // Extract text from AI response + if (typeof aiResponse === 'string') { + responseText = aiResponse; + } else if (aiResponse.content) { + responseText = aiResponse.content; + } else if (aiResponse.text) { + responseText = aiResponse.text; + } else { + responseText = JSON.stringify(aiResponse); + } + + console.log('🔍 Parsing AI text comprehension response:', responseText.substring(0, 150) + '...'); + + // Extract [answer] - case insensitive + const answerMatch = responseText.match(/\[answer\](yes|no)/i); + if (!answerMatch) { + throw new Error('AI response missing [answer] format'); + } + + // Extract [explanation] - multiline support + const explanationMatch = responseText.match(/\[explanation\](.+)/s); + if (!explanationMatch) { + throw new Error('AI response missing [explanation] format'); + } + + const isCorrect = answerMatch[1].toLowerCase() === 'yes'; + const explanation = explanationMatch[1].trim(); + + // Higher scores for text comprehension to encourage reading + const result = { + score: isCorrect ? 90 : 60, // More generous scoring for comprehension + correct: isCorrect, + feedback: explanation, + answer: answerMatch[1].toLowerCase(), + explanation: explanation, + timestamp: new Date().toISOString(), + provider: this.config.requiredProvider, + model: this.config.model, + cached: false, + formatValid: true, + textComprehension: true + }; + + console.log(`✅ AI text comprehension parsed: ${result.answer} - Score: ${result.score}`); + return result; + + } catch (error) { + console.error('❌ Failed to parse AI text comprehension response:', error); + console.error('Raw response:', aiResponse); + + throw new Error(`AI response format invalid: ${error.message}`); + } + } + + /** + * Perform basic text validation when AI is unavailable + * @private + */ + _performBasicTextValidation(userAnswer) { + console.log('🔍 Performing basic text validation (AI unavailable)'); + + const answerLength = userAnswer.trim().length; + const hasKeywords = this.currentQuestion.keywords?.some(keyword => + userAnswer.toLowerCase().includes(keyword.toLowerCase()) + ); + + // Basic scoring based on answer length and keyword presence + let score = 40; // Base score + + if (answerLength > 20) score += 20; // Substantial answer + if (answerLength > 50) score += 10; // Detailed answer + if (hasKeywords) score += 20; // Contains relevant keywords + if (answerLength > 100) score += 10; // Very detailed + + const isCorrect = score >= 70; + + return { + score: Math.min(score, 100), + correct: isCorrect, + feedback: isCorrect + ? "Good comprehension demonstrated! Your answer shows understanding of the text." + : "Your answer could be more detailed. Try to include more specific information from the text.", + timestamp: new Date().toISOString(), + provider: 'basic_text_analysis', + model: 'keyword_length_analysis', + cached: false, + mockGenerated: true, + textComprehension: true + }; + } + + /** + * Render the text exercise interface + * @private + */ + async _renderTextExercise() { + if (!this.container || !this.currentText) return; + + const textTitle = this.currentText.title || 'Reading Exercise'; + const textContent = this.currentText.content || this.currentText.text || ''; + const wordCount = textContent.split(/\s+/).length; + + this.container.innerHTML = ` +
+
+

📖 Reading Comprehension

+
+ + ${this.questions?.length || 0} questions • ~${wordCount} words + ${!this.aiAvailable ? ' • ⚠️ Limited analysis mode' : ' • 🧠 AI analysis'} + +
+
+ +
+
+
+
+

${textTitle}

+ +
+
+ ${this._formatTextContent(textContent)} +
+
+ +
+
+
+ + + + + + +
+
+ `; + + // Add CSS styles + this._addStyles(); + + // Add event listeners + this._setupEventListeners(); + } + + /** + * Format text content with paragraphs and line breaks + * @private + */ + _formatTextContent(content) { + if (!content) return ''; + + // Split into paragraphs and format + return content + .split('\n\n') + .map(paragraph => `

${paragraph.replace(/\n/g, '
')}

`) + .join(''); + } + + /** + * Setup event listeners for text exercise + * @private + */ + _setupEventListeners() { + const startQuestionsBtn = document.getElementById('start-questions-btn'); + const rereadBtn = document.getElementById('reread-btn'); + const answerInput = document.getElementById('answer-input'); + const validateBtn = document.getElementById('validate-answer-btn'); + const retryBtn = document.getElementById('retry-answer-btn'); + const nextBtn = document.getElementById('next-question-btn'); + const finishBtn = document.getElementById('finish-text-btn'); + + // Start questions button + if (startQuestionsBtn) { + startQuestionsBtn.onclick = () => this._startQuestions(); + } + + // Re-read text button + if (rereadBtn) { + rereadBtn.onclick = this._handleRereadText; + } + + // Answer input validation + if (answerInput) { + answerInput.addEventListener('input', () => { + const hasText = answerInput.value.trim().length > 0; + if (validateBtn) { + validateBtn.disabled = !hasText || this.validationInProgress; + } + }); + + // Allow Ctrl+Enter to validate + answerInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && !validateBtn.disabled) { + e.preventDefault(); + this._handleUserInput(); + } + }); + } + + // Validate button + if (validateBtn) { + validateBtn.onclick = this._handleUserInput; + } + + // Action buttons + if (retryBtn) retryBtn.onclick = this._handleRetry; + if (nextBtn) nextBtn.onclick = this._handleNextQuestion; + if (finishBtn) finishBtn.onclick = () => this._completeTextExercise(); + } + + /** + * Show text reading phase + * @private + */ + _showTextReading() { + const textSection = document.getElementById('text-passage-section'); + const questionsSection = document.getElementById('questions-section'); + + if (textSection) textSection.style.display = 'block'; + if (questionsSection) questionsSection.style.display = 'none'; + } + + /** + * Start questions phase + * @private + */ + _startQuestions() { + const textSection = document.getElementById('text-passage-section'); + const questionsSection = document.getElementById('questions-section'); + const rereadBtn = document.getElementById('reread-btn'); + + if (textSection) { + textSection.style.display = this.config.showTextDuringQuestions ? 'block' : 'none'; + } + if (questionsSection) questionsSection.style.display = 'block'; + if (rereadBtn) rereadBtn.style.display = 'inline-block'; + + this._presentCurrentQuestion(); + } + + /** + * Present current question + * @private + */ + _presentCurrentQuestion() { + if (this.questionIndex >= this.questions.length) { + this._showTextResults(); + return; + } + + this.currentQuestion = this.questions[this.questionIndex]; + const questionContent = document.getElementById('question-content'); + const questionCounter = document.getElementById('question-counter'); + const progressFill = document.getElementById('progress-fill'); + + if (!questionContent || !this.currentQuestion) return; + + // Update progress + const progressPercentage = ((this.questionIndex + 1) / this.questions.length) * 100; + if (progressFill) progressFill.style.width = `${progressPercentage}%`; + if (questionCounter) questionCounter.textContent = `Question ${this.questionIndex + 1} of ${this.questions.length}`; + + // Display question + questionContent.innerHTML = ` +
+
${this.currentQuestion.question}
+
+ ${this.currentQuestion.type} + + ${this.currentQuestion.difficulty} + +
+
+ `; + + // Clear previous answer and focus + const answerInput = document.getElementById('answer-input'); + if (answerInput) { + answerInput.value = ''; + answerInput.focus(); + } + + // Hide explanation panel + const explanationPanel = document.getElementById('explanation-panel'); + if (explanationPanel) explanationPanel.style.display = 'none'; + } + + /** + * Handle user input validation + * @private + */ + async _handleUserInput() { + const answerInput = document.getElementById('answer-input'); + const validateBtn = document.getElementById('validate-answer-btn'); + const statusDiv = document.getElementById('validation-status'); + + if (!answerInput || !validateBtn || !statusDiv) return; + + const userAnswer = answerInput.value.trim(); + if (!userAnswer) return; + + try { + // Set validation in progress + this.validationInProgress = true; + validateBtn.disabled = true; + answerInput.disabled = true; + + // Show loading status + statusDiv.innerHTML = ` +
+
🔍
+ ${this.aiAvailable ? 'AI is analyzing your comprehension...' : 'Analyzing your answer...'} +
+ `; + + // Call validation + const result = await this.validate(userAnswer, {}); + this.lastValidationResult = result; + + // Store result + this.questionResults[this.questionIndex] = { + question: this.currentQuestion.question, + userAnswer: userAnswer, + correct: result.correct, + score: result.score, + feedback: result.feedback, + timestamp: new Date().toISOString() + }; + + // Show result + this._showValidationResult(result); + + // Update status + statusDiv.innerHTML = ` +
+ ${result.correct ? '✅' : '📚'} + Analysis complete +
+ `; + + } catch (error) { + console.error('❌ Text validation error:', error); + + // Show error status + statusDiv.innerHTML = ` +
+ ⚠️ + Error: ${error.message} +
+ `; + + // Re-enable input for retry + this._enableRetry(); + } + } + + /** + * Show validation result in explanation panel + * @private + */ + _showValidationResult(result) { + const explanationPanel = document.getElementById('explanation-panel'); + const explanationContent = document.getElementById('explanation-content'); + const nextBtn = document.getElementById('next-question-btn'); + const retryBtn = document.getElementById('retry-answer-btn'); + const finishBtn = document.getElementById('finish-text-btn'); + + if (!explanationPanel || !explanationContent) return; + + // Show panel + explanationPanel.style.display = 'block'; + + // Set explanation content + explanationContent.innerHTML = ` +
+
+ + ${result.correct ? '✅ Good Comprehension!' : '📚 Keep Learning!'} + + Score: ${result.score}/100 +
+
${result.explanation || result.feedback}
+ ${result.textComprehension ? '
💡 This analysis focuses on your understanding of the text\'s meaning and context.
' : ''} +
+ `; + + // Show appropriate buttons + const isLastQuestion = this.questionIndex >= this.questions.length - 1; + + if (nextBtn) nextBtn.style.display = isLastQuestion ? 'none' : 'inline-block'; + if (finishBtn) finishBtn.style.display = isLastQuestion ? 'inline-block' : 'none'; + if (retryBtn) retryBtn.style.display = result.correct ? 'none' : 'inline-block'; + + // Scroll to explanation + explanationPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + + /** + * Handle next question + * @private + */ + _handleNextQuestion() { + this.questionIndex++; + this._presentCurrentQuestion(); + } + + /** + * Handle retry + * @private + */ + _handleRetry() { + // Hide explanation and enable new input + const explanationPanel = document.getElementById('explanation-panel'); + const statusDiv = document.getElementById('validation-status'); + + if (explanationPanel) explanationPanel.style.display = 'none'; + if (statusDiv) statusDiv.innerHTML = ''; + + this._enableRetry(); + } + + /** + * Handle re-read text + * @private + */ + _handleRereadText() { + const textSection = document.getElementById('text-passage-section'); + if (textSection) { + textSection.style.display = 'block'; + textSection.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + + /** + * Enable retry after error + * @private + */ + _enableRetry() { + this.validationInProgress = false; + + const answerInput = document.getElementById('answer-input'); + const validateBtn = document.getElementById('validate-answer-btn'); + + if (answerInput) { + answerInput.disabled = false; + answerInput.focus(); + } + + if (validateBtn) { + validateBtn.disabled = false; + } + } + + /** + * Show final text results + * @private + */ + _showTextResults() { + const resultsContainer = document.getElementById('text-results'); + const questionsSection = document.getElementById('questions-section'); + + if (!resultsContainer) return; + + const correctCount = this.questionResults.filter(result => result.correct).length; + const totalCount = this.questionResults.length; + const comprehensionRate = totalCount > 0 ? Math.round((correctCount / totalCount) * 100) : 0; + + let resultClass = 'results-poor'; + if (comprehensionRate >= 80) resultClass = 'results-excellent'; + else if (comprehensionRate >= 60) resultClass = 'results-good'; + + const resultsHTML = ` +
+

📊 Reading Comprehension Results

+
+
+ ${comprehensionRate}% + Comprehension Rate +
+
+ ${correctCount} / ${totalCount} questions understood well +
+
+ +
+ ${this.questionResults.map((result, index) => ` +
+
+ Q${index + 1} + ${result.correct ? '✅' : '📚'} + Score: ${result.score}/100 +
+
+ `).join('')} +
+ +
+ + +
+
+ `; + + resultsContainer.innerHTML = resultsHTML; + resultsContainer.style.display = 'block'; + + // Hide other sections + if (questionsSection) questionsSection.style.display = 'none'; + + // Add action listeners + document.getElementById('complete-text-btn').onclick = () => this._completeTextExercise(); + document.getElementById('review-text-btn').onclick = () => this._reviewText(); + } + + /** + * Complete text exercise + * @private + */ + _completeTextExercise() { + // Mark text as comprehended if performance is good + const correctCount = this.questionResults.filter(result => result.correct).length; + const comprehensionRate = correctCount / this.questionResults.length; + + if (comprehensionRate >= 0.6) { // 60% comprehension threshold + const textId = this.currentText.id || this.currentText.title || 'text_exercise'; + const metadata = { + comprehensionRate: Math.round(comprehensionRate * 100), + questionsAnswered: this.questionResults.length, + correctAnswers: correctCount, + sessionId: this.orchestrator?.sessionId || 'unknown', + moduleType: 'text', + aiAnalysisUsed: this.aiAvailable + }; + + this.prerequisiteEngine.markPhraseMastered(textId, metadata); + + // Also save to persistent storage + if (window.addMasteredItem && this.orchestrator?.bookId && this.orchestrator?.chapterId) { + window.addMasteredItem( + this.orchestrator.bookId, + this.orchestrator.chapterId, + 'texts', + textId, + metadata + ); + } + } + + // Emit completion event + this.orchestrator._eventBus.emit('drs:exerciseCompleted', { + moduleType: 'text', + results: this.questionResults, + progress: this.getProgress() + }, 'TextModule'); + } + + /** + * Review text again + * @private + */ + _reviewText() { + this.questionIndex = 0; + this.questionResults = []; + this._showTextReading(); + + const resultsContainer = document.getElementById('text-results'); + if (resultsContainer) resultsContainer.style.display = 'none'; + } + + /** + * Add CSS styles for text exercise + * @private + */ + _addStyles() { + if (document.getElementById('text-module-styles')) return; + + const styles = document.createElement('style'); + styles.id = 'text-module-styles'; + styles.textContent = ` + .text-exercise { + max-width: 900px; + margin: 0 auto; + padding: 20px; + display: grid; + gap: 20px; + } + + .exercise-header { + text-align: center; + margin-bottom: 20px; + } + + .text-info { + margin-top: 10px; + } + + .text-meta { + color: #666; + font-size: 0.9em; + } + + .text-content { + display: grid; + gap: 20px; + } + + .text-passage-card, .question-card { + background: white; + border-radius: 12px; + padding: 30px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + } + + .passage-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 2px solid #eee; + } + + .passage-header h3 { + margin: 0; + color: #333; + font-size: 1.5em; + } + + .passage-content { + line-height: 1.8; + font-size: 1.1em; + color: #444; + margin-bottom: 30px; + max-height: 400px; + overflow-y: auto; + } + + .passage-content p { + margin-bottom: 1.2em; + } + + .reading-actions { + text-align: center; + } + + .question-progress { + margin-bottom: 25px; + } + + .progress-indicator { + text-align: center; + } + + .progress-bar { + width: 100%; + height: 8px; + background-color: #e0e0e0; + border-radius: 4px; + margin-top: 10px; + overflow: hidden; + } + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, #667eea, #764ba2); + transition: width 0.5s ease; + } + + .question-display { + margin-bottom: 25px; + padding: 20px; + background: linear-gradient(135deg, #f8f9ff, #e8f4fd); + border-radius: 10px; + border-left: 4px solid #667eea; + } + + .question-text { + font-size: 1.3em; + font-weight: 600; + color: #333; + margin-bottom: 15px; + line-height: 1.4; + } + + .question-meta { + display: flex; + gap: 15px; + align-items: center; + } + + .question-type { + background: #e3f2fd; + color: #1976d2; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .difficulty-easy { + background: #e8f5e8; + color: #2e7d32; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .difficulty-medium { + background: #fff3e0; + color: #f57c00; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .difficulty-hard { + background: #ffebee; + color: #c62828; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.85em; + font-weight: 500; + } + + .answer-input-section { + margin-bottom: 20px; + } + + .answer-input-section label { + display: block; + margin-bottom: 10px; + font-weight: 600; + color: #555; + } + + .answer-input-section textarea { + width: 100%; + padding: 15px; + font-size: 1.05em; + border: 2px solid #ddd; + border-radius: 8px; + resize: vertical; + min-height: 100px; + box-sizing: border-box; + transition: border-color 0.3s ease; + font-family: inherit; + line-height: 1.5; + } + + .answer-input-section textarea:focus { + outline: none; + border-color: #667eea; + } + + .question-controls { + display: flex; + flex-direction: column; + align-items: center; + gap: 15px; + } + + .validation-status { + min-height: 30px; + display: flex; + align-items: center; + justify-content: center; + } + + .status-loading, .status-complete, .status-error { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 20px; + border-radius: 25px; + font-weight: 500; + } + + .status-loading { + background: #e3f2fd; + color: #1976d2; + } + + .status-complete { + background: #e8f5e8; + color: #2e7d32; + } + + .status-error { + background: #ffebee; + color: #c62828; + } + + .loading-spinner { + animation: spin 1s linear infinite; + } + + .explanation-panel { + background: white; + border-radius: 12px; + padding: 25px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + border-left: 4px solid #667eea; + } + + .panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid #eee; + } + + .panel-header h3 { + margin: 0; + color: #333; + } + + .analysis-model { + font-size: 0.9em; + color: #666; + background: #f5f5f5; + padding: 4px 8px; + border-radius: 4px; + } + + .explanation-result { + padding: 20px; + border-radius: 8px; + margin-bottom: 15px; + } + + .explanation-result.correct { + border-left: 4px solid #4caf50; + background: linear-gradient(135deg, #f1f8e9, #e8f5e8); + } + + .explanation-result.needs-improvement { + border-left: 4px solid #ff9800; + background: linear-gradient(135deg, #fff8e1, #fff3e0); + } + + .result-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + font-weight: 600; + } + + .result-indicator { + font-size: 1.1em; + } + + .comprehension-score { + font-size: 0.9em; + color: #666; + } + + .explanation-text { + line-height: 1.6; + color: #333; + font-size: 1.05em; + margin-bottom: 10px; + } + + .analysis-note { + font-size: 0.9em; + color: #666; + font-style: italic; + padding-top: 10px; + border-top: 1px solid #eee; + } + + .panel-actions { + display: flex; + gap: 15px; + justify-content: center; + flex-wrap: wrap; + } + + .text-results-content { + background: white; + border-radius: 12px; + padding: 30px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + text-align: center; + } + + .results-summary { + margin-bottom: 30px; + } + + .comprehension-display { + margin-bottom: 15px; + } + + .comprehension-rate { + font-size: 3em; + font-weight: bold; + display: block; + } + + .results-excellent .comprehension-rate { color: #4caf50; } + .results-good .comprehension-rate { color: #ff9800; } + .results-poor .comprehension-rate { color: #f44336; } + + .comprehension-label { + font-size: 1.2em; + color: #666; + } + + .questions-summary { + font-size: 1.1em; + color: #555; + } + + .question-breakdown { + display: grid; + gap: 10px; + margin-bottom: 30px; + } + + .question-result { + display: flex; + align-items: center; + justify-content: space-between; + padding: 15px; + border-radius: 8px; + background: #f8f9fa; + } + + .question-result.understood { + background: linear-gradient(135deg, #e8f5e8, #f1f8e9); + border-left: 4px solid #4caf50; + } + + .question-result.needs-work { + background: linear-gradient(135deg, #fff8e1, #fff3e0); + border-left: 4px solid #ff9800; + } + + .question-summary { + display: flex; + align-items: center; + gap: 15px; + } + + .question-num { + font-weight: bold; + color: #333; + } + + .results-actions { + display: flex; + gap: 15px; + justify-content: center; + flex-wrap: wrap; + } + + .btn { + padding: 12px 24px; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 1em; + font-weight: 500; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + gap: 8px; + text-decoration: none; + } + + .btn:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .btn-primary { + background: linear-gradient(135deg, #667eea, #764ba2); + color: white; + } + + .btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); + } + + .btn-secondary { + background: #6c757d; + color: white; + } + + .btn-secondary:hover:not(:disabled) { + background: #5a6268; + } + + .btn-outline { + background: transparent; + border: 2px solid #667eea; + color: #667eea; + } + + .btn-outline:hover:not(:disabled) { + background: #667eea; + color: white; + } + + .btn-success { + background: linear-gradient(135deg, #4caf50, #45a049); + color: white; + } + + .btn-success:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(76, 175, 80, 0.3); + } + + .btn-sm { + padding: 8px 16px; + font-size: 0.9em; + } + + @keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + + @media (max-width: 768px) { + .text-exercise { + padding: 15px; + } + + .text-passage-card, .question-card, .explanation-panel { + padding: 20px; + } + + .question-text { + font-size: 1.2em; + } + + .comprehension-rate { + font-size: 2.5em; + } + + .panel-actions, .results-actions { + flex-direction: column; + } + + .passage-header { + flex-direction: column; + gap: 15px; + align-items: flex-start; + } + } + `; + + document.head.appendChild(styles); + } +} + +export default TextModule; \ No newline at end of file diff --git a/src/DRS/exercise-modules/VocabularyModule.js b/src/DRS/exercise-modules/VocabularyModule.js new file mode 100644 index 0000000..925dd92 --- /dev/null +++ b/src/DRS/exercise-modules/VocabularyModule.js @@ -0,0 +1,923 @@ +/** + * VocabularyModule - Groups of 5 vocabulary exercise implementation + * First exercise module following the ExerciseModuleInterface + */ + +import ExerciseModuleInterface from '../interfaces/ExerciseModuleInterface.js'; + +class VocabularyModule extends ExerciseModuleInterface { + constructor(orchestrator, llmValidator, prerequisiteEngine, contextMemory) { + super(); + + // Validate dependencies + if (!orchestrator || !llmValidator || !prerequisiteEngine || !contextMemory) { + throw new Error('VocabularyModule requires all service dependencies'); + } + + this.orchestrator = orchestrator; + this.llmValidator = llmValidator; + this.prerequisiteEngine = prerequisiteEngine; + this.contextMemory = contextMemory; + + // Module state + this.initialized = false; + this.container = null; + this.currentExerciseData = null; + this.currentVocabularyGroup = []; + this.currentWordIndex = 0; + this.groupResults = []; + this.isRevealed = false; + + // Configuration + this.config = { + groupSize: 5, + masteryThreshold: 80, // 80% correct to consider mastered + maxAttempts: 3, + showPronunciation: true, + randomizeOrder: true + }; + + // Bind methods + this._handleNextWord = this._handleNextWord.bind(this); + this._handleRevealAnswer = this._handleRevealAnswer.bind(this); + this._handleUserInput = this._handleUserInput.bind(this); + this._handleDifficultySelection = this._handleDifficultySelection.bind(this); + } + + async init() { + if (this.initialized) return; + + console.log('📚 Initializing VocabularyModule...'); + this.initialized = true; + console.log('✅ VocabularyModule initialized'); + } + + /** + * Check if module can run with current prerequisites + * @param {Array} prerequisites - List of learned vocabulary/concepts + * @param {Object} chapterContent - Full chapter content + * @returns {boolean} - True if module can run + */ + canRun(prerequisites, chapterContent) { + // Vocabulary module can always run if there's vocabulary in the chapter + const hasVocabulary = chapterContent && chapterContent.vocabulary && + Object.keys(chapterContent.vocabulary).length > 0; + + return hasVocabulary; + } + + /** + * Present exercise UI and content + * @param {HTMLElement} container - DOM container to render into + * @param {Object} exerciseData - Specific exercise data to present + * @returns {Promise} + */ + async present(container, exerciseData) { + if (!this.initialized) { + throw new Error('VocabularyModule must be initialized before use'); + } + + this.container = container; + this.currentExerciseData = exerciseData; + + // Extract vocabulary group + this.currentVocabularyGroup = exerciseData.vocabulary || []; + this.currentWordIndex = 0; + this.groupResults = []; + this.isRevealed = false; + + if (this.config.randomizeOrder) { + this._shuffleArray(this.currentVocabularyGroup); + } + + console.log(`📚 Presenting vocabulary group (${this.currentVocabularyGroup.length} words)`); + + // Render initial UI + await this._renderVocabularyExercise(); + + // Start with first word + this._presentCurrentWord(); + } + + /** + * Validate user input with simple string matching (NO AI) + * @param {string} userInput - User's response + * @param {Object} context - Exercise context and expected answer + * @returns {Promise} - Validation result with score and feedback + */ + async validate(userInput, context) { + if (!userInput || !userInput.trim()) { + return { + score: 0, + correct: false, + feedback: "Please provide an answer.", + timestamp: new Date().toISOString(), + provider: 'local' + }; + } + + const currentWord = this.currentVocabularyGroup[this.currentWordIndex]; + const expectedTranslation = currentWord.translation; + const userAnswer = userInput.trim(); + + // Simple string matching validation (NO AI) + const isCorrect = this._checkTranslation(userAnswer, expectedTranslation); + + const result = { + score: isCorrect ? 100 : 0, + correct: isCorrect, + feedback: isCorrect + ? "Correct! Well done." + : `Incorrect. The correct answer is: ${expectedTranslation}`, + timestamp: new Date().toISOString(), + provider: 'local', + expectedAnswer: expectedTranslation, + userAnswer: userAnswer + }; + + // Record interaction in context memory + this.contextMemory.recordInteraction({ + type: 'vocabulary', + subtype: 'translation', + content: { + vocabulary: [currentWord], + word: currentWord.word, + expectedTranslation + }, + userResponse: userAnswer, + validation: result, + context: { validationType: 'simple_string_match' } + }); + + return result; + } + + /** + * Get current progress data + * @returns {ProgressData} - Progress information for this module + */ + getProgress() { + const totalWords = this.currentVocabularyGroup.length; + const completedWords = this.groupResults.length; + const correctWords = this.groupResults.filter(result => result.correct).length; + + return { + type: 'vocabulary', + totalWords, + completedWords, + correctWords, + currentWordIndex: this.currentWordIndex, + groupResults: this.groupResults, + progressPercentage: totalWords > 0 ? Math.round((completedWords / totalWords) * 100) : 0, + accuracyPercentage: completedWords > 0 ? Math.round((correctWords / completedWords) * 100) : 0 + }; + } + + /** + * Clean up and prepare for unloading + */ + cleanup() { + console.log('🧹 Cleaning up VocabularyModule...'); + + // Remove event listeners + if (this.container) { + this.container.innerHTML = ''; + } + + // Reset state + this.container = null; + this.currentExerciseData = null; + this.currentVocabularyGroup = []; + this.currentWordIndex = 0; + this.groupResults = []; + this.isRevealed = false; + + console.log('✅ VocabularyModule cleaned up'); + } + + /** + * Get module metadata + * @returns {Object} - Module information + */ + getMetadata() { + return { + name: 'VocabularyModule', + type: 'vocabulary', + version: '1.0.0', + description: 'Groups of 5 vocabulary exercises with LLM validation', + capabilities: ['translation', 'pronunciation', 'spaced_repetition'], + config: this.config + }; + } + + // Private Methods + + /** + * Check translation with simple string matching and fuzzy logic + * @param {string} userAnswer - User's answer + * @param {string} expectedTranslation - Expected correct answer + * @returns {boolean} - True if answer is acceptable + * @private + */ + _checkTranslation(userAnswer, expectedTranslation) { + if (!userAnswer || !expectedTranslation) return false; + + // Normalize both strings + const normalizeString = (str) => { + return str.toLowerCase() + .trim() + .replace(/[.,!?;:"'()]/g, '') // Remove punctuation + .replace(/\s+/g, ' '); // Normalize whitespace + }; + + const normalizedUser = normalizeString(userAnswer); + const normalizedExpected = normalizeString(expectedTranslation); + + // Exact match after normalization + if (normalizedUser === normalizedExpected) { + return true; + } + + // Split expected answer into alternatives (e.g., "shirt, t-shirt" or "shirt / t-shirt") + const alternatives = normalizedExpected.split(/[,/|;]/).map(alt => alt.trim()); + + // Check if user answer matches any alternative + for (const alternative of alternatives) { + if (normalizedUser === alternative) { + return true; + } + + // Allow partial matches for single words if they're very close + if (alternative.split(' ').length === 1 && normalizedUser.split(' ').length === 1) { + const similarity = this._calculateSimilarity(normalizedUser, alternative); + if (similarity > 0.85) { // 85% similarity threshold + return true; + } + } + } + + return false; + } + + /** + * Calculate string similarity using simple character comparison + * @param {string} str1 - First string + * @param {string} str2 - Second string + * @returns {number} - Similarity score between 0 and 1 + * @private + */ + _calculateSimilarity(str1, str2) { + if (str1 === str2) return 1.0; + if (str1.length === 0 || str2.length === 0) return 0.0; + + // Simple character-based similarity + const longer = str1.length > str2.length ? str1 : str2; + const shorter = str1.length > str2.length ? str2 : str1; + + if (longer.length === 0) return 1.0; + + const editDistance = this._levenshteinDistance(str1, str2); + return (longer.length - editDistance) / longer.length; + } + + /** + * Calculate Levenshtein distance between two strings + * @param {string} str1 - First string + * @param {string} str2 - Second string + * @returns {number} - Edit distance + * @private + */ + _levenshteinDistance(str1, str2) { + const matrix = []; + + for (let i = 0; i <= str2.length; i++) { + matrix[i] = [i]; + } + + for (let j = 0; j <= str1.length; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= str2.length; i++) { + for (let j = 1; j <= str1.length; j++) { + if (str2.charAt(i - 1) === str1.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1]; + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j] + 1 + ); + } + } + } + + return matrix[str2.length][str1.length]; + } + + async _renderVocabularyExercise() { + if (!this.container) return; + + const totalWords = this.currentVocabularyGroup.length; + const progressPercentage = totalWords > 0 ? + Math.round((this.currentWordIndex / totalWords) * 100) : 0; + + this.container.innerHTML = ` +
+
+

📚 Vocabulary Practice

+
+ + Word ${this.currentWordIndex + 1} of ${totalWords} + +
+
+
+
+
+ +
+ +
+ +
+ +
+ + +
+ `; + + // Add CSS styles + this._addStyles(); + } + + _presentCurrentWord() { + if (this.currentWordIndex >= this.currentVocabularyGroup.length) { + this._showGroupResults(); + return; + } + + const currentWord = this.currentVocabularyGroup[this.currentWordIndex]; + const card = document.getElementById('vocabulary-card'); + const controls = document.getElementById('exercise-controls'); + + if (!card || !controls) return; + + this.isRevealed = false; + + card.innerHTML = ` +
+
+

${currentWord.word}

+ ${this.config.showPronunciation && currentWord.pronunciation ? + `
[${currentWord.pronunciation}]
` : ''} +
${currentWord.type || 'word'}
+
+ +
+
+ + +
+
+ + +
+ `; + + controls.innerHTML = ` +
+ + +
+ `; + + // Add event listeners + document.getElementById('reveal-btn').onclick = this._handleRevealAnswer; + document.getElementById('submit-btn').onclick = this._handleUserInput; + + // Allow Enter key to submit + const input = document.getElementById('translation-input'); + input.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + this._handleUserInput(); + } + }); + + // Focus on input + input.focus(); + } + + async _handleUserInput() { + const input = document.getElementById('translation-input'); + const userInput = input ? input.value.trim() : ''; + + if (!userInput) { + this._showFeedback('Please enter a translation.', 'warning'); + return; + } + + // Disable input during validation + this._setInputEnabled(false); + this._showFeedback('Checking answer...', 'info'); + + try { + const validationResult = await this.validate(userInput, {}); + + // Store result + this.groupResults[this.currentWordIndex] = { + word: this.currentVocabularyGroup[this.currentWordIndex].word, + userAnswer: userInput, + correct: validationResult.correct, + score: validationResult.score, + feedback: validationResult.feedback, + timestamp: new Date().toISOString() + }; + + // Show result and difficulty selection + this._showValidationResult(validationResult); + + } catch (error) { + console.error('Validation error:', error); + this._showFeedback('Error validating answer. Please try again.', 'error'); + this._setInputEnabled(true); + } + } + + _handleRevealAnswer() { + const revealedSection = document.getElementById('revealed-answer'); + const answerSection = document.getElementById('answer-section'); + + if (revealedSection && answerSection) { + revealedSection.style.display = 'block'; + answerSection.style.display = 'none'; + this.isRevealed = true; + + // Mark as incorrect since user revealed the answer + this.groupResults[this.currentWordIndex] = { + word: this.currentVocabularyGroup[this.currentWordIndex].word, + userAnswer: '[revealed]', + correct: false, + score: 0, + feedback: 'Answer was revealed', + timestamp: new Date().toISOString() + }; + + this._showDifficultySelection(); + } + } + + _showValidationResult(validationResult) { + const feedbackClass = validationResult.correct ? 'success' : 'error'; + this._showFeedback(validationResult.feedback, feedbackClass); + + // Show correct answer if incorrect + if (!validationResult.correct) { + const revealedSection = document.getElementById('revealed-answer'); + if (revealedSection) { + revealedSection.style.display = 'block'; + } + } + + // Show difficulty selection + setTimeout(() => { + this._showDifficultySelection(); + }, 2000); + } + + _showDifficultySelection() { + const controls = document.getElementById('exercise-controls'); + if (!controls) return; + + controls.innerHTML = ` +
+

How difficult was this word?

+
+ + + + +
+
+ `; + + // Add event listeners for difficulty buttons + document.querySelectorAll('.difficulty-btn').forEach(btn => { + btn.onclick = (e) => this._handleDifficultySelection(e.target.dataset.difficulty); + }); + } + + _handleDifficultySelection(difficulty) { + const currentWord = this.currentVocabularyGroup[this.currentWordIndex]; + + // Update result with difficulty + if (this.groupResults[this.currentWordIndex]) { + this.groupResults[this.currentWordIndex].difficulty = difficulty; + } + + // Mark word as mastered if good or easy + if (['good', 'easy'].includes(difficulty)) { + const metadata = { + difficulty: difficulty, + sessionId: this.orchestrator?.sessionId || 'unknown', + moduleType: 'vocabulary', + attempts: this.groupResults[this.currentWordIndex]?.attempts || 1, + correct: this.groupResults[this.currentWordIndex]?.correct || false + }; + this.prerequisiteEngine.markWordMastered(currentWord.word, metadata); + + // Also save to persistent storage + if (window.addMasteredItem && this.orchestrator?.bookId && this.orchestrator?.chapterId) { + window.addMasteredItem( + this.orchestrator.bookId, + this.orchestrator.chapterId, + 'vocabulary', + currentWord.word, + metadata + ); + } + } + + console.log(`Word "${currentWord.word}" marked as ${difficulty}`); + + // Move to next word + this.currentWordIndex++; + this._presentCurrentWord(); + } + + _handleNextWord() { + this.currentWordIndex++; + this._presentCurrentWord(); + } + + _showGroupResults() { + const resultsContainer = document.getElementById('group-results'); + const card = document.getElementById('vocabulary-card'); + const controls = document.getElementById('exercise-controls'); + + if (!resultsContainer) return; + + const correctCount = this.groupResults.filter(result => result.correct).length; + const totalCount = this.groupResults.length; + const accuracy = totalCount > 0 ? Math.round((correctCount / totalCount) * 100) : 0; + + let resultClass = 'results-poor'; + if (accuracy >= 80) resultClass = 'results-excellent'; + else if (accuracy >= 60) resultClass = 'results-good'; + + const resultsHTML = ` +
+

📊 Group Results

+
+
+ ${accuracy}% + Accuracy +
+
+ ${correctCount} / ${totalCount} correct +
+
+ +
+ ${this.groupResults.map((result, index) => ` +
+ ${result.word} + "${result.userAnswer}" + ${result.correct ? '✅' : '❌'} +
+ `).join('')} +
+ +
+ +
+
+ `; + + resultsContainer.innerHTML = resultsHTML; + resultsContainer.style.display = 'block'; + + // Hide other sections + if (card) card.style.display = 'none'; + if (controls) controls.style.display = 'none'; + + // Add continue button listener + document.getElementById('continue-btn').onclick = () => { + // Emit completion event to orchestrator + this.orchestrator._eventBus.emit('drs:exerciseCompleted', { + moduleType: 'vocabulary', + results: this.groupResults, + progress: this.getProgress() + }, 'VocabularyModule'); + }; + } + + _setInputEnabled(enabled) { + const input = document.getElementById('translation-input'); + const submitBtn = document.getElementById('submit-btn'); + const revealBtn = document.getElementById('reveal-btn'); + + if (input) input.disabled = !enabled; + if (submitBtn) submitBtn.disabled = !enabled; + if (revealBtn) revealBtn.disabled = !enabled; + } + + _showFeedback(message, type = 'info') { + // Create or update feedback element + let feedback = document.getElementById('feedback-message'); + if (!feedback) { + feedback = document.createElement('div'); + feedback.id = 'feedback-message'; + feedback.className = 'feedback-message'; + + const card = document.getElementById('vocabulary-card'); + if (card) { + card.appendChild(feedback); + } + } + + feedback.className = `feedback-message feedback-${type}`; + feedback.textContent = message; + feedback.style.display = 'block'; + + // Auto-hide info messages + if (type === 'info') { + setTimeout(() => { + if (feedback) { + feedback.style.display = 'none'; + } + }, 3000); + } + } + + _shuffleArray(array) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + } + + _addStyles() { + if (document.getElementById('vocabulary-module-styles')) return; + + const styles = document.createElement('style'); + styles.id = 'vocabulary-module-styles'; + styles.textContent = ` + .vocabulary-exercise { + max-width: 600px; + margin: 0 auto; + padding: 20px; + } + + .exercise-header { + text-align: center; + margin-bottom: 30px; + } + + .progress-info { + margin-top: 10px; + } + + .progress-bar { + width: 100%; + height: 8px; + background-color: #e0e0e0; + border-radius: 4px; + margin-top: 5px; + overflow: hidden; + } + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, #667eea, #764ba2); + transition: width 0.3s ease; + } + + .vocabulary-card { + background: white; + border-radius: 12px; + padding: 30px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + margin-bottom: 20px; + } + + .word-display { + text-align: center; + margin-bottom: 30px; + } + + .target-word { + font-size: 2.5em; + color: #333; + margin-bottom: 10px; + font-weight: bold; + } + + .pronunciation { + font-style: italic; + color: #666; + margin-bottom: 5px; + } + + .word-type { + color: #888; + font-size: 0.9em; + text-transform: uppercase; + } + + .translation-input { + margin-bottom: 20px; + } + + .translation-input label { + display: block; + margin-bottom: 8px; + font-weight: bold; + color: #555; + } + + .translation-input input { + width: 100%; + padding: 12px; + font-size: 1.1em; + border: 2px solid #ddd; + border-radius: 8px; + box-sizing: border-box; + } + + .translation-input input:focus { + border-color: #667eea; + outline: none; + } + + .revealed-answer { + background-color: #f8f9fa; + padding: 15px; + border-radius: 8px; + margin-top: 15px; + } + + .correct-translation { + font-size: 1.2em; + color: #28a745; + margin-bottom: 5px; + } + + .pronunciation-text { + font-style: italic; + color: #666; + } + + .exercise-controls { + text-align: center; + } + + .control-buttons { + display: flex; + gap: 15px; + justify-content: center; + } + + .difficulty-selection { + text-align: center; + } + + .difficulty-buttons { + display: flex; + gap: 10px; + justify-content: center; + flex-wrap: wrap; + margin-top: 15px; + } + + .difficulty-btn { + padding: 10px 15px; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 0.9em; + transition: all 0.3s ease; + } + + .group-results-content { + background: white; + border-radius: 12px; + padding: 30px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + text-align: center; + } + + .results-summary { + margin-bottom: 30px; + } + + .accuracy-display { + margin-bottom: 10px; + } + + .accuracy-number { + font-size: 3em; + font-weight: bold; + display: block; + } + + .results-excellent .accuracy-number { color: #28a745; } + .results-good .accuracy-number { color: #ffc107; } + .results-poor .accuracy-number { color: #dc3545; } + + .word-results { + text-align: left; + margin-bottom: 30px; + } + + .word-result { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + margin-bottom: 5px; + border-radius: 8px; + background-color: #f8f9fa; + } + + .word-result.correct { background-color: #d4edda; } + .word-result.incorrect { background-color: #f8d7da; } + + .feedback-message { + padding: 10px; + border-radius: 8px; + margin-top: 15px; + text-align: center; + } + + .feedback-info { background-color: #d1ecf1; color: #0c5460; } + .feedback-success { background-color: #d4edda; color: #155724; } + .feedback-warning { background-color: #fff3cd; color: #856404; } + .feedback-error { background-color: #f8d7da; color: #721c24; } + + .btn-primary { + background: linear-gradient(135deg, #667eea, #764ba2); + color: white; + border: none; + padding: 12px 24px; + border-radius: 8px; + cursor: pointer; + font-size: 1em; + transition: all 0.3s ease; + } + + .btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); + } + + .btn-secondary { + background: #6c757d; + color: white; + border: none; + padding: 12px 24px; + border-radius: 8px; + cursor: pointer; + font-size: 1em; + transition: all 0.3s ease; + } + + .btn-secondary:hover { + background: #5a6268; + } + + .btn-success { background-color: #28a745; color: white; } + .btn-warning { background-color: #ffc107; color: #212529; } + .btn-error { background-color: #dc3545; color: white; } + + button:disabled { + opacity: 0.6; + cursor: not-allowed; + } + `; + + document.head.appendChild(styles); + } +} + +export default VocabularyModule; \ No newline at end of file diff --git a/src/DRS/interfaces/ExerciseModuleInterface.js b/src/DRS/interfaces/ExerciseModuleInterface.js new file mode 100644 index 0000000..7e7bd43 --- /dev/null +++ b/src/DRS/interfaces/ExerciseModuleInterface.js @@ -0,0 +1,61 @@ +/** + * ExerciseModuleInterface - Standard interface for all exercise modules + * All exercise modules must implement these methods + */ + +class ExerciseModuleInterface { + /** + * Check if module can run with current prerequisites + * @param {Array} prerequisites - List of learned vocabulary/concepts + * @param {Object} chapterContent - Full chapter content + * @returns {boolean} - True if module can run + */ + canRun(prerequisites, chapterContent) { + throw new Error('ExerciseModuleInterface.canRun() must be implemented by subclass'); + } + + /** + * Present exercise UI and content + * @param {HTMLElement} container - DOM container to render into + * @param {Object} exerciseData - Specific exercise data to present + * @returns {Promise} + */ + async present(container, exerciseData) { + throw new Error('ExerciseModuleInterface.present() must be implemented by subclass'); + } + + /** + * Validate user input with LLM + * @param {string} userInput - User's response + * @param {Object} context - Exercise context and expected answer + * @returns {Promise} - Validation result with score and feedback + */ + async validate(userInput, context) { + throw new Error('ExerciseModuleInterface.validate() must be implemented by subclass'); + } + + /** + * Get current progress data + * @returns {ProgressData} - Progress information for this module + */ + getProgress() { + throw new Error('ExerciseModuleInterface.getProgress() must be implemented by subclass'); + } + + /** + * Clean up and prepare for unloading + */ + cleanup() { + throw new Error('ExerciseModuleInterface.cleanup() must be implemented by subclass'); + } + + /** + * Get module metadata + * @returns {Object} - Module information + */ + getMetadata() { + throw new Error('ExerciseModuleInterface.getMetadata() must be implemented by subclass'); + } +} + +export default ExerciseModuleInterface; \ No newline at end of file diff --git a/src/DRS/services/AIReportSystem.js b/src/DRS/services/AIReportSystem.js new file mode 100644 index 0000000..4d7729d --- /dev/null +++ b/src/DRS/services/AIReportSystem.js @@ -0,0 +1,563 @@ +/** + * AIReportSystem - Système d'export et de rapport des explications de l'IA + * Capture et formate les réponses détaillées de l'IAEngine pour les étudiants + */ + +class AIReportSystem { + constructor(config = {}) { + this.config = { + includeTimestamps: config.includeTimestamps !== false, + formatStyle: config.formatStyle || 'detailed', // 'detailed', 'summary', 'json' + language: config.language || 'fr', + includeScores: config.includeScores !== false, + ...config + }; + + // Stockage des sessions d'apprentissage + this.sessionReports = new Map(); + this.currentSessionId = null; + } + + /** + * Démarre une nouvelle session de rapport + * @param {Object} sessionInfo - Informations de la session + * @returns {string} - ID de session + */ + startSession(sessionInfo = {}) { + const sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + const session = { + id: sessionId, + startTime: new Date(), + endTime: null, + exerciseCount: 0, + totalScore: 0, + averageScore: 0, + sessionInfo: { + bookId: sessionInfo.bookId || 'unknown', + chapterId: sessionInfo.chapterId || 'unknown', + difficulty: sessionInfo.difficulty || 'medium', + exerciseTypes: sessionInfo.exerciseTypes || [], + ...sessionInfo + }, + exercises: [], + aiInteractions: [], + summary: { + strengths: [], + areasForImprovement: [], + keyLearnings: [], + recommendations: [] + } + }; + + this.sessionReports.set(sessionId, session); + this.currentSessionId = sessionId; + + console.log(`📊 Started AI report session: ${sessionId}`); + return sessionId; + } + + /** + * Ajoute une réponse IA au rapport + * @param {Object} aiResponse - Réponse de l'IAEngine + * @param {Object} exerciseContext - Contexte de l'exercice + */ + addAIResponse(aiResponse, exerciseContext = {}) { + if (!this.currentSessionId) { + console.warn('⚠️ No active session for AI report'); + return; + } + + const session = this.sessionReports.get(this.currentSessionId); + if (!session) { + console.error('❌ Session not found:', this.currentSessionId); + return; + } + + // Créer l'entrée de rapport + const reportEntry = { + timestamp: new Date(), + exerciseType: exerciseContext.exerciseType || 'unknown', + exerciseStep: exerciseContext.exerciseStep || 0, + originalContent: exerciseContext.originalContent || '', + userAnswer: exerciseContext.userAnswer || '', + aiResponse: { + score: aiResponse.score || 0, + correct: aiResponse.correct || false, + feedback: aiResponse.feedback || '', + encouragement: aiResponse.encouragement || '', + keyPoints: aiResponse.keyPoints || [], + mainPointsUnderstood: aiResponse.mainPointsUnderstood || [], + grammarErrors: aiResponse.grammarErrors || [], + grammarStrengths: aiResponse.grammarStrengths || [], + suggestion: aiResponse.suggestion || '', + vocabularyUsed: aiResponse.vocabularyUsed || [], + creativityScore: aiResponse.creativityScore || null + }, + context: { + difficulty: exerciseContext.difficulty || session.sessionInfo.difficulty, + exerciseNumber: session.exerciseCount + 1, + ...exerciseContext + } + }; + + // Ajouter au rapport de session + session.exercises.push(reportEntry); + session.aiInteractions.push(reportEntry); + session.exerciseCount++; + + if (aiResponse.score !== undefined) { + session.totalScore += aiResponse.score; + session.averageScore = session.totalScore / session.exerciseCount; + } + + // Extraire les points d'apprentissage + this._extractLearningPoints(reportEntry, session); + + console.log(`📝 Added AI response to report (Exercise ${session.exerciseCount})`); + } + + /** + * Extrait les points d'apprentissage de la réponse IA + * @private + */ + _extractLearningPoints(reportEntry, session) { + const response = reportEntry.aiResponse; + + // Points forts + if (response.grammarStrengths && response.grammarStrengths.length > 0) { + response.grammarStrengths.forEach(strength => { + if (!session.summary.strengths.includes(strength)) { + session.summary.strengths.push(strength); + } + }); + } + + // Domaines d'amélioration + if (response.grammarErrors && response.grammarErrors.length > 0) { + response.grammarErrors.forEach(error => { + if (!session.summary.areasForImprovement.includes(error)) { + session.summary.areasForImprovement.push(error); + } + }); + } + + // Points clés compris + if (response.keyPoints && response.keyPoints.length > 0) { + response.keyPoints.forEach(point => { + if (!session.summary.keyLearnings.includes(point)) { + session.summary.keyLearnings.push(point); + } + }); + } + + // Suggestions + if (response.suggestion && !session.summary.recommendations.includes(response.suggestion)) { + session.summary.recommendations.push(response.suggestion); + } + } + + /** + * Termine la session actuelle + */ + endSession() { + if (!this.currentSessionId) { + console.warn('⚠️ No active session to end'); + return; + } + + const session = this.sessionReports.get(this.currentSessionId); + if (session) { + session.endTime = new Date(); + session.duration = session.endTime - session.startTime; + } + + console.log(`📊 Ended AI report session: ${this.currentSessionId}`); + this.currentSessionId = null; + } + + /** + * Génère un rapport de session formaté + * @param {string} sessionId - ID de la session (optionnel, utilise la session courante si non spécifié) + * @param {string} format - Format du rapport ('text', 'html', 'json') + * @returns {string} - Rapport formaté + */ + generateReport(sessionId = null, format = 'text') { + const targetSessionId = sessionId || this.currentSessionId; + if (!targetSessionId) { + throw new Error('No session available for report generation'); + } + + const session = this.sessionReports.get(targetSessionId); + if (!session) { + throw new Error(`Session not found: ${targetSessionId}`); + } + + switch (format.toLowerCase()) { + case 'html': + return this._generateHTMLReport(session); + case 'json': + return this._generateJSONReport(session); + case 'text': + default: + return this._generateTextReport(session); + } + } + + /** + * Génère un rapport texte formaté + * @private + */ + _generateTextReport(session) { + const lines = []; + + // En-tête + lines.push('='.repeat(60)); + lines.push('📚 RAPPORT D\'APPRENTISSAGE IA'); + lines.push('='.repeat(60)); + lines.push(''); + + // Informations de session + lines.push(`📖 Livre/Chapitre: ${session.sessionInfo.bookId}/${session.sessionInfo.chapterId}`); + lines.push(`🎯 Difficulté: ${session.sessionInfo.difficulty}`); + lines.push(`📅 Date: ${session.startTime.toLocaleDateString('fr-FR')}`); + lines.push(`⏱️ Heure: ${session.startTime.toLocaleTimeString('fr-FR')}`); + if (session.endTime) { + const duration = Math.round((session.endTime - session.startTime) / 1000 / 60); + lines.push(`⏰ Durée: ${duration} minutes`); + } + lines.push(''); + + // Statistiques générales + lines.push('📊 STATISTIQUES GÉNÉRALES'); + lines.push('-'.repeat(30)); + lines.push(`• Exercices complétés: ${session.exerciseCount}`); + if (session.averageScore > 0) { + lines.push(`• Score moyen: ${Math.round(session.averageScore)}%`); + } + lines.push(''); + + // Détails des exercices + if (session.exercises.length > 0) { + lines.push('📝 DÉTAIL DES EXERCICES'); + lines.push('-'.repeat(30)); + + session.exercises.forEach((exercise, index) => { + const response = exercise.aiResponse; + lines.push(`\n${index + 1}. ${exercise.exerciseType.toUpperCase()} (${exercise.timestamp.toLocaleTimeString('fr-FR')})`); + + if (exercise.originalContent) { + lines.push(` 💡 Contenu: ${exercise.originalContent.substring(0, 100)}${exercise.originalContent.length > 100 ? '...' : ''}`); + } + + if (exercise.userAnswer) { + lines.push(` ✏️ Réponse: ${exercise.userAnswer}`); + } + + if (response.score !== undefined) { + lines.push(` 🎯 Score: ${response.score}% ${response.correct ? '✅' : '❌'}`); + } + + if (response.feedback) { + lines.push(` 📢 Retour: ${response.feedback}`); + } + + if (response.encouragement) { + lines.push(` 💪 Encouragement: ${response.encouragement}`); + } + + if (response.keyPoints && response.keyPoints.length > 0) { + lines.push(` 🔑 Points clés: ${response.keyPoints.join(', ')}`); + } + + if (response.suggestion) { + lines.push(` 💡 Suggestion: ${response.suggestion}`); + } + }); + } + + // Résumé d'apprentissage + lines.push('\n📈 RÉSUMÉ D\'APPRENTISSAGE'); + lines.push('-'.repeat(30)); + + if (session.summary.strengths.length > 0) { + lines.push('\n✅ Points forts:'); + session.summary.strengths.forEach(strength => lines.push(` • ${strength}`)); + } + + if (session.summary.areasForImprovement.length > 0) { + lines.push('\n🔄 Domaines d\'amélioration:'); + session.summary.areasForImprovement.forEach(area => lines.push(` • ${area}`)); + } + + if (session.summary.keyLearnings.length > 0) { + lines.push('\n🧠 Apprentissages clés:'); + session.summary.keyLearnings.forEach(learning => lines.push(` • ${learning}`)); + } + + if (session.summary.recommendations.length > 0) { + lines.push('\n🎯 Recommandations:'); + session.summary.recommendations.forEach(rec => lines.push(` • ${rec}`)); + } + + lines.push('\n' + '='.repeat(60)); + lines.push('🎓 Rapport généré par Class Generator 2.0'); + lines.push('='.repeat(60)); + + return lines.join('\n'); + } + + /** + * Génère un rapport HTML formaté + * @private + */ + _generateHTMLReport(session) { + const html = ` + + + + + + Rapport d'Apprentissage IA - ${session.sessionInfo.bookId} + + + +
+

📚 Rapport d'Apprentissage IA

+

${session.sessionInfo.bookId} - ${session.sessionInfo.chapterId}

+

${session.startTime.toLocaleDateString('fr-FR')} à ${session.startTime.toLocaleTimeString('fr-FR')}

+
+ +
+

📊 Statistiques

+

Exercices complétés: ${session.exerciseCount}

+ ${session.averageScore > 0 ? `

Score moyen: ${Math.round(session.averageScore)}%

` : ''} +

Difficulté: ${session.sessionInfo.difficulty}

+ ${session.duration ? `

Durée: ${Math.round(session.duration / 1000 / 60)} minutes

` : ''} +
+ +

📝 Détail des exercices

+ ${session.exercises.map((exercise, index) => ` +
+

${index + 1}. ${exercise.exerciseType.toUpperCase()} - ${exercise.timestamp.toLocaleTimeString('fr-FR')}

+ + ${exercise.originalContent ? `

💡 Contenu: ${exercise.originalContent}

` : ''} + ${exercise.userAnswer ? `

✏️ Votre réponse: ${exercise.userAnswer}

` : ''} + + ${exercise.aiResponse.score !== undefined ? ` +

🎯 Score: + ${exercise.aiResponse.score}% + ${exercise.aiResponse.correct ? '✅' : '❌'} +

+ ` : ''} + + ${exercise.aiResponse.feedback ? `` : ''} + ${exercise.aiResponse.encouragement ? `
💪 ${exercise.aiResponse.encouragement}
` : ''} + + ${exercise.aiResponse.keyPoints && exercise.aiResponse.keyPoints.length > 0 ? ` +

🔑 Points clés:
+ ${exercise.aiResponse.keyPoints.map(point => `${point}`).join('')}

+ ` : ''} + + ${exercise.aiResponse.suggestion ? `

💡 Suggestion: ${exercise.aiResponse.suggestion}

` : ''} +
+ `).join('')} + +
+

📈 Résumé d'apprentissage

+ + ${session.summary.strengths.length > 0 ? ` +

✅ Points forts

+
    ${session.summary.strengths.map(s => `
  • ${s}
  • `).join('')}
+ ` : ''} + + ${session.summary.areasForImprovement.length > 0 ? ` +

🔄 Domaines d'amélioration

+
    ${session.summary.areasForImprovement.map(a => `
  • ${a}
  • `).join('')}
+ ` : ''} + + ${session.summary.keyLearnings.length > 0 ? ` +

🧠 Apprentissages clés

+
    ${session.summary.keyLearnings.map(l => `
  • ${l}
  • `).join('')}
+ ` : ''} + + ${session.summary.recommendations.length > 0 ? ` +

🎯 Recommandations

+
    ${session.summary.recommendations.map(r => `
  • ${r}
  • `).join('')}
+ ` : ''} +
+ +
+ 🎓 Rapport généré par Class Generator 2.0 - ${new Date().toLocaleDateString('fr-FR')} +
+ +`; + + return html; + } + + /** + * Génère un rapport JSON + * @private + */ + _generateJSONReport(session) { + return JSON.stringify(session, null, 2); + } + + /** + * Obtient la classe CSS pour un score + * @private + */ + _getScoreClass(score) { + if (score >= 80) return 'score-good'; + if (score >= 60) return 'score-medium'; + return 'score-poor'; + } + + /** + * Exporte un rapport vers un fichier + * @param {string} sessionId - ID de la session + * @param {string} format - Format du fichier ('text', 'html', 'json') + * @param {string} filename - Nom du fichier (optionnel) + * @returns {string} - URL de téléchargement + */ + exportReport(sessionId = null, format = 'text', filename = null) { + const session = this.sessionReports.get(sessionId || this.currentSessionId); + if (!session) { + throw new Error('Session not found for export'); + } + + const content = this.generateReport(sessionId, format); + + // Créer le nom de fichier + const defaultFilename = `rapport_ia_${session.sessionInfo.bookId}_${session.sessionInfo.chapterId}_${new Date().toISOString().split('T')[0]}.${format}`; + const finalFilename = filename || defaultFilename; + + // Créer un blob et une URL de téléchargement + const mimeTypes = { + 'text': 'text/plain;charset=utf-8', + 'html': 'text/html;charset=utf-8', + 'json': 'application/json;charset=utf-8' + }; + + const blob = new Blob([content], { type: mimeTypes[format] || 'text/plain' }); + const url = URL.createObjectURL(blob); + + // Déclencher le téléchargement + const a = document.createElement('a'); + a.href = url; + a.download = finalFilename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + // Nettoyer l'URL + setTimeout(() => URL.revokeObjectURL(url), 100); + + console.log(`📥 Exported AI report: ${finalFilename}`); + return url; + } + + /** + * Obtient toutes les sessions + * @returns {Array} - Liste des sessions + */ + getAllSessions() { + return Array.from(this.sessionReports.values()); + } + + /** + * Supprime une session + * @param {string} sessionId - ID de la session à supprimer + */ + deleteSession(sessionId) { + if (this.sessionReports.delete(sessionId)) { + console.log(`🗑️ Deleted AI report session: ${sessionId}`); + + // Si c'est la session courante, la réinitialiser + if (this.currentSessionId === sessionId) { + this.currentSessionId = null; + } + } + } + + /** + * Nettoie les anciennes sessions (plus de X jours) + * @param {number} daysOld - Nombre de jours (défaut: 30) + */ + cleanupOldSessions(daysOld = 30) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysOld); + + let deletedCount = 0; + for (const [sessionId, session] of this.sessionReports.entries()) { + if (session.startTime < cutoffDate) { + this.sessionReports.delete(sessionId); + deletedCount++; + } + } + + console.log(`🧹 Cleaned up ${deletedCount} old AI report sessions`); + } + + /** + * Obtient les statistiques globales de toutes les sessions + * @returns {Object} - Statistiques globales + */ + getGlobalStats() { + const sessions = this.getAllSessions(); + + if (sessions.length === 0) { + return { + totalSessions: 0, + totalExercises: 0, + averageScore: 0, + mostCommonExerciseType: null, + totalTimeSpent: 0 + }; + } + + const totalExercises = sessions.reduce((sum, session) => sum + session.exerciseCount, 0); + const totalScore = sessions.reduce((sum, session) => sum + session.totalScore, 0); + const averageScore = totalExercises > 0 ? totalScore / totalExercises : 0; + + // Types d'exercices les plus courants + const exerciseTypes = {}; + sessions.forEach(session => { + session.exercises.forEach(exercise => { + exerciseTypes[exercise.exerciseType] = (exerciseTypes[exercise.exerciseType] || 0) + 1; + }); + }); + + const mostCommonExerciseType = Object.keys(exerciseTypes).reduce((a, b) => + exerciseTypes[a] > exerciseTypes[b] ? a : b, null); + + // Temps total passé + const totalTimeSpent = sessions + .filter(session => session.duration) + .reduce((sum, session) => sum + session.duration, 0); + + return { + totalSessions: sessions.length, + totalExercises, + averageScore: Math.round(averageScore), + mostCommonExerciseType, + totalTimeSpent: Math.round(totalTimeSpent / 1000 / 60), // en minutes + exerciseTypeDistribution: exerciseTypes + }; + } +} + +export default AIReportSystem; \ No newline at end of file diff --git a/src/DRS/services/ContextMemory.js b/src/DRS/services/ContextMemory.js new file mode 100644 index 0000000..22e7df4 --- /dev/null +++ b/src/DRS/services/ContextMemory.js @@ -0,0 +1,474 @@ +/** + * ContextMemory - Progressive context building service + * Stores user responses and builds context for sequential exercises + */ + +class ContextMemory { + constructor() { + this.sessions = new Map(); // sessionId -> session data + this.currentSessionId = null; + this.maxHistoryLength = 50; // Maximum number of interactions to remember + this.contextTypes = ['vocabulary', 'phrase', 'text', 'audio', 'image', 'grammar']; + } + + /** + * Start a new session + * @param {string} sessionId - Unique session identifier + * @param {Object} sessionData - Initial session data + */ + startSession(sessionId, sessionData = {}) { + this.currentSessionId = sessionId; + + this.sessions.set(sessionId, { + id: sessionId, + startTime: new Date().toISOString(), + chapterData: sessionData, + interactions: [], + contextBuilders: { + vocabulary: new Map(), // word -> interaction history + phrase: new Map(), // phrase -> interaction history + text: new Map(), // text -> progressive context + audio: new Map(), // audio -> comprehension context + image: new Map(), // image -> description context + grammar: new Map() // grammar concept -> usage context + }, + progressiveContext: { + currentText: null, + accumulatedSentences: [], + currentDialog: null, + accumulatedDialogLines: [], + learningPath: [] + } + }); + + console.log(`🧠 Context memory session started: ${sessionId}`); + } + + /** + * End current session and cleanup + * @param {string} sessionId - Session to end (optional, defaults to current) + */ + endSession(sessionId = null) { + const targetSessionId = sessionId || this.currentSessionId; + + if (this.sessions.has(targetSessionId)) { + const session = this.sessions.get(targetSessionId); + session.endTime = new Date().toISOString(); + session.duration = new Date(session.endTime) - new Date(session.startTime); + + console.log(`🧠 Context memory session ended: ${targetSessionId} (${session.interactions.length} interactions)`); + } + + if (targetSessionId === this.currentSessionId) { + this.currentSessionId = null; + } + } + + /** + * Record an interaction and build context + * @param {Object} interaction - Interaction data + * @returns {Object} - Updated context for this interaction type + */ + recordInteraction(interaction) { + if (!this.currentSessionId) { + throw new Error('No active session. Call startSession() first.'); + } + + const session = this.sessions.get(this.currentSessionId); + + // Add timestamp and normalize interaction + const normalizedInteraction = { + timestamp: new Date().toISOString(), + type: interaction.type || 'unknown', + subtype: interaction.subtype || 'general', + content: interaction.content || {}, + userResponse: interaction.userResponse || '', + validation: interaction.validation || {}, + context: interaction.context || {}, + ...interaction + }; + + // Store interaction + session.interactions.push(normalizedInteraction); + + // Build type-specific context + this._buildContextForType(session, normalizedInteraction); + + // Build progressive context if applicable + this._buildProgressiveContext(session, normalizedInteraction); + + // Maintain history limits + this._maintainHistoryLimits(session); + + console.log(`🧠 Recorded ${normalizedInteraction.type} interaction`); + + return this.getContextForType(normalizedInteraction.type, normalizedInteraction.content); + } + + /** + * Get context for a specific exercise type + * @param {string} exerciseType - Type of exercise + * @param {Object} content - Exercise content for context matching + * @returns {Object} - Context information + */ + getContextForType(exerciseType, content = {}) { + if (!this.currentSessionId) { + return { history: [], progressiveContext: {} }; + } + + const session = this.sessions.get(this.currentSessionId); + const contextBuilder = session.contextBuilders[exerciseType]; + + if (!contextBuilder) { + return { history: [], progressiveContext: session.progressiveContext }; + } + + // Get relevant history based on content + const relevantHistory = this._getRelevantHistory(session, exerciseType, content); + + return { + history: relevantHistory, + progressiveContext: session.progressiveContext, + typeSpecificContext: this._getTypeSpecificContext(session, exerciseType, content) + }; + } + + /** + * Get full session history + * @param {string} sessionId - Session ID (optional, defaults to current) + * @returns {Array} - Session interaction history + */ + getSessionHistory(sessionId = null) { + const targetSessionId = sessionId || this.currentSessionId; + + if (!targetSessionId || !this.sessions.has(targetSessionId)) { + return []; + } + + return this.sessions.get(targetSessionId).interactions; + } + + /** + * Get context summary for LLM prompts + * @param {string} exerciseType - Type of current exercise + * @param {Object} content - Current exercise content + * @returns {string} - Formatted context summary + */ + getContextSummary(exerciseType, content = {}) { + const context = this.getContextForType(exerciseType, content); + + if (!context.history.length) { + return 'No previous context.'; + } + + // Format recent relevant interactions for LLM + const recentInteractions = context.history.slice(-5); // Last 5 relevant interactions + const summary = recentInteractions.map(interaction => { + const score = interaction.validation.score || 'N/A'; + const correct = interaction.validation.correct ? '✅' : '❌'; + return `${interaction.type}: "${interaction.userResponse}" ${correct} (${score})`; + }).join('\n'); + + // Add progressive context if applicable + let progressiveInfo = ''; + if (context.progressiveContext.accumulatedSentences.length > 0) { + progressiveInfo = `\nProgressive text context: ${context.progressiveContext.accumulatedSentences.length} previous sentences`; + } + + return `Recent context:\n${summary}${progressiveInfo}`; + } + + /** + * Clear session data + * @param {string} sessionId - Session to clear (optional, defaults to current) + */ + clearSession(sessionId = null) { + const targetSessionId = sessionId || this.currentSessionId; + + if (this.sessions.has(targetSessionId)) { + this.sessions.delete(targetSessionId); + console.log(`🧹 Context memory cleared for session: ${targetSessionId}`); + } + + if (targetSessionId === this.currentSessionId) { + this.currentSessionId = null; + } + } + + /** + * Get memory statistics + * @returns {Object} - Memory usage statistics + */ + getStats() { + return { + activeSessions: this.sessions.size, + currentSessionId: this.currentSessionId, + currentSessionInteractions: this.currentSessionId ? + this.sessions.get(this.currentSessionId)?.interactions.length || 0 : 0, + totalInteractions: Array.from(this.sessions.values()) + .reduce((total, session) => total + session.interactions.length, 0) + }; + } + + // Private Methods + + _buildContextForType(session, interaction) { + const { type, content } = interaction; + const contextBuilder = session.contextBuilders[type]; + + if (!contextBuilder) return; + + // Create or update type-specific context + switch (type) { + case 'vocabulary': + this._buildVocabularyContext(contextBuilder, interaction); + break; + case 'phrase': + this._buildPhraseContext(contextBuilder, interaction); + break; + case 'text': + this._buildTextContext(contextBuilder, interaction); + break; + case 'audio': + this._buildAudioContext(contextBuilder, interaction); + break; + case 'image': + this._buildImageContext(contextBuilder, interaction); + break; + case 'grammar': + this._buildGrammarContext(contextBuilder, interaction); + break; + } + } + + _buildVocabularyContext(contextBuilder, interaction) { + const words = interaction.content.vocabulary || []; + + words.forEach(wordData => { + const word = wordData.word; + if (!contextBuilder.has(word)) { + contextBuilder.set(word, []); + } + contextBuilder.get(word).push({ + timestamp: interaction.timestamp, + userResponse: interaction.userResponse, + validation: interaction.validation, + attempts: contextBuilder.get(word).length + 1 + }); + }); + } + + _buildPhraseContext(contextBuilder, interaction) { + const phraseKey = interaction.content.phrase?.id || + interaction.content.phrase?.english || + `phrase_${interaction.timestamp}`; + + if (!contextBuilder.has(phraseKey)) { + contextBuilder.set(phraseKey, []); + } + + contextBuilder.get(phraseKey).push({ + timestamp: interaction.timestamp, + userResponse: interaction.userResponse, + validation: interaction.validation, + attempts: contextBuilder.get(phraseKey).length + 1 + }); + } + + _buildTextContext(contextBuilder, interaction) { + const textKey = interaction.content.text?.id || + `text_${interaction.content.textIndex || 0}`; + + if (!contextBuilder.has(textKey)) { + contextBuilder.set(textKey, { + sentences: [], + responses: [] + }); + } + + const textContext = contextBuilder.get(textKey); + textContext.responses.push({ + timestamp: interaction.timestamp, + sentenceIndex: interaction.content.sentenceIndex || 0, + userResponse: interaction.userResponse, + validation: interaction.validation + }); + } + + _buildAudioContext(contextBuilder, interaction) { + const audioKey = interaction.content.audio?.id || + `audio_${interaction.timestamp}`; + + if (!contextBuilder.has(audioKey)) { + contextBuilder.set(audioKey, []); + } + + contextBuilder.get(audioKey).push({ + timestamp: interaction.timestamp, + userResponse: interaction.userResponse, + validation: interaction.validation, + comprehensionLevel: interaction.validation.score || 0 + }); + } + + _buildImageContext(contextBuilder, interaction) { + const imageKey = interaction.content.image?.id || + `image_${interaction.timestamp}`; + + if (!contextBuilder.has(imageKey)) { + contextBuilder.set(imageKey, []); + } + + contextBuilder.get(imageKey).push({ + timestamp: interaction.timestamp, + userResponse: interaction.userResponse, + validation: interaction.validation, + vocabularyUsed: interaction.validation.vocabularyUsed || [] + }); + } + + _buildGrammarContext(contextBuilder, interaction) { + const grammarConcepts = interaction.content.grammarConcepts || ['general']; + + grammarConcepts.forEach(concept => { + if (!contextBuilder.has(concept)) { + contextBuilder.set(concept, []); + } + contextBuilder.get(concept).push({ + timestamp: interaction.timestamp, + userResponse: interaction.userResponse, + validation: interaction.validation, + errors: interaction.validation.grammarErrors || [], + strengths: interaction.validation.grammarStrengths || [] + }); + }); + } + + _buildProgressiveContext(session, interaction) { + const { type, content } = interaction; + const progressive = session.progressiveContext; + + // Handle text progression (sentence by sentence) + if (type === 'text') { + const textId = content.text?.id || `text_${content.textIndex || 0}`; + + if (progressive.currentText !== textId) { + // New text - reset accumulated sentences + progressive.currentText = textId; + progressive.accumulatedSentences = []; + } + + // Add current sentence to accumulation + if (content.sentence) { + progressive.accumulatedSentences.push({ + index: content.sentenceIndex || progressive.accumulatedSentences.length, + sentence: content.sentence, + userResponse: interaction.userResponse, + validation: interaction.validation, + timestamp: interaction.timestamp + }); + } + } + + // Handle dialog progression + if (type === 'dialog') { + const dialogId = content.dialog?.id || `dialog_${content.dialogIndex || 0}`; + + if (progressive.currentDialog !== dialogId) { + progressive.currentDialog = dialogId; + progressive.accumulatedDialogLines = []; + } + + if (content.line) { + progressive.accumulatedDialogLines.push({ + index: content.lineIndex || progressive.accumulatedDialogLines.length, + line: content.line, + userResponse: interaction.userResponse, + validation: interaction.validation, + timestamp: interaction.timestamp + }); + } + } + + // Track learning path + progressive.learningPath.push({ + type, + timestamp: interaction.timestamp, + success: interaction.validation.correct || false, + score: interaction.validation.score || 0 + }); + } + + _getRelevantHistory(session, exerciseType, content) { + // Get recent interactions of the same type + return session.interactions + .filter(interaction => interaction.type === exerciseType) + .slice(-10) // Last 10 interactions of this type + .map(interaction => ({ + timestamp: interaction.timestamp, + userResponse: interaction.userResponse, + validation: interaction.validation, + content: interaction.content + })); + } + + _getTypeSpecificContext(session, exerciseType, content) { + const contextBuilder = session.contextBuilders[exerciseType]; + if (!contextBuilder) return {}; + + // Return type-specific context based on current content + const context = {}; + + // Add relevant context based on exercise type and content + if (exerciseType === 'text' && session.progressiveContext.currentText) { + context.previousSentences = session.progressiveContext.accumulatedSentences; + } + + if (exerciseType === 'dialog' && session.progressiveContext.currentDialog) { + context.previousLines = session.progressiveContext.accumulatedDialogLines; + } + + return context; + } + + _maintainHistoryLimits(session) { + // Limit interaction history to prevent memory bloat + if (session.interactions.length > this.maxHistoryLength) { + session.interactions = session.interactions.slice(-this.maxHistoryLength); + } + + // Clean up old context builders + Object.values(session.contextBuilders).forEach(builder => { + if (builder instanceof Map) { + builder.forEach((value, key) => { + if (Array.isArray(value) && value.length > 20) { + // Keep only recent 20 entries per item + builder.set(key, value.slice(-20)); + } + }); + } + }); + + // Limit progressive context + if (session.progressiveContext.accumulatedSentences.length > 10) { + session.progressiveContext.accumulatedSentences = + session.progressiveContext.accumulatedSentences.slice(-10); + } + + if (session.progressiveContext.learningPath.length > 50) { + session.progressiveContext.learningPath = + session.progressiveContext.learningPath.slice(-50); + } + } + + /** + * Cleanup all sessions + */ + cleanup() { + this.sessions.clear(); + this.currentSessionId = null; + console.log('🧹 Context memory completely cleared'); + } +} + +export default ContextMemory; \ No newline at end of file diff --git a/src/DRS/services/IAEngine.js b/src/DRS/services/IAEngine.js new file mode 100644 index 0000000..a5aa486 --- /dev/null +++ b/src/DRS/services/IAEngine.js @@ -0,0 +1,745 @@ +/** + * IAEngine - Intelligence Artificielle Engine pour les exercices éducatifs + * Basé sur LLMManager.js mais adapté pour ES6 modules et contexte éducatif + */ + +class IAEngine { + constructor(config = {}) { + // Configuration par défaut + this.config = { + defaultProvider: config.defaultProvider || 'openai', + fallbackProviders: config.fallbackProviders || ['deepseek'], // OpenAI -> DeepSeek -> Disable + timeout: config.timeout || 30000, // 30 secondes + maxRetries: config.maxRetries || 3, + retryDelay: config.retryDelay || 1000, + enableFallback: config.enableFallback !== false, + debug: config.debug || true, // Enable debug par défaut pour voir le fallback + disableOnAllFailed: config.disableOnAllFailed !== false, // Désactive si tous échouent + ...config + }; + + // Configuration des providers LLM + this.providers = { + openai: { + endpoint: 'https://api.openai.com/v1/chat/completions', + model: 'gpt-4o-mini', + headers: { + 'Authorization': 'Bearer {API_KEY}', + 'Content-Type': 'application/json' + }, + temperature: 0.3, // Plus conservateur pour l'éducation + maxTokens: 1000, + timeout: 30000, + retries: 3 + }, + claude: { + endpoint: 'https://api.anthropic.com/v1/messages', + model: 'claude-3-haiku-20240307', + headers: { + 'x-api-key': '{API_KEY}', + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01' + }, + temperature: 0.3, + maxTokens: 1000, + timeout: 30000, + retries: 3 + }, + deepseek: { + endpoint: 'https://api.deepseek.com/v1/chat/completions', + model: 'deepseek-chat', + headers: { + 'Authorization': 'Bearer {API_KEY}', + 'Content-Type': 'application/json' + }, + temperature: 0.3, + maxTokens: 1000, + timeout: 30000, + retries: 3 + } + }; + + // Cache pour éviter les appels répétés + this.cache = new Map(); + this.stats = { + totalRequests: 0, + successfulRequests: 0, + failedRequests: 0, + cacheHits: 0, + providerUsage: {} + }; + + // Variables d'environnement simulées (en production, celles-ci viendraient du serveur) + this.apiKeys = null; + this.consecutiveErrors = 0; + this.maxConsecutiveErrors = 5; + + // État de disponibilité des providers + this.providerStatus = { + openai: { available: null, lastTested: null, consecutiveFailures: 0 }, + deepseek: { available: null, lastTested: null, consecutiveFailures: 0 } + }; + this.aiDisabled = false; // État global : IA complètement désactivée + this.disableReason = null; + + this._initializeApiKeys(); + } + + /** + * Initialise les clés API (simulation, en production elles viendraient du serveur) + */ + async _initializeApiKeys() { + // En développement, on peut simuler les clés API + // En production, ces clés devraient venir du serveur via un endpoint sécurisé + try { + // Essayer de récupérer les clés depuis le serveur + const response = await fetch('/api/llm-config', { + method: 'GET', + credentials: 'same-origin' + }); + + if (response.ok) { + this.apiKeys = await response.json(); + this._log('✅ API keys loaded from server'); + } else { + throw new Error('Server API keys not available'); + } + } catch (error) { + this._log('⚠️ Using mock mode - server keys not available'); + // En cas d'échec, utiliser le mode mock + this.apiKeys = { mock: true }; + } + } + + /** + * Appel principal à un LLM pour validation éducative + * @param {string} prompt - Le prompt éducatif à envoyer + * @param {Object} options - Options personnalisées + * @returns {Promise} - Réponse structurée + */ + async validateEducationalContent(prompt, options = {}) { + const startTime = Date.now(); + this.stats.totalRequests++; + + // Vérifier si l'IA est complètement désactivée + if (this.aiDisabled) { + this._log('🚫 AI system is disabled, using mock validation'); + this.stats.failedRequests++; + return this._generateMockValidation(prompt, options); + } + + // Vérification du cache + const cacheKey = this._generateCacheKey(prompt, options); + if (this.cache.has(cacheKey)) { + this.stats.cacheHits++; + this._log('📦 Cache hit for educational validation'); + return this.cache.get(cacheKey); + } + + // Ordre de fallback : OpenAI -> DeepSeek -> Disable + const providers = this._getProviderOrder(options.preferredProvider); + let lastError = null; + let allProvidersFailed = true; + + for (const provider of providers) { + // Vérifier si le provider est marqué comme non disponible + if (this.providerStatus[provider]?.available === false) { + this._log(`⏭️ Skipping ${provider.toUpperCase()} - marked as unavailable`); + continue; + } + + try { + this._log(`🤖 Attempting ${provider.toUpperCase()} for educational validation`); + + const result = await this._callProvider(provider, prompt, options); + + // Succès : mettre à jour le statut du provider + this.providerStatus[provider] = { + available: true, + lastTested: new Date().toISOString(), + consecutiveFailures: 0, + lastDuration: Date.now() - startTime + }; + + // Cache le résultat + this.cache.set(cacheKey, result); + + // Nettoyer le cache si il devient trop gros + if (this.cache.size > 1000) { + const firstKey = this.cache.keys().next().value; + this.cache.delete(firstKey); + } + + // Mettre à jour les stats + this.stats.successfulRequests++; + this.consecutiveErrors = 0; + this._updateProviderStats(provider, Date.now() - startTime, true); + allProvidersFailed = false; + + this._log(`✅ ${provider.toUpperCase()} educational validation successful (${Date.now() - startTime}ms)`); + + return result; + + } catch (error) { + lastError = error; + this.consecutiveErrors++; + this._updateProviderStats(provider, Date.now() - startTime, false); + + // Mettre à jour le statut d'échec du provider + this.providerStatus[provider].available = false; + this.providerStatus[provider].lastTested = new Date().toISOString(); + this.providerStatus[provider].consecutiveFailures = (this.providerStatus[provider].consecutiveFailures || 0) + 1; + this.providerStatus[provider].lastError = error.message; + + this._log(`❌ ${provider.toUpperCase()} failed: ${error.message}`); + + // Si pas de fallback activé, arrêter + if (!this.config.enableFallback) { + break; + } + } + } + + // Tous les providers ont échoué + this.stats.failedRequests++; + + // Si tous les providers configurés ont échoué, désactiver l'IA + if (allProvidersFailed && this.config.disableOnAllFailed) { + this.aiDisabled = true; + this.disableReason = 'All providers failed during operation'; + this._log('🚫 All providers failed - AI system disabled'); + } + + // Basculer en mode mock + this._log('⚠️ All providers failed, switching to mock mode'); + return this._generateMockValidation(prompt, options); + } + + /** + * Appelle un provider spécifique + * @private + */ + async _callProvider(provider, prompt, options) { + // Si pas de clés API, utiliser le mode mock + if (!this.apiKeys || this.apiKeys.mock) { + return this._generateMockValidation(prompt, options); + } + + const config = this.providers[provider]; + if (!config) { + throw new Error(`Provider ${provider} not configured`); + } + + const apiKey = this.apiKeys[`${provider.toUpperCase()}_API_KEY`]; + if (!apiKey) { + throw new Error(`API key missing for ${provider}`); + } + + // Construire la requête + const requestData = this._buildRequest(provider, prompt, options); + + // Préparer les headers + const headers = {}; + Object.keys(config.headers).forEach(key => { + headers[key] = config.headers[key].replace('{API_KEY}', apiKey); + }); + + // Effectuer l'appel + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), config.timeout); + + try { + const response = await fetch(config.endpoint, { + method: 'POST', + headers, + body: JSON.stringify(requestData), + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + const responseData = await response.json(); + return this._parseResponse(provider, responseData); + + } catch (error) { + clearTimeout(timeoutId); + throw error; + } + } + + /** + * Construit la requête selon le format du provider + * @private + */ + _buildRequest(provider, prompt, options) { + const config = this.providers[provider]; + const temperature = options.temperature ?? config.temperature; + const maxTokens = options.maxTokens ?? config.maxTokens; + + // Prompt système pour l'éducation + const systemPrompt = options.systemPrompt || + 'You are an expert language learning evaluator. Provide constructive, encouraging feedback for students. Return responses in valid JSON format only.'; + + switch (provider) { + case 'openai': + case 'deepseek': + return { + model: config.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: prompt } + ], + max_tokens: maxTokens, + temperature: temperature, + stream: false + }; + + case 'claude': + return { + model: config.model, + max_tokens: maxTokens, + temperature: temperature, + system: systemPrompt, + messages: [ + { role: 'user', content: prompt } + ] + }; + + default: + throw new Error(`Request format not supported for ${provider}`); + } + } + + /** + * Parse la réponse selon le format du provider + * @private + */ + _parseResponse(provider, responseData) { + let content; + + try { + switch (provider) { + case 'openai': + case 'deepseek': + content = responseData.choices[0].message.content.trim(); + break; + + case 'claude': + content = responseData.content[0].text.trim(); + break; + + default: + throw new Error(`Parser not supported for ${provider}`); + } + + // Essayer de parser en JSON si possible + try { + const jsonResponse = JSON.parse(content); + return { + ...jsonResponse, + provider, + timestamp: new Date().toISOString(), + cached: false + }; + } catch (jsonError) { + // Si ce n'est pas du JSON valide, retourner comme texte + return { + content: content, + provider, + timestamp: new Date().toISOString(), + cached: false + }; + } + + } catch (error) { + this._log(`❌ Error parsing ${provider} response: ${error.message}`); + throw new Error(`Failed to parse ${provider} response: ${error.message}`); + } + } + + /** + * Génère une réponse mock réaliste + * @private + */ + _generateMockValidation(prompt, options) { + const mockResponses = { + translation: () => ({ + score: Math.floor(Math.random() * 40) + 60, // 60-100 + correct: Math.random() > 0.3, + feedback: "Good effort! Consider the nuance of the verb tense.", + keyPoints: ["vocabulary usage", "grammar structure"], + suggestions: ["Try to focus on the context", "Remember the word order rules"] + }), + comprehension: () => ({ + score: Math.floor(Math.random() * 30) + 70, // 70-100 + correct: Math.random() > 0.25, + feedback: "You understood the main idea well. Pay attention to details.", + mainPointsUnderstood: ["main topic", "key action"], + missedPoints: Math.random() > 0.7 ? ["time reference"] : [] + }), + grammar: () => ({ + score: Math.floor(Math.random() * 50) + 50, // 50-100 + correct: Math.random() > 0.4, + feedback: "Good sentence structure. Watch the word order.", + grammarErrors: Math.random() > 0.5 ? ["word order"] : [], + grammarStrengths: ["verb conjugation", "article usage"], + suggestion: Math.random() > 0.7 ? "Try: 'I wear a blue shirt to work.'" : null + }), + general: () => ({ + score: Math.floor(Math.random() * 40) + 60, + correct: Math.random() > 0.3, + feedback: "Keep practicing! You're making good progress.", + encouragement: "Don't give up, you're learning!" + }) + }; + + // Détecter le type d'exercice depuis le prompt + const exerciseType = this._detectExerciseType(prompt); + const responseGenerator = mockResponses[exerciseType] || mockResponses.general; + + const mockResponse = responseGenerator(); + + return { + ...mockResponse, + provider: 'mock', + timestamp: new Date().toISOString(), + cached: false, + mockGenerated: true + }; + } + + /** + * Détecte le type d'exercice depuis le prompt + * @private + */ + _detectExerciseType(prompt) { + const lowerPrompt = prompt.toLowerCase(); + + if (lowerPrompt.includes('translation') || lowerPrompt.includes('translate')) { + return 'translation'; + } + if (lowerPrompt.includes('grammar') || lowerPrompt.includes('grammatical')) { + return 'grammar'; + } + if (lowerPrompt.includes('comprehension') || lowerPrompt.includes('understand')) { + return 'comprehension'; + } + + return 'general'; + } + + /** + * Détermine l'ordre des providers à essayer (OpenAI -> DeepSeek seulement) + * @private + */ + _getProviderOrder(preferredProvider) { + // Ordre fixe : OpenAI -> DeepSeek seulement + const preferredOrder = ['openai', 'deepseek']; + + if (preferredProvider && preferredOrder.includes(preferredProvider)) { + // Commencer par le provider préféré, puis les autres dans l'ordre + return [preferredProvider, ...preferredOrder.filter(p => p !== preferredProvider)]; + } + + // Utiliser l'ordre par défaut : OpenAI -> DeepSeek + return preferredOrder; + } + + /** + * Génère une clé de cache + * @private + */ + _generateCacheKey(prompt, options) { + const keyData = { + prompt: prompt.substring(0, 100), // Première partie du prompt + temperature: options.temperature || 0.3, + type: this._detectExerciseType(prompt) + }; + return JSON.stringify(keyData); + } + + /** + * Met à jour les statistiques du provider + * @private + */ + _updateProviderStats(provider, duration, success) { + if (!this.stats.providerUsage[provider]) { + this.stats.providerUsage[provider] = { + calls: 0, + successes: 0, + failures: 0, + totalDuration: 0, + avgDuration: 0 + }; + } + + const stats = this.stats.providerUsage[provider]; + stats.calls++; + stats.totalDuration += duration; + stats.avgDuration = Math.round(stats.totalDuration / stats.calls); + + if (success) { + stats.successes++; + } else { + stats.failures++; + } + } + + /** + * Log avec contrôle de debug + * @private + */ + _log(message, level = 'INFO') { + if (this.config.debug || level === 'ERROR') { + console.log(`[IAEngine ${level}] ${message}`); + } + } + + /** + * API publiques utilitaires + */ + + /** + * Obtient les statistiques d'usage + */ + getStats() { + return { + ...this.stats, + cacheSize: this.cache.size, + consecutiveErrors: this.consecutiveErrors, + isInMockMode: this.consecutiveErrors >= this.maxConsecutiveErrors + }; + } + + /** + * Vide le cache + */ + clearCache() { + this.cache.clear(); + this._log('🧹 Cache cleared'); + } + + /** + * Reset les statistiques + */ + resetStats() { + this.stats = { + totalRequests: 0, + successfulRequests: 0, + failedRequests: 0, + cacheHits: 0, + providerUsage: {} + }; + this.consecutiveErrors = 0; + this._log('📊 Stats reset'); + } + + /** + * Test de connectivité spécifique à un provider + */ + async testProviderConnectivity(provider) { + if (!this.providers[provider]) { + throw new Error(`Provider ${provider} not configured`); + } + + const testPrompt = 'Test'; + const testOptions = { + maxTokens: 10, + temperature: 0, + timeout: 10000 // Test plus rapide + }; + + const startTime = Date.now(); + + try { + // Forcer l'utilisation du provider spécifique + const result = await this._callProvider(provider, testPrompt, testOptions); + + const duration = Date.now() - startTime; + + // Mettre à jour le statut + this.providerStatus[provider] = { + available: true, + lastTested: new Date().toISOString(), + consecutiveFailures: 0, + lastDuration: duration + }; + + this._log(`✅ ${provider.toUpperCase()} connectivity test successful (${duration}ms)`); + + return { + success: true, + provider: provider, + duration: duration, + response: result + }; + + } catch (error) { + const duration = Date.now() - startTime; + + // Mettre à jour le statut d'échec + this.providerStatus[provider].available = false; + this.providerStatus[provider].lastTested = new Date().toISOString(); + this.providerStatus[provider].consecutiveFailures++; + this.providerStatus[provider].lastError = error.message; + + this._log(`❌ ${provider.toUpperCase()} connectivity test failed: ${error.message} (${duration}ms)`); + + return { + success: false, + provider: provider, + duration: duration, + error: error.message + }; + } + } + + /** + * Test de connectivité global (tous les providers) + */ + async testAllProvidersConnectivity() { + this._log('🔍 Testing connectivity for all providers...'); + + const results = {}; + const testPromises = []; + + // Tester OpenAI et DeepSeek en parallèle + for (const provider of ['openai', 'deepseek']) { + testPromises.push( + this.testProviderConnectivity(provider) + .then(result => results[provider] = result) + .catch(error => results[provider] = { success: false, error: error.message }) + ); + } + + await Promise.allSettled(testPromises); + + // Déterminer l'état global + const availableProviders = Object.keys(results).filter(p => results[p].success); + + if (availableProviders.length === 0) { + this.aiDisabled = true; + this.disableReason = 'All providers failed connectivity test'; + this._log('🚫 All AI providers failed - AI system disabled'); + } else { + this.aiDisabled = false; + this.disableReason = null; + this._log(`✅ AI system available with providers: ${availableProviders.join(', ')}`); + } + + return { + results, + availableProviders, + aiDisabled: this.aiDisabled, + disableReason: this.disableReason, + providerStatus: this.providerStatus + }; + } + + /** + * Test de connectivité simplifié (pour compatibilité) + */ + async testConnectivity() { + const fullTest = await this.testAllProvidersConnectivity(); + + if (fullTest.availableProviders.length > 0) { + return { + success: true, + provider: fullTest.availableProviders[0], + availableProviders: fullTest.availableProviders, + providerStatus: this.providerStatus + }; + } else { + return { + success: false, + error: this.disableReason, + providerStatus: this.providerStatus, + stats: this.getStats() + }; + } + } + + /** + * Méthodes spécialisées pour l'éducation + */ + + /** + * Validation de traduction éducative + */ + async validateTranslation(original, userTranslation, context = {}) { + const prompt = `Evaluate this language learning translation: +- Original (English): "${original}" +- Student translation: "${userTranslation}" +- Context: ${context.exerciseType || 'vocabulary'} exercise +- Target language: ${context.targetLanguage || 'French/Chinese'} + +Evaluate if the translation captures the essential meaning. Be encouraging but accurate. +Return JSON: { + "score": 0-100, + "correct": boolean, + "feedback": "constructive feedback", + "keyPoints": ["important aspects noted"], + "encouragement": "positive reinforcement" +}`; + + return await this.validateEducationalContent(prompt, { + systemPrompt: 'You are a supportive language learning tutor. Always provide encouraging feedback.', + preferredProvider: 'openai' + }); + } + + /** + * Validation de compréhension audio/texte + */ + async validateComprehension(content, userResponse, context = {}) { + const prompt = `Evaluate comprehension: +- Content: "${content}" +- Student response: "${userResponse}" +- Exercise type: ${context.exerciseType || 'comprehension'} + +Did the student understand the main meaning? Accept paraphrasing. +Return JSON: { + "score": 0-100, + "correct": boolean, + "feedback": "constructive feedback", + "mainPointsUnderstood": ["concepts captured"], + "encouragement": "motivating message" +}`; + + return await this.validateEducationalContent(prompt, { + systemPrompt: 'You are a patient language teacher. Focus on understanding, not perfection.' + }); + } + + /** + * Validation grammaticale + */ + async validateGrammar(userResponse, context = {}) { + const prompt = `Evaluate grammar usage: +- Student response: "${userResponse}" +- Target concepts: ${JSON.stringify(context.grammarConcepts || {})} +- Language level: ${context.languageLevel || 'beginner'} + +Evaluate grammatical correctness and naturalness. Be encouraging. +Return JSON: { + "score": 0-100, + "correct": boolean, + "feedback": "constructive grammar feedback", + "grammarErrors": ["specific errors"], + "grammarStrengths": ["what was done well"], + "suggestion": "improvement suggestion", + "encouragement": "positive reinforcement" +}`; + + return await this.validateEducationalContent(prompt, { + systemPrompt: 'You are a grammar expert who focuses on helping students improve with kindness.' + }); + } +} + +export default IAEngine; \ No newline at end of file diff --git a/src/DRS/services/LLMValidator.js b/src/DRS/services/LLMValidator.js new file mode 100644 index 0000000..c69c61d --- /dev/null +++ b/src/DRS/services/LLMValidator.js @@ -0,0 +1,415 @@ +/** + * LLMValidator - LLM integration service for exercise validation + * Uses IAEngine for intelligent evaluation of all exercise types + */ + +import IAEngine from './IAEngine.js'; +import AIReportSystem from './AIReportSystem.js'; + +class LLMValidator { + constructor(config = {}) { + this.config = { + provider: config.provider || 'openai', + temperature: config.temperature || 0.3, + maxTokens: config.maxTokens || 1000, + timeout: config.timeout || 30000, + debug: config.debug || false, + enableReporting: config.enableReporting !== false, // Enabled by default + ...config + }; + + // Initialize the IAEngine + this.iaEngine = new IAEngine({ + defaultProvider: this.config.provider, + fallbackProviders: ['claude', 'deepseek'], + timeout: this.config.timeout, + debug: this.config.debug + }); + + // Initialize AI Report System + this.reportSystem = new AIReportSystem({ + includeTimestamps: true, + formatStyle: 'detailed', + language: 'fr', + includeScores: true + }); + + console.log('🧠 LLMValidator initialized with IAEngine and AI Report System'); + } + + /** + * Start a new AI report session + * @param {Object} sessionInfo - Session information + * @returns {string} - Session ID + */ + startReportSession(sessionInfo = {}) { + if (!this.config.enableReporting) { + return null; + } + return this.reportSystem.startSession(sessionInfo); + } + + /** + * End the current AI report session + */ + endReportSession() { + if (!this.config.enableReporting) { + return; + } + this.reportSystem.endSession(); + } + + /** + * Add AI response to the report system + * @private + */ + _addToReport(aiResponse, exerciseContext) { + if (!this.config.enableReporting) { + return; + } + this.reportSystem.addAIResponse(aiResponse, exerciseContext); + } + + /** + * Generate and export a report + * @param {string} format - Report format ('text', 'html', 'json') + * @param {string} filename - Optional filename + * @returns {string} - Download URL + */ + exportReport(format = 'text', filename = null) { + if (!this.config.enableReporting) { + throw new Error('Reporting is disabled'); + } + return this.reportSystem.exportReport(null, format, filename); + } + + /** + * Get a formatted report as string + * @param {string} format - Report format ('text', 'html', 'json') + * @returns {string} - Formatted report + */ + getReport(format = 'text') { + if (!this.config.enableReporting) { + throw new Error('Reporting is disabled'); + } + return this.reportSystem.generateReport(null, format); + } + + /** + * Validate translation exercise + * @param {string} originalText - Original text + * @param {string} userAnswer - User's translation + * @param {Object} context - Exercise context + * @returns {Promise} + */ + async validateTranslation(originalText, userAnswer, context = {}) { + try { + console.log(`🔍 Validating translation: "${originalText}" -> "${userAnswer}"`); + + const result = await this.iaEngine.validateTranslation(originalText, userAnswer, { + exerciseType: context.exerciseType || 'vocabulary', + targetLanguage: context.targetLanguage || 'French/Chinese', + contextHistory: context.contextHistory || '', + wordType: context.wordType || 'general' + }); + + // Add to AI report system + this._addToReport(result, { + exerciseType: 'vocabulary', + originalContent: originalText, + userAnswer: userAnswer, + difficulty: context.difficulty, + exerciseStep: context.exerciseStep + }); + + // Ensure backward compatibility with expected format + return this._normalizeResult(result, 'translation'); + + } catch (error) { + console.error('❌ Translation validation error:', error); + return this._generateFallbackResult('translation'); + } + } + + /** + * Validate audio comprehension + * @param {string} transcription - Audio transcription + * @param {string} userAnswer - User's comprehension response + * @param {Object} context - Exercise context + * @returns {Promise} + */ + async validateAudioComprehension(transcription, userAnswer, context = {}) { + try { + console.log(`🎵 Validating audio comprehension: "${transcription}" -> "${userAnswer}"`); + + const result = await this.iaEngine.validateComprehension(transcription, userAnswer, { + exerciseType: 'audio', + languageLevel: context.languageLevel || 'beginner' + }); + + // Add to AI report system + this._addToReport(result, { + exerciseType: 'audio', + originalContent: transcription, + userAnswer: userAnswer, + difficulty: context.difficulty, + exerciseStep: context.exerciseStep + }); + + return this._normalizeResult(result, 'audio'); + + } catch (error) { + console.error('❌ Audio comprehension validation error:', error); + return this._generateFallbackResult('audio'); + } + } + + /** + * Validate image description + * @param {string} userAnswer - User's description + * @param {Object} context - Exercise context with target vocabulary + * @returns {Promise} + */ + async validateImageDescription(userAnswer, context = {}) { + try { + console.log(`🖼️ Validating image description: "${userAnswer}"`); + + const prompt = `Evaluate image description: +- Student description: "${userAnswer}" +- Target vocabulary: ${JSON.stringify(context.targetVocabulary || {})} +- Exercise type: free description + +Evaluate vocabulary usage, accuracy, and creativity. +Return JSON: { + "score": 0-100, + "correct": boolean, + "feedback": "constructive feedback", + "vocabularyUsed": ["words from target vocabulary used"], + "creativityScore": 0-100 +}`; + + const result = await this.iaEngine.validateEducationalContent(prompt, { + preferredProvider: this.config.provider + }); + + return this._normalizeResult(result, 'image'); + + } catch (error) { + console.error('❌ Image description validation error:', error); + return this._generateFallbackResult('image'); + } + } + + /** + * Validate grammar exercise + * @param {string} userAnswer - User's grammar response + * @param {Object} context - Exercise context with grammar concepts + * @returns {Promise} + */ + async validateGrammar(userAnswer, context = {}) { + try { + console.log(`📝 Validating grammar: "${userAnswer}"`); + + const result = await this.iaEngine.validateGrammar(userAnswer, { + grammarConcepts: context.grammarConcepts || {}, + languageLevel: context.languageLevel || 'beginner' + }); + + // Add to AI report system + this._addToReport(result, { + exerciseType: 'grammar', + originalContent: context.grammarPrompt || '', + userAnswer: userAnswer, + difficulty: context.difficulty, + exerciseStep: context.exerciseStep + }); + + return this._normalizeResult(result, 'grammar'); + + } catch (error) { + console.error('❌ Grammar validation error:', error); + return this._generateFallbackResult('grammar'); + } + } + + /** + * Validate text comprehension + * @param {string} text - Original text + * @param {string} userAnswer - User's comprehension response + * @param {Object} context - Exercise context + * @returns {Promise} + */ + async validateTextComprehension(text, userAnswer, context = {}) { + try { + console.log(`📖 Validating text comprehension: "${text}" -> "${userAnswer}"`); + + const result = await this.iaEngine.validateComprehension(text, userAnswer, { + exerciseType: 'text', + contextHistory: context.contextHistory || '' + }); + + // Add to AI report system + this._addToReport(result, { + exerciseType: 'text', + originalContent: text, + userAnswer: userAnswer, + difficulty: context.difficulty, + exerciseStep: context.exerciseStep + }); + + return this._normalizeResult(result, 'text'); + + } catch (error) { + console.error('❌ Text comprehension validation error:', error); + return this._generateFallbackResult('text'); + } + } + + /** + * Normalize result to ensure backward compatibility + * @private + */ + _normalizeResult(result, exerciseType) { + // If result is already in the expected format, return as is + if (result.score !== undefined && result.correct !== undefined) { + return { + score: result.score, + correct: result.correct, + feedback: result.feedback || result.encouragement || 'Good effort!', + timestamp: result.timestamp || new Date().toISOString(), + provider: result.provider || 'unknown', + cached: result.cached || false, + + // Include exercise-specific fields + ...(exerciseType === 'translation' && { + keyPoints: result.keyPoints || [], + suggestions: result.suggestions || [] + }), + ...(exerciseType === 'audio' && { + mainPointsUnderstood: result.mainPointsUnderstood || [], + missedPoints: result.missedPoints || [] + }), + ...(exerciseType === 'image' && { + vocabularyUsed: result.vocabularyUsed || [], + creativityScore: result.creativityScore || 70 + }), + ...(exerciseType === 'grammar' && { + grammarErrors: result.grammarErrors || [], + grammarStrengths: result.grammarStrengths || [], + suggestion: result.suggestion || null + }), + ...(exerciseType === 'text' && { + keyConceptsUnderstood: result.keyConceptsUnderstood || [], + missedPoints: result.missedPoints || [] + }) + }; + } + + // If result has unexpected format, try to extract content + return { + score: 75, // Default score + correct: true, + feedback: result.content || result.feedback || 'Response received', + timestamp: new Date().toISOString(), + provider: result.provider || 'unknown', + cached: false + }; + } + + /** + * Generate fallback result when validation fails + * @private + */ + _generateFallbackResult(exerciseType) { + const fallbackResponses = { + translation: { + score: Math.floor(Math.random() * 40) + 60, + correct: Math.random() > 0.3, + feedback: "Good effort! Keep practicing your translations.", + keyPoints: ["vocabulary usage", "meaning accuracy"], + suggestions: ["Focus on context", "Review similar words"] + }, + audio: { + score: Math.floor(Math.random() * 30) + 70, + correct: Math.random() > 0.25, + feedback: "You captured the main idea. Work on details.", + mainPointsUnderstood: ["main topic"], + missedPoints: ["specific details"] + }, + image: { + score: Math.floor(Math.random() * 35) + 65, + correct: Math.random() > 0.2, + feedback: "Creative description! Try using more target vocabulary.", + vocabularyUsed: ["basic", "color"], + creativityScore: Math.floor(Math.random() * 30) + 70 + }, + grammar: { + score: Math.floor(Math.random() * 50) + 50, + correct: Math.random() > 0.4, + feedback: "Good attempt. Review the grammar rule.", + grammarErrors: Math.random() > 0.5 ? ["word order"] : [], + grammarStrengths: ["basic structure"], + suggestion: "Practice with similar examples" + }, + text: { + score: Math.floor(Math.random() * 40) + 60, + correct: Math.random() > 0.3, + feedback: "You understood the general meaning well.", + keyConceptsUnderstood: ["main idea"], + missedPoints: ["some details"] + } + }; + + const fallback = fallbackResponses[exerciseType] || fallbackResponses.translation; + + return { + ...fallback, + timestamp: new Date().toISOString(), + provider: 'fallback', + cached: false, + fallbackGenerated: true + }; + } + + /** + * Get validation statistics + */ + getStats() { + return this.iaEngine.getStats(); + } + + /** + * Clear cache + */ + clearCache() { + this.iaEngine.clearCache(); + console.log('🧹 LLMValidator cache cleared'); + } + + /** + * Test connectivity + */ + async testConnectivity() { + try { + const testResult = await this.iaEngine.testConnectivity(); + console.log('🧪 LLMValidator connectivity test:', testResult.success ? '✅ OK' : '❌ Failed'); + return testResult; + } catch (error) { + console.error('❌ LLMValidator connectivity test failed:', error); + return { + success: false, + error: error.message + }; + } + } + + /** + * Cleanup resources + */ + cleanup() { + this.clearCache(); + console.log('🧹 LLMValidator cleaned up'); + } +} + +export default LLMValidator; \ No newline at end of file diff --git a/src/DRS/services/PrerequisiteEngine.js b/src/DRS/services/PrerequisiteEngine.js new file mode 100644 index 0000000..8011302 --- /dev/null +++ b/src/DRS/services/PrerequisiteEngine.js @@ -0,0 +1,460 @@ +/** + * PrerequisiteEngine - Dependency tracking and content filtering service + * Manages vocabulary prerequisites and unlocks content based on mastery + */ + +class PrerequisiteEngine { + constructor() { + this.chapterVocabulary = new Set(); + this.masteredWords = new Set(); + this.masteredPhrases = new Set(); + this.masteredGrammar = new Set(); + this.contentAnalysis = null; + + // Basic words assumed to be known (no need to learn) + this.assumedKnown = new Set([ + // Articles and determiners + 'a', 'an', 'the', 'this', 'that', 'these', 'those', 'some', 'any', 'each', 'every', + + // Pronouns + 'I', 'you', 'he', 'she', 'it', 'we', 'they', 'me', 'him', 'her', 'us', 'them', + 'my', 'your', 'his', 'her', 'its', 'our', 'their', 'mine', 'yours', 'hers', 'ours', 'theirs', + + // Prepositions + 'in', 'on', 'at', 'by', 'for', 'with', 'to', 'from', 'of', 'about', 'under', 'over', + 'through', 'during', 'before', 'after', 'above', 'below', 'up', 'down', 'out', 'off', + + // Common verbs + 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', + 'will', 'would', 'could', 'should', 'can', 'may', 'might', 'must', + + // Conjunctions + 'and', 'or', 'but', 'because', 'if', 'when', 'where', 'how', 'why', 'what', 'who', 'which', + + // Common adverbs + 'not', 'no', 'yes', 'very', 'so', 'too', 'also', 'only', 'just', 'still', 'even', + + // Numbers + 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', + 'first', 'second', 'third', 'last', 'next' + ]); + } + + /** + * Analyze chapter content and extract vocabulary requirements + * @param {Object} chapterContent - Full chapter content data + * @returns {Object} - Analysis results + */ + analyzeChapter(chapterContent) { + console.log('📊 Analyzing chapter prerequisites...'); + + // Extract chapter vocabulary + this.chapterVocabulary.clear(); + if (chapterContent.vocabulary) { + Object.keys(chapterContent.vocabulary).forEach(word => { + this.chapterVocabulary.add(word.toLowerCase()); + }); + } + + // Analyze all content types + const analysis = { + totalVocabulary: this.chapterVocabulary.size, + phrases: this._analyzePhrases(chapterContent.phrases || {}), + texts: this._analyzeTexts(chapterContent.texts || []), + dialogs: this._analyzeDialogs(chapterContent.dialogs || []), + audio: this._analyzeAudio(chapterContent.audio || []), + images: this._analyzeImages(chapterContent.images || []), + grammar: this._analyzeGrammar(chapterContent.grammar || {}), + vocabularyCategories: this._categorizeVocabulary(chapterContent.vocabulary || {}) + }; + + this.contentAnalysis = analysis; + + console.log('✅ Chapter analysis complete:', analysis); + return analysis; + } + + /** + * Check if content can be unlocked based on current mastery + * @param {string} contentType - Type of content (phrase, text, audio, etc.) + * @param {Object} contentItem - Specific content item + * @returns {Object} - Unlock status and missing prerequisites + */ + canUnlock(contentType, contentItem) { + const requiredWords = this.getPrerequisites(contentType, contentItem); + const missingWords = requiredWords.filter(word => !this.isMastered(word)); + + return { + canUnlock: missingWords.length === 0, + requiredWords, + missingWords, + masteredCount: requiredWords.length - missingWords.length, + totalRequired: requiredWords.length + }; + } + + /** + * Get vocabulary prerequisites for specific content + * @param {string} contentType - Type of content + * @param {Object} contentItem - Content item + * @returns {Array} - Required vocabulary words + */ + getPrerequisites(contentType, contentItem) { + let text = ''; + + // Extract text based on content type + switch (contentType) { + case 'phrase': + text = contentItem.english || contentItem.text || ''; + break; + case 'text': + text = contentItem.content || contentItem.text || ''; + break; + case 'dialog': + text = contentItem.conversation ? + contentItem.conversation.map(line => line.english || line.text || '').join(' ') : ''; + break; + case 'audio': + text = contentItem.transcription || contentItem.text || ''; + break; + case 'grammar': + text = contentItem.example || contentItem.sentence || ''; + break; + default: + text = JSON.stringify(contentItem); + } + + return this._extractPrerequisites(text); + } + + /** + * Mark vocabulary as mastered + * @param {string} word - Word to mark as mastered + * @param {Object} metadata - Additional metadata (attempts, sessionId, etc.) + */ + markWordMastered(word, metadata = {}) { + const normalizedWord = word.toLowerCase(); + if (this.chapterVocabulary.has(normalizedWord)) { + const masteredEntry = { + word: normalizedWord, + masteredAt: new Date().toISOString(), + attempts: 1, + ...metadata + }; + this.masteredWords.add(normalizedWord); + console.log(`✅ Word mastered: ${word} at ${masteredEntry.masteredAt}`); + } + } + + /** + * Mark phrase as mastered + * @param {string} phraseId - Phrase identifier to mark as mastered + * @param {Object} metadata - Additional metadata (attempts, sessionId, etc.) + */ + markPhraseMastered(phraseId, metadata = {}) { + const masteredEntry = { + phrase: phraseId, + masteredAt: new Date().toISOString(), + attempts: 1, + ...metadata + }; + this.masteredPhrases.add(phraseId); + console.log(`✅ Phrase mastered: ${phraseId} at ${masteredEntry.masteredAt}`); + } + + /** + * Mark grammar concept as mastered + * @param {string} grammarConcept - Grammar concept to mark as mastered + * @param {Object} metadata - Additional metadata (attempts, sessionId, etc.) + */ + markGrammarMastered(grammarConcept, metadata = {}) { + const masteredEntry = { + concept: grammarConcept, + masteredAt: new Date().toISOString(), + attempts: 1, + ...metadata + }; + this.masteredGrammar.add(grammarConcept); + console.log(`✅ Grammar concept mastered: ${grammarConcept} at ${masteredEntry.masteredAt}`); + } + + /** + * Check if a word is considered mastered + * @param {string} word - Word to check + * @returns {boolean} - True if mastered or assumed known + */ + isMastered(word) { + const normalizedWord = word.toLowerCase(); + return this.assumedKnown.has(normalizedWord) || + this.masteredWords.has(normalizedWord) || + !this.chapterVocabulary.has(normalizedWord); // Non-chapter words assumed known + } + + /** + * Get available content based on current mastery + * @param {string} contentType - Type of content to check + * @param {Array} contentList - List of content items + * @returns {Array} - Available content items with unlock status + */ + getAvailableContent(contentType, contentList) { + return contentList.map((item, index) => { + const unlockStatus = this.canUnlock(contentType, item); + return { + ...item, + index, + unlockStatus, + available: unlockStatus.canUnlock + }; + }); + } + + /** + * Get mastery progress statistics + * @returns {Object} - Progress statistics + */ + getMasteryProgress() { + return { + vocabulary: { + total: this.chapterVocabulary.size, + mastered: this.masteredWords.size, + percentage: Math.round((this.masteredWords.size / Math.max(this.chapterVocabulary.size, 1)) * 100) + }, + phrases: { + total: this.contentAnalysis?.phrases.total || 0, + mastered: this.masteredPhrases.size, + percentage: Math.round((this.masteredPhrases.size / Math.max(this.contentAnalysis?.phrases.total || 1, 1)) * 100) + }, + grammar: { + total: this.contentAnalysis?.grammar.concepts || 0, + mastered: this.masteredGrammar.size, + percentage: Math.round((this.masteredGrammar.size / Math.max(this.contentAnalysis?.grammar.concepts || 1, 1)) * 100) + } + }; + } + + // Private Methods + + _extractPrerequisites(text) { + if (!text) return []; + + // Extract words and filter for chapter vocabulary only + const words = text.toLowerCase() + .replace(/[^\w\s]/g, ' ') // Remove punctuation + .split(/\s+/) + .filter(word => word.length > 0) + .map(word => this._getBaseForm(word)); + + // Return only words that are in chapter vocabulary (prerequisites) + return [...new Set(words)].filter(word => + this.chapterVocabulary.has(word) && + !this.assumedKnown.has(word) + ); + } + + _getBaseForm(word) { + // Simple stemming - can be enhanced with proper stemming library + const normalizedWord = word.toLowerCase(); + + // Remove common suffixes to find base form + const suffixes = ['s', 'es', 'ed', 'ing', 'ly', 'er', 'est']; + + for (const suffix of suffixes) { + if (normalizedWord.endsWith(suffix) && normalizedWord.length > suffix.length + 2) { + const baseForm = normalizedWord.slice(0, -suffix.length); + if (this.chapterVocabulary.has(baseForm)) { + return baseForm; + } + } + } + + return normalizedWord; + } + + _analyzePhrases(phrases) { + // Convert object to array of entries + const phraseEntries = Object.entries(phrases); + + const analysis = { + total: phraseEntries.length, + withPrerequisites: 0, + averagePrerequisites: 0, + categories: {} + }; + + let totalPrereqs = 0; + phraseEntries.forEach(([phraseText, phraseData]) => { + // Create phrase object with english text for getPrerequisites + const phraseObj = { + english: phraseText, + translation: phraseData.user_language, + context: phraseData.context || 'general', + ...phraseData + }; + + const prereqs = this.getPrerequisites('phrase', phraseObj); + if (prereqs.length > 0) { + analysis.withPrerequisites++; + } + totalPrereqs += prereqs.length; + + // Categorize phrases + const category = phraseData.context || 'general'; + analysis.categories[category] = (analysis.categories[category] || 0) + 1; + }); + + analysis.averagePrerequisites = Math.round((totalPrereqs / Math.max(phraseEntries.length, 1)) * 100) / 100; + return analysis; + } + + _analyzeTexts(texts) { + // Convert object to array if needed + const textArray = Array.isArray(texts) ? texts : Object.values(texts); + + const analysis = { + total: textArray.length, + totalSentences: 0, + averagePrerequisites: 0, + categories: {} + }; + + let totalPrereqs = 0; + textArray.forEach(text => { + const prereqs = this.getPrerequisites('text', text); + totalPrereqs += prereqs.length; + + // Count sentences + const sentences = (text.content || text.text || '').split(/[.!?]+/).filter(s => s.trim().length > 0); + analysis.totalSentences += sentences.length; + + // Categorize texts + const category = text.category || text.type || 'general'; + analysis.categories[category] = (analysis.categories[category] || 0) + 1; + }); + + analysis.averagePrerequisites = Math.round((totalPrereqs / Math.max(textArray.length, 1)) * 100) / 100; + return analysis; + } + + _analyzeDialogs(dialogs) { + // Convert object to array of entries if needed + const dialogEntries = Array.isArray(dialogs) ? dialogs : Object.entries(dialogs); + + const analysis = { + total: dialogEntries.length, + totalLines: 0, + averagePrerequisites: 0 + }; + + let totalPrereqs = 0; + dialogEntries.forEach(dialogItem => { + // Handle both array format and object format + const dialog = Array.isArray(dialogs) ? dialogItem : dialogItem[1]; + + const prereqs = this.getPrerequisites('dialog', dialog); + totalPrereqs += prereqs.length; + + if (dialog.conversation) { + analysis.totalLines += dialog.conversation.length; + } + }); + + analysis.averagePrerequisites = Math.round((totalPrereqs / Math.max(dialogEntries.length, 1)) * 100) / 100; + return analysis; + } + + _analyzeAudio(audioItems) { + // Convert object to array if needed + const audioArray = Array.isArray(audioItems) ? audioItems : Object.values(audioItems); + + return { + total: audioArray.length, + withTranscription: audioArray.filter(item => item.transcription).length, + categories: audioArray.reduce((cats, item) => { + const cat = item.type || 'general'; + cats[cat] = (cats[cat] || 0) + 1; + return cats; + }, {}) + }; + } + + _analyzeImages(imageItems) { + // Convert object to array if needed + const imageArray = Array.isArray(imageItems) ? imageItems : Object.values(imageItems); + + return { + total: imageArray.length, + withDescriptions: imageArray.filter(item => item.description).length, + categories: imageArray.reduce((cats, item) => { + const cat = item.category || item.type || 'general'; + cats[cat] = (cats[cat] || 0) + 1; + return cats; + }, {}) + }; + } + + _analyzeGrammar(grammarData) { + const concepts = Object.keys(grammarData); + return { + concepts: concepts.length, + conceptList: concepts, + categories: concepts.reduce((cats, concept) => { + const cat = grammarData[concept]?.category || 'general'; + cats[cat] = (cats[cat] || 0) + 1; + return cats; + }, {}) + }; + } + + _categorizeVocabulary(vocabulary) { + const categories = {}; + + Object.entries(vocabulary).forEach(([word, data]) => { + const type = data.type || 'unknown'; + categories[type] = (categories[type] || 0) + 1; + }); + + return categories; + } + + /** + * Reset all mastery tracking + */ + reset() { + this.masteredWords.clear(); + this.masteredPhrases.clear(); + this.masteredGrammar.clear(); + console.log('🔄 Prerequisites reset'); + } + + /** + * Export mastery state for persistence + * @returns {Object} - Serializable mastery state + */ + exportMasteryState() { + return { + masteredWords: Array.from(this.masteredWords), + masteredPhrases: Array.from(this.masteredPhrases), + masteredGrammar: Array.from(this.masteredGrammar), + timestamp: new Date().toISOString() + }; + } + + /** + * Import mastery state from persistence + * @param {Object} state - Previously exported mastery state + */ + importMasteryState(state) { + if (state.masteredWords) { + this.masteredWords = new Set(state.masteredWords); + } + if (state.masteredPhrases) { + this.masteredPhrases = new Set(state.masteredPhrases); + } + if (state.masteredGrammar) { + this.masteredGrammar = new Set(state.masteredGrammar); + } + console.log('📥 Mastery state imported'); + } +} + +export default PrerequisiteEngine; \ No newline at end of file diff --git a/src/content/sbs-level-7-8.json b/src/chapters/chapter-7-8.json similarity index 69% rename from src/content/sbs-level-7-8.json rename to src/chapters/chapter-7-8.json index b03c7e3..bf4db51 100644 --- a/src/content/sbs-level-7-8.json +++ b/src/chapters/chapter-7-8.json @@ -1,8 +1,13 @@ { - "name": "SBS Level 7-8 New", - "description": "Side by Side Level 7-8 vocabulary with language-agnostic format", - "difficulty": "intermediate", - "language": "en-US", + "chapter_info": { + "chapter_number": "7-8", + "book_reference": "SBS", + "completion_criteria": { + "vocabulary_mastery": 80, + "quiz_score": 75, + "games_completed": 5 + } + }, "vocabulary": { "central": { "user_language": "中心的;中央的", "type": "adjective" }, "avenue": { "user_language": "大街;林荫道", "type": "noun" }, @@ -137,5 +142,93 @@ "block": { "user_language": "屏蔽", "type": "verb" }, "tag": { "user_language": "标记", "type": "verb" } }, - "sentences": [] + "content_structure": { + "vocabulary_sections": [ + { + "section_id": "housing", + "title": "Housing & Living", + "words": ["central", "avenue", "building", "elevator", "superintendent", "bus stop", "jacuzzi", "machine", "town", "noise", "sidewalks", "convenient"] + }, + { + "section_id": "clothing", + "title": "Clothing & Accessories", + "words": ["shirt", "coat", "dress", "skirt", "blouse", "jacket", "sweater", "suit", "tie", "pants", "jeans", "belt", "hat", "glove", "purse", "glasses", "pajamas", "socks", "shoes", "bathrobe", "tee shirt", "scarf", "wallet", "ring", "sandals"] + }, + { + "section_id": "body", + "title": "Body Parts & Health", + "words": ["throat", "shoulder", "chest", "back", "arm", "elbow", "wrist", "hip", "thigh", "knee", "shin", "ankle", "cough", "sneeze", "wheeze", "feel dizzy", "feel nauseous", "twist", "burn", "hurt", "cut", "sprain", "dislocate", "break"] + }, + { + "section_id": "emotions", + "title": "Emotions & Feelings", + "words": ["upset", "worried", "concerned", "anxious", "nervous", "excited", "thrilled", "delighted", "pleased", "satisfied", "disappointed", "frustrated", "annoyed", "furious", "exhausted", "overwhelmed", "confused", "embarrassed", "proud", "jealous", "guilty"] + }, + { + "section_id": "communication", + "title": "Communication & Actions", + "words": ["recommend", "suggest", "insist", "warn", "promise", "apologize", "complain", "discuss", "argue", "disagree", "agree", "decide", "choose", "prefer", "enjoy", "appreciate", "celebrate", "congratulate"] + }, + { + "section_id": "technology", + "title": "Technology & Digital", + "words": ["website", "password", "username", "download", "upload", "install", "update", "delete", "save", "print", "scan", "copy", "paste", "search", "browse", "surf", "stream", "tweet", "post", "share", "like", "follow", "unfollow", "block", "tag"] + } + ] + }, + "assessment": { + "vocabulary_quizzes": [ + { + "quiz_id": "housing_basic", + "section": "housing", + "difficulty": "beginner", + "question_count": 12, + "pass_score": 70 + }, + { + "quiz_id": "emotions_advanced", + "section": "emotions", + "difficulty": "advanced", + "question_count": 21, + "pass_score": 80 + } + ], + "practical_exercises": [ + { + "exercise_id": "clothing_conversation", + "type": "role_play", + "scenario": "Shopping for clothes", + "required_vocabulary": ["shirt", "coat", "dress", "suit", "jacket"] + }, + { + "exercise_id": "tech_tutorial", + "type": "guided_practice", + "scenario": "Using social media", + "required_vocabulary": ["post", "share", "like", "follow", "tag"] + } + ] + }, + "sentences": [ + { + "id": "housing_01", + "text": "The apartment building is in the center of town.", + "vocabulary_used": ["building", "central", "town"], + "difficulty": "beginner", + "audio": "housing_01.mp3" + }, + { + "id": "emotions_01", + "text": "I feel anxious and overwhelmed about the presentation.", + "vocabulary_used": ["anxious", "overwhelmed"], + "difficulty": "intermediate", + "audio": "emotions_01.mp3" + }, + { + "id": "tech_01", + "text": "Don't forget to download the app and create your username.", + "vocabulary_used": ["download", "username"], + "difficulty": "intermediate", + "audio": "tech_01.mp3" + } + ] } \ No newline at end of file diff --git a/src/chapters/sbs-copy.json b/src/chapters/sbs-copy.json new file mode 100644 index 0000000..fc64117 --- /dev/null +++ b/src/chapters/sbs-copy.json @@ -0,0 +1,268 @@ +{ + "name": "SBS", + "description": "Side by Side Level 7-8 vocabulary with language-agnostic format", + "difficulty": "intermediate", + "language": "en-US", + "metadata": { + "version": "1.0", + "created": "2025-09-23", + "updated": "2025-09-23", + "source": "Side by Side English Learning Series", + "target_level": "intermediate", + "estimated_hours": 25, + "prerequisites": ["basic-english"], + "learning_objectives": [ + "Master intermediate vocabulary for daily situations", + "Understand clothing and body parts terminology", + "Learn emotional expressions and feelings", + "Practice technology and social media vocabulary" + ], + "content_tags": ["vocabulary", "daily-life", "practical-english", "conversational"], + "chapter_info": { + "chapter_number": "7-8", + "total_chapters": 12, + "completion_criteria": { + "vocabulary_mastery": 80, + "quiz_score": 75, + "games_completed": 5 + } + } + }, + "vocabulary": { + "central": { "user_language": "中心的;中央的", "type": "adjective" }, + "avenue": { "user_language": "大街;林荫道", "type": "noun" }, + "refrigerator": { "user_language": "冰箱", "type": "noun" }, + "closet": { "user_language": "衣柜;壁橱", "type": "noun" }, + "elevator": { "user_language": "电梯", "type": "noun" }, + "building": { "user_language": "建筑物;大楼", "type": "noun" }, + "air conditioner": { "user_language": "空调", "type": "noun" }, + "superintendent": { "user_language": "主管;负责人", "type": "noun" }, + "bus stop": { "user_language": "公交车站", "type": "noun" }, + "jacuzzi": { "user_language": "按摩浴缸", "type": "noun" }, + "machine": { "user_language": "机器;设备", "type": "noun" }, + "two and a half": { "user_language": "两个半", "type": "number" }, + "in the center of": { "user_language": "在……中心", "type": "preposition" }, + "town": { "user_language": "城镇", "type": "noun" }, + "a lot of": { "user_language": "许多", "type": "determiner" }, + "noise": { "user_language": "噪音", "type": "noun" }, + "sidewalks": { "user_language": "人行道", "type": "noun" }, + "all day and all night": { "user_language": "整日整夜", "type": "adverb" }, + "convenient": { "user_language": "便利的", "type": "adjective" }, + "upset": { "user_language": "失望的", "type": "adjective" }, + "shirt": { "user_language": "衬衫", "type": "noun" }, + "coat": { "user_language": "外套、大衣", "type": "noun" }, + "dress": { "user_language": "连衣裙", "type": "noun" }, + "skirt": { "user_language": "短裙", "type": "noun" }, + "blouse": { "user_language": "女式衬衫", "type": "noun" }, + "jacket": { "user_language": "夹克、短外套", "type": "noun" }, + "sweater": { "user_language": "毛衣、针织衫", "type": "noun" }, + "suit": { "user_language": "套装、西装", "type": "noun" }, + "tie": { "user_language": "领带", "type": "noun" }, + "pants": { "user_language": "裤子", "type": "noun" }, + "jeans": { "user_language": "牛仔裤", "type": "noun" }, + "belt": { "user_language": "腰带、皮带", "type": "noun" }, + "hat": { "user_language": "帽子", "type": "noun" }, + "glove": { "user_language": "手套", "type": "noun" }, + "purse": { "user_language": "手提包、女式小包", "type": "noun" }, + "glasses": { "user_language": "眼镜", "type": "noun" }, + "pajamas": { "user_language": "睡衣", "type": "noun" }, + "socks": { "user_language": "袜子", "type": "noun" }, + "shoes": { "user_language": "鞋子", "type": "noun" }, + "bathrobe": { "user_language": "浴袍", "type": "noun" }, + "tee shirt": { "user_language": "T恤", "type": "noun" }, + "scarf": { "user_language": "围巾", "type": "noun" }, + "wallet": { "user_language": "钱包", "type": "noun" }, + "ring": { "user_language": "戒指", "type": "noun" }, + "sandals": { "user_language": "凉鞋", "type": "noun" }, + "throat": { "user_language": "喉咙", "type": "noun" }, + "shoulder": { "user_language": "肩膀", "type": "noun" }, + "chest": { "user_language": "胸部", "type": "noun" }, + "back": { "user_language": "背部", "type": "noun" }, + "arm": { "user_language": "手臂", "type": "noun" }, + "elbow": { "user_language": "肘部", "type": "noun" }, + "wrist": { "user_language": "手腕", "type": "noun" }, + "hip": { "user_language": "髋部", "type": "noun" }, + "thigh": { "user_language": "大腿", "type": "noun" }, + "knee": { "user_language": "膝盖", "type": "noun" }, + "shin": { "user_language": "胫骨", "type": "noun" }, + "ankle": { "user_language": "脚踝", "type": "noun" }, + "cough": { "user_language": "咳嗽", "type": "verb" }, + "sneeze": { "user_language": "打喷嚏", "type": "verb" }, + "wheeze": { "user_language": "喘息", "type": "verb" }, + "feel dizzy": { "user_language": "感到头晕", "type": "verb" }, + "feel nauseous": { "user_language": "感到恶心", "type": "verb" }, + "twist": { "user_language": "扭伤", "type": "verb" }, + "burn": { "user_language": "烧伤", "type": "verb" }, + "hurt": { "user_language": "受伤", "type": "verb" }, + "cut": { "user_language": "割伤", "type": "verb" }, + "sprain": { "user_language": "扭伤", "type": "verb" }, + "dislocate": { "user_language": "脱臼", "type": "verb" }, + "break": { "user_language": "骨折", "type": "verb" }, + "recommend": { "user_language": "推荐", "type": "verb" }, + "suggest": { "user_language": "建议", "type": "verb" }, + "insist": { "user_language": "坚持", "type": "verb" }, + "warn": { "user_language": "警告", "type": "verb" }, + "promise": { "user_language": "承诺", "type": "verb" }, + "apologize": { "user_language": "道歉", "type": "verb" }, + "complain": { "user_language": "抱怨", "type": "verb" }, + "discuss": { "user_language": "讨论", "type": "verb" }, + "argue": { "user_language": "争论", "type": "verb" }, + "disagree": { "user_language": "不同意", "type": "verb" }, + "agree": { "user_language": "同意", "type": "verb" }, + "decide": { "user_language": "决定", "type": "verb" }, + "choose": { "user_language": "选择", "type": "verb" }, + "prefer": { "user_language": "偏爱", "type": "verb" }, + "enjoy": { "user_language": "享受", "type": "verb" }, + "appreciate": { "user_language": "欣赏", "type": "verb" }, + "celebrate": { "user_language": "庆祝", "type": "verb" }, + "congratulate": { "user_language": "祝贺", "type": "verb" }, + "worried": { "user_language": "担心的", "type": "adjective" }, + "concerned": { "user_language": "关心的", "type": "adjective" }, + "anxious": { "user_language": "焦虑的", "type": "adjective" }, + "nervous": { "user_language": "紧张的", "type": "adjective" }, + "excited": { "user_language": "兴奋的", "type": "adjective" }, + "thrilled": { "user_language": "激动的", "type": "adjective" }, + "delighted": { "user_language": "高兴的", "type": "adjective" }, + "pleased": { "user_language": "满意的", "type": "adjective" }, + "satisfied": { "user_language": "满足的", "type": "adjective" }, + "disappointed": { "user_language": "失望的", "type": "adjective" }, + "frustrated": { "user_language": "沮丧的", "type": "adjective" }, + "annoyed": { "user_language": "恼怒的", "type": "adjective" }, + "furious": { "user_language": "愤怒的", "type": "adjective" }, + "exhausted": { "user_language": "筋疲力尽的", "type": "adjective" }, + "overwhelmed": { "user_language": "不知所措的", "type": "adjective" }, + "confused": { "user_language": "困惑的", "type": "adjective" }, + "embarrassed": { "user_language": "尴尬的", "type": "adjective" }, + "proud": { "user_language": "自豪的", "type": "adjective" }, + "jealous": { "user_language": "嫉妒的", "type": "adjective" }, + "guilty": { "user_language": "内疚的", "type": "adjective" }, + "website": { "user_language": "网站", "type": "noun" }, + "password": { "user_language": "密码", "type": "noun" }, + "username": { "user_language": "用户名", "type": "noun" }, + "download": { "user_language": "下载", "type": "verb" }, + "upload": { "user_language": "上传", "type": "verb" }, + "install": { "user_language": "安装", "type": "verb" }, + "update": { "user_language": "更新", "type": "verb" }, + "delete": { "user_language": "删除", "type": "verb" }, + "save": { "user_language": "保存", "type": "verb" }, + "print": { "user_language": "打印", "type": "verb" }, + "scan": { "user_language": "扫描", "type": "verb" }, + "copy": { "user_language": "复制", "type": "verb" }, + "paste": { "user_language": "粘贴", "type": "verb" }, + "search": { "user_language": "搜索", "type": "verb" }, + "browse": { "user_language": "浏览", "type": "verb" }, + "surf": { "user_language": "网上冲浪", "type": "verb" }, + "stream": { "user_language": "流媒体", "type": "verb" }, + "tweet": { "user_language": "发推特", "type": "verb" }, + "post": { "user_language": "发布", "type": "verb" }, + "share": { "user_language": "分享", "type": "verb" }, + "like": { "user_language": "点赞", "type": "verb" }, + "follow": { "user_language": "关注", "type": "verb" }, + "unfollow": { "user_language": "取消关注", "type": "verb" }, + "block": { "user_language": "屏蔽", "type": "verb" }, + "tag": { "user_language": "标记", "type": "verb" } + }, + "content_structure": { + "vocabulary_sections": [ + { + "section_id": "housing", + "title": "Housing & Living", + "words": ["central", "avenue", "building", "elevator", "superintendent", "bus stop", "jacuzzi", "machine", "town", "noise", "sidewalks", "convenient"] + }, + { + "section_id": "clothing", + "title": "Clothing & Accessories", + "words": ["shirt", "coat", "dress", "skirt", "blouse", "jacket", "sweater", "suit", "tie", "pants", "jeans", "belt", "hat", "glove", "purse", "glasses", "pajamas", "socks", "shoes", "bathrobe", "tee shirt", "scarf", "wallet", "ring", "sandals"] + }, + { + "section_id": "body", + "title": "Body Parts & Health", + "words": ["throat", "shoulder", "chest", "back", "arm", "elbow", "wrist", "hip", "thigh", "knee", "shin", "ankle", "cough", "sneeze", "wheeze", "feel dizzy", "feel nauseous", "twist", "burn", "hurt", "cut", "sprain", "dislocate", "break"] + }, + { + "section_id": "emotions", + "title": "Emotions & Feelings", + "words": ["upset", "worried", "concerned", "anxious", "nervous", "excited", "thrilled", "delighted", "pleased", "satisfied", "disappointed", "frustrated", "annoyed", "furious", "exhausted", "overwhelmed", "confused", "embarrassed", "proud", "jealous", "guilty"] + }, + { + "section_id": "communication", + "title": "Communication & Actions", + "words": ["recommend", "suggest", "insist", "warn", "promise", "apologize", "complain", "discuss", "argue", "disagree", "agree", "decide", "choose", "prefer", "enjoy", "appreciate", "celebrate", "congratulate"] + }, + { + "section_id": "technology", + "title": "Technology & Digital", + "words": ["website", "password", "username", "download", "upload", "install", "update", "delete", "save", "print", "scan", "copy", "paste", "search", "browse", "surf", "stream", "tweet", "post", "share", "like", "follow", "unfollow", "block", "tag"] + } + ] + }, + "learning_paths": { + "beginner": { + "recommended_order": ["housing", "clothing", "body", "emotions"], + "estimated_time": "12 hours" + }, + "intermediate": { + "recommended_order": ["housing", "clothing", "body", "emotions", "communication", "technology"], + "estimated_time": "20 hours" + }, + "advanced": { + "recommended_order": ["emotions", "communication", "technology", "housing", "clothing", "body"], + "estimated_time": "15 hours" + } + }, + "assessment": { + "vocabulary_quizzes": [ + { + "quiz_id": "housing_basic", + "section": "housing", + "difficulty": "beginner", + "question_count": 12, + "pass_score": 70 + }, + { + "quiz_id": "emotions_advanced", + "section": "emotions", + "difficulty": "advanced", + "question_count": 21, + "pass_score": 80 + } + ], + "practical_exercises": [ + { + "exercise_id": "clothing_conversation", + "type": "role_play", + "scenario": "Shopping for clothes", + "required_vocabulary": ["shirt", "coat", "dress", "suit", "jacket"] + }, + { + "exercise_id": "tech_tutorial", + "type": "guided_practice", + "scenario": "Using social media", + "required_vocabulary": ["post", "share", "like", "follow", "tag"] + } + ] + }, + "sentences": [ + { + "id": "housing_01", + "text": "The apartment building is in the center of town.", + "vocabulary_used": ["building", "central", "town"], + "difficulty": "beginner", + "audio": "housing_01.mp3" + }, + { + "id": "emotions_01", + "text": "I feel anxious and overwhelmed about the presentation.", + "vocabulary_used": ["anxious", "overwhelmed"], + "difficulty": "intermediate", + "audio": "emotions_01.mp3" + }, + { + "id": "tech_01", + "text": "Don't forget to download the app and create your username.", + "vocabulary_used": ["download", "username"], + "difficulty": "intermediate", + "audio": "tech_01.mp3" + } + ] +} \ No newline at end of file diff --git a/src/chapters/sbs-real.json b/src/chapters/sbs-real.json new file mode 100644 index 0000000..d84f724 --- /dev/null +++ b/src/chapters/sbs-real.json @@ -0,0 +1,37 @@ +{ + "name": "SBS", + "description": "Side by Side Level 7-8 vocabulary with language-agnostic format", + "difficulty": "intermediate", + "language": "en-US", + "metadata": { + "version": "1.0", + "created": "2025-09-23", + "updated": "2025-09-23", + "source": "Side by Side English Learning Series", + "target_level": "intermediate", + "estimated_hours": 25, + "prerequisites": ["basic-english"], + "learning_objectives": [ + "Master intermediate vocabulary for daily situations", + "Understand clothing and body parts terminology", + "Learn emotional expressions and feelings", + "Practice technology and social media vocabulary" + ], + "content_tags": ["vocabulary", "daily-life", "practical-english", "conversational"], + "total_chapters": 12 + }, + "learning_paths": { + "beginner": { + "recommended_order": ["housing", "clothing", "body", "emotions"], + "estimated_time": "12 hours" + }, + "intermediate": { + "recommended_order": ["housing", "clothing", "body", "emotions", "communication", "technology"], + "estimated_time": "20 hours" + }, + "advanced": { + "recommended_order": ["emotions", "communication", "technology", "housing", "clothing", "body"], + "estimated_time": "15 hours" + } + } +} \ No newline at end of file diff --git a/src/chapters/sbs.json b/src/chapters/sbs.json new file mode 100644 index 0000000..4778efd --- /dev/null +++ b/src/chapters/sbs.json @@ -0,0 +1,268 @@ +{ + "name": "SBS", + "description": "Side by Side Level 7-8 vocabulary with language-agnostic format", + "difficulty": "intermediate", + "language": "en-US", + "metadata": { + "version": "1.0", + "created": "2025-09-23", + "updated": "2025-09-23", + "source": "Side by Side English Learning Series", + "target_level": "intermediate", + "estimated_hours": 25, + "prerequisites": ["basic-english"], + "learning_objectives": [ + "Master intermediate vocabulary for daily situations", + "Understand clothing and body parts terminology", + "Learn emotional expressions and feelings", + "Practice technology and social media vocabulary" + ], + "content_tags": ["vocabulary", "daily-life", "practical-english", "conversational"], + "chapter_info": { + "chapter_number": "7-8", + "total_chapters": 12, + "completion_criteria": { + "vocabulary_mastery": 80, + "quiz_score": 75, + "games_completed": 5 + } + } + }, + "vocabulary": { + "central": { "user_language": "中心的;中央的", "type": "adjective", "pronunciation": "/ˈsentrəl/" }, + "avenue": { "user_language": "大街;林荫道", "type": "noun", "pronunciation": "/ˈævənjuː/" }, + "refrigerator": { "user_language": "冰箱", "type": "noun", "pronunciation": "/rɪˈfrɪdʒəreɪtər/" }, + "closet": { "user_language": "衣柜;壁橱", "type": "noun", "pronunciation": "/ˈklɒzɪt/" }, + "elevator": { "user_language": "电梯", "type": "noun", "pronunciation": "/ˈeləveɪtər/" }, + "building": { "user_language": "建筑物;大楼", "type": "noun", "pronunciation": "/ˈbɪldɪŋ/" }, + "air conditioner": { "user_language": "空调", "type": "noun", "pronunciation": "/ɛr kənˈdɪʃənər/" }, + "superintendent": { "user_language": "主管;负责人", "type": "noun", "pronunciation": "/ˌsuːpərɪnˈtendənt/" }, + "bus stop": { "user_language": "公交车站", "type": "noun", "pronunciation": "/bʌs stɒp/" }, + "jacuzzi": { "user_language": "按摩浴缸", "type": "noun", "pronunciation": "/dʒəˈkuːzi/" }, + "machine": { "user_language": "机器;设备", "type": "noun", "pronunciation": "/məˈʃiːn/" }, + "two and a half": { "user_language": "两个半", "type": "number", "pronunciation": "/tuː ænd ə hæf/" }, + "in the center of": { "user_language": "在……中心", "type": "preposition", "pronunciation": "/ɪn ðə ˈsentər ʌv/" }, + "town": { "user_language": "城镇", "type": "noun", "pronunciation": "/taʊn/" }, + "a lot of": { "user_language": "许多", "type": "determiner", "pronunciation": "/ə lɑt ʌv/" }, + "noise": { "user_language": "噪音", "type": "noun", "pronunciation": "/nɔɪz/" }, + "sidewalks": { "user_language": "人行道", "type": "noun", "pronunciation": "/ˈsaɪdwɔːks/" }, + "all day and all night": { "user_language": "整日整夜", "type": "adverb", "pronunciation": "/ɔːl deɪ ænd ɔːl naɪt/" }, + "convenient": { "user_language": "便利的", "type": "adjective", "pronunciation": "/kənˈviːniənt/" }, + "upset": { "user_language": "失望的", "type": "adjective", "pronunciation": "/ʌpˈset/" }, + "shirt": { "user_language": "衬衫", "type": "noun", "pronunciation": "/ʃɜːrt/" }, + "coat": { "user_language": "外套、大衣", "type": "noun", "pronunciation": "/koʊt/" }, + "dress": { "user_language": "连衣裙", "type": "noun", "pronunciation": "/dres/" }, + "skirt": { "user_language": "短裙", "type": "noun", "pronunciation": "/skɜːrt/" }, + "blouse": { "user_language": "女式衬衫", "type": "noun", "pronunciation": "/blaʊs/" }, + "jacket": { "user_language": "夹克、短外套", "type": "noun" }, + "sweater": { "user_language": "毛衣、针织衫", "type": "noun" }, + "suit": { "user_language": "套装、西装", "type": "noun" }, + "tie": { "user_language": "领带", "type": "noun" }, + "pants": { "user_language": "裤子", "type": "noun" }, + "jeans": { "user_language": "牛仔裤", "type": "noun" }, + "belt": { "user_language": "腰带、皮带", "type": "noun" }, + "hat": { "user_language": "帽子", "type": "noun" }, + "glove": { "user_language": "手套", "type": "noun" }, + "purse": { "user_language": "手提包、女式小包", "type": "noun" }, + "glasses": { "user_language": "眼镜", "type": "noun" }, + "pajamas": { "user_language": "睡衣", "type": "noun" }, + "socks": { "user_language": "袜子", "type": "noun" }, + "shoes": { "user_language": "鞋子", "type": "noun" }, + "bathrobe": { "user_language": "浴袍", "type": "noun" }, + "tee shirt": { "user_language": "T恤", "type": "noun" }, + "scarf": { "user_language": "围巾", "type": "noun" }, + "wallet": { "user_language": "钱包", "type": "noun" }, + "ring": { "user_language": "戒指", "type": "noun" }, + "sandals": { "user_language": "凉鞋", "type": "noun" }, + "throat": { "user_language": "喉咙", "type": "noun" }, + "shoulder": { "user_language": "肩膀", "type": "noun" }, + "chest": { "user_language": "胸部", "type": "noun" }, + "back": { "user_language": "背部", "type": "noun" }, + "arm": { "user_language": "手臂", "type": "noun" }, + "elbow": { "user_language": "肘部", "type": "noun" }, + "wrist": { "user_language": "手腕", "type": "noun" }, + "hip": { "user_language": "髋部", "type": "noun" }, + "thigh": { "user_language": "大腿", "type": "noun" }, + "knee": { "user_language": "膝盖", "type": "noun" }, + "shin": { "user_language": "胫骨", "type": "noun" }, + "ankle": { "user_language": "脚踝", "type": "noun" }, + "cough": { "user_language": "咳嗽", "type": "verb" }, + "sneeze": { "user_language": "打喷嚏", "type": "verb" }, + "wheeze": { "user_language": "喘息", "type": "verb" }, + "feel dizzy": { "user_language": "感到头晕", "type": "verb" }, + "feel nauseous": { "user_language": "感到恶心", "type": "verb" }, + "twist": { "user_language": "扭伤", "type": "verb" }, + "burn": { "user_language": "烧伤", "type": "verb" }, + "hurt": { "user_language": "受伤", "type": "verb" }, + "cut": { "user_language": "割伤", "type": "verb" }, + "sprain": { "user_language": "扭伤", "type": "verb" }, + "dislocate": { "user_language": "脱臼", "type": "verb" }, + "break": { "user_language": "骨折", "type": "verb" }, + "recommend": { "user_language": "推荐", "type": "verb" }, + "suggest": { "user_language": "建议", "type": "verb" }, + "insist": { "user_language": "坚持", "type": "verb" }, + "warn": { "user_language": "警告", "type": "verb" }, + "promise": { "user_language": "承诺", "type": "verb" }, + "apologize": { "user_language": "道歉", "type": "verb" }, + "complain": { "user_language": "抱怨", "type": "verb" }, + "discuss": { "user_language": "讨论", "type": "verb" }, + "argue": { "user_language": "争论", "type": "verb" }, + "disagree": { "user_language": "不同意", "type": "verb" }, + "agree": { "user_language": "同意", "type": "verb" }, + "decide": { "user_language": "决定", "type": "verb" }, + "choose": { "user_language": "选择", "type": "verb" }, + "prefer": { "user_language": "偏爱", "type": "verb" }, + "enjoy": { "user_language": "享受", "type": "verb" }, + "appreciate": { "user_language": "欣赏", "type": "verb" }, + "celebrate": { "user_language": "庆祝", "type": "verb" }, + "congratulate": { "user_language": "祝贺", "type": "verb" }, + "worried": { "user_language": "担心的", "type": "adjective" }, + "concerned": { "user_language": "关心的", "type": "adjective" }, + "anxious": { "user_language": "焦虑的", "type": "adjective" }, + "nervous": { "user_language": "紧张的", "type": "adjective" }, + "excited": { "user_language": "兴奋的", "type": "adjective" }, + "thrilled": { "user_language": "激动的", "type": "adjective" }, + "delighted": { "user_language": "高兴的", "type": "adjective" }, + "pleased": { "user_language": "满意的", "type": "adjective" }, + "satisfied": { "user_language": "满足的", "type": "adjective" }, + "disappointed": { "user_language": "失望的", "type": "adjective" }, + "frustrated": { "user_language": "沮丧的", "type": "adjective" }, + "annoyed": { "user_language": "恼怒的", "type": "adjective" }, + "furious": { "user_language": "愤怒的", "type": "adjective" }, + "exhausted": { "user_language": "筋疲力尽的", "type": "adjective" }, + "overwhelmed": { "user_language": "不知所措的", "type": "adjective" }, + "confused": { "user_language": "困惑的", "type": "adjective" }, + "embarrassed": { "user_language": "尴尬的", "type": "adjective" }, + "proud": { "user_language": "自豪的", "type": "adjective" }, + "jealous": { "user_language": "嫉妒的", "type": "adjective" }, + "guilty": { "user_language": "内疚的", "type": "adjective" }, + "website": { "user_language": "网站", "type": "noun" }, + "password": { "user_language": "密码", "type": "noun" }, + "username": { "user_language": "用户名", "type": "noun" }, + "download": { "user_language": "下载", "type": "verb" }, + "upload": { "user_language": "上传", "type": "verb" }, + "install": { "user_language": "安装", "type": "verb" }, + "update": { "user_language": "更新", "type": "verb" }, + "delete": { "user_language": "删除", "type": "verb" }, + "save": { "user_language": "保存", "type": "verb" }, + "print": { "user_language": "打印", "type": "verb" }, + "scan": { "user_language": "扫描", "type": "verb" }, + "copy": { "user_language": "复制", "type": "verb" }, + "paste": { "user_language": "粘贴", "type": "verb" }, + "search": { "user_language": "搜索", "type": "verb" }, + "browse": { "user_language": "浏览", "type": "verb" }, + "surf": { "user_language": "网上冲浪", "type": "verb" }, + "stream": { "user_language": "流媒体", "type": "verb" }, + "tweet": { "user_language": "发推特", "type": "verb" }, + "post": { "user_language": "发布", "type": "verb" }, + "share": { "user_language": "分享", "type": "verb" }, + "like": { "user_language": "点赞", "type": "verb" }, + "follow": { "user_language": "关注", "type": "verb" }, + "unfollow": { "user_language": "取消关注", "type": "verb" }, + "block": { "user_language": "屏蔽", "type": "verb" }, + "tag": { "user_language": "标记", "type": "verb" } + }, + "content_structure": { + "vocabulary_sections": [ + { + "section_id": "housing", + "title": "Housing & Living", + "words": ["central", "avenue", "building", "elevator", "superintendent", "bus stop", "jacuzzi", "machine", "town", "noise", "sidewalks", "convenient"] + }, + { + "section_id": "clothing", + "title": "Clothing & Accessories", + "words": ["shirt", "coat", "dress", "skirt", "blouse", "jacket", "sweater", "suit", "tie", "pants", "jeans", "belt", "hat", "glove", "purse", "glasses", "pajamas", "socks", "shoes", "bathrobe", "tee shirt", "scarf", "wallet", "ring", "sandals"] + }, + { + "section_id": "body", + "title": "Body Parts & Health", + "words": ["throat", "shoulder", "chest", "back", "arm", "elbow", "wrist", "hip", "thigh", "knee", "shin", "ankle", "cough", "sneeze", "wheeze", "feel dizzy", "feel nauseous", "twist", "burn", "hurt", "cut", "sprain", "dislocate", "break"] + }, + { + "section_id": "emotions", + "title": "Emotions & Feelings", + "words": ["upset", "worried", "concerned", "anxious", "nervous", "excited", "thrilled", "delighted", "pleased", "satisfied", "disappointed", "frustrated", "annoyed", "furious", "exhausted", "overwhelmed", "confused", "embarrassed", "proud", "jealous", "guilty"] + }, + { + "section_id": "communication", + "title": "Communication & Actions", + "words": ["recommend", "suggest", "insist", "warn", "promise", "apologize", "complain", "discuss", "argue", "disagree", "agree", "decide", "choose", "prefer", "enjoy", "appreciate", "celebrate", "congratulate"] + }, + { + "section_id": "technology", + "title": "Technology & Digital", + "words": ["website", "password", "username", "download", "upload", "install", "update", "delete", "save", "print", "scan", "copy", "paste", "search", "browse", "surf", "stream", "tweet", "post", "share", "like", "follow", "unfollow", "block", "tag"] + } + ] + }, + "learning_paths": { + "beginner": { + "recommended_order": ["housing", "clothing", "body", "emotions"], + "estimated_time": "12 hours" + }, + "intermediate": { + "recommended_order": ["housing", "clothing", "body", "emotions", "communication", "technology"], + "estimated_time": "20 hours" + }, + "advanced": { + "recommended_order": ["emotions", "communication", "technology", "housing", "clothing", "body"], + "estimated_time": "15 hours" + } + }, + "assessment": { + "vocabulary_quizzes": [ + { + "quiz_id": "housing_basic", + "section": "housing", + "difficulty": "beginner", + "question_count": 12, + "pass_score": 70 + }, + { + "quiz_id": "emotions_advanced", + "section": "emotions", + "difficulty": "advanced", + "question_count": 21, + "pass_score": 80 + } + ], + "practical_exercises": [ + { + "exercise_id": "clothing_conversation", + "type": "role_play", + "scenario": "Shopping for clothes", + "required_vocabulary": ["shirt", "coat", "dress", "suit", "jacket"] + }, + { + "exercise_id": "tech_tutorial", + "type": "guided_practice", + "scenario": "Using social media", + "required_vocabulary": ["post", "share", "like", "follow", "tag"] + } + ] + }, + "sentences": [ + { + "id": "housing_01", + "text": "The apartment building is in the center of town.", + "vocabulary_used": ["building", "central", "town"], + "difficulty": "beginner", + "audio": "housing_01.mp3" + }, + { + "id": "emotions_01", + "text": "I feel anxious and overwhelmed about the presentation.", + "vocabulary_used": ["anxious", "overwhelmed"], + "difficulty": "intermediate", + "audio": "emotions_01.mp3" + }, + { + "id": "tech_01", + "text": "Don't forget to download the app and create your username.", + "vocabulary_used": ["download", "username"], + "difficulty": "intermediate", + "audio": "tech_01.mp3" + } + ] +} \ No newline at end of file diff --git a/src/components/AIReportInterface.js b/src/components/AIReportInterface.js new file mode 100644 index 0000000..66f2d94 --- /dev/null +++ b/src/components/AIReportInterface.js @@ -0,0 +1,461 @@ +/** + * AIReportInterface - UI component pour accéder aux rapports IA + * Provides buttons and interface for accessing AI reports and explanations + */ + +import componentRegistry from './ComponentRegistry.js'; + +class AIReportInterface { + constructor(llmValidator, config = {}) { + this.llmValidator = llmValidator; + this.config = { + showInline: config.showInline !== false, // Show reports inline by default + showDownloadButtons: config.showDownloadButtons !== false, + position: config.position || 'bottom-right', // Position in UI + autoShow: config.autoShow !== false, // Auto-show after exercises + ...config + }; + + this.container = null; + this.isVisible = false; + + console.log('📊 AIReportInterface initialized'); + } + + /** + * Create and insert the report interface into the DOM + * @param {HTMLElement} parentContainer - Parent element + */ + create(parentContainer) { + if (this.container) { + console.warn('⚠️ AIReportInterface already created'); + return; + } + + this.container = document.createElement('div'); + this.container.className = `ai-report-interface position-${this.config.position}`; + this.container.innerHTML = this._generateHTML(); + + // Style the component + this._applyStyles(); + + // Add event listeners + this._attachEventListeners(); + + // Insert into parent + parentContainer.appendChild(this.container); + + console.log('📊 AIReportInterface created and inserted into DOM'); + } + + /** + * Generate the HTML structure + * @private + */ + _generateHTML() { + return ` +
+
+

📚 Rapport IA

+ +
+ +
+
+

Aucune session active

+
+ + ${this.config.showDownloadButtons ? ` +
+ + +
+ + + +
+
+ ` : ''} + + +
+
+ `; + } + + /** + * Apply CSS styles to the component + * @private + */ + _applyStyles() { + const style = document.createElement('style'); + style.textContent = ` + .ai-report-interface { + position: fixed; + z-index: 1000; + max-width: 350px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + } + + .ai-report-interface.position-bottom-right { + bottom: 20px; + right: 20px; + } + + .ai-report-interface.position-bottom-left { + bottom: 20px; + left: 20px; + } + + .ai-report-interface.position-top-right { + top: 20px; + right: 20px; + } + + .ai-report-panel { + background: white; + border: 1px solid #e5e7eb; + border-radius: 12px; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + overflow: hidden; + transition: all 0.3s ease; + } + + .ai-report-header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 12px 16px; + display: flex; + justify-content: space-between; + align-items: center; + } + + .ai-report-header h4 { + margin: 0; + font-size: 14px; + font-weight: 600; + } + + .ai-report-toggle { + background: none; + border: none; + color: white; + font-size: 18px; + cursor: pointer; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: background-color 0.2s ease; + } + + .ai-report-toggle:hover { + background-color: rgba(255, 255, 255, 0.2); + } + + .ai-report-content { + padding: 16px; + } + + .ai-report-summary { + margin-bottom: 16px; + } + + .ai-report-status { + margin: 0; + font-size: 13px; + color: #6b7280; + text-align: center; + padding: 8px; + background: #f9fafb; + border-radius: 6px; + } + + .ai-report-actions { + display: flex; + flex-direction: column; + gap: 12px; + } + + .ai-report-download-group { + display: flex; + gap: 6px; + justify-content: space-between; + } + + .btn-small { + padding: 6px 12px; + font-size: 12px; + border-radius: 6px; + border: 1px solid #e5e7eb; + cursor: pointer; + font-weight: 500; + transition: all 0.2s ease; + text-align: center; + flex: 1; + } + + .btn-small.btn-primary { + background: #3b82f6; + color: white; + border-color: #3b82f6; + } + + .btn-small.btn-primary:hover { + background: #2563eb; + border-color: #2563eb; + } + + .btn-small.btn-outline { + background: white; + color: #374151; + border-color: #d1d5db; + } + + .btn-small.btn-outline:hover { + background: #f9fafb; + border-color: #9ca3af; + } + + .ai-report-inline-view { + margin-top: 16px; + padding: 12px; + background: #f8fafc; + border-radius: 8px; + border: 1px solid #e2e8f0; + } + + .ai-report-inline-content { + max-height: 300px; + overflow-y: auto; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + font-family: 'Monaco', 'Consolas', monospace; + margin-bottom: 12px; + } + + .ai-report-panel.collapsed .ai-report-content { + display: none; + } + + .ai-report-panel.collapsed .ai-report-toggle::after { + content: '+'; + } + + @media (max-width: 768px) { + .ai-report-interface { + position: fixed; + bottom: 10px; + left: 10px; + right: 10px; + max-width: none; + } + + .ai-report-download-group { + flex-direction: column; + gap: 8px; + } + } + `; + + document.head.appendChild(style); + } + + /** + * Attach event listeners + * @private + */ + _attachEventListeners() { + if (!this.container) return; + + // Toggle panel + const toggleBtn = this.container.querySelector('.ai-report-toggle'); + if (toggleBtn) { + toggleBtn.addEventListener('click', () => this.toggle()); + } + + // View report inline + const viewReportBtn = this.container.querySelector('#viewReportBtn'); + if (viewReportBtn) { + viewReportBtn.addEventListener('click', () => this.showInlineReport()); + } + + // Download buttons + const downloadTextBtn = this.container.querySelector('#downloadTextBtn'); + if (downloadTextBtn) { + downloadTextBtn.addEventListener('click', () => this.downloadReport('text')); + } + + const downloadHtmlBtn = this.container.querySelector('#downloadHtmlBtn'); + if (downloadHtmlBtn) { + downloadHtmlBtn.addEventListener('click', () => this.downloadReport('html')); + } + + const downloadJsonBtn = this.container.querySelector('#downloadJsonBtn'); + if (downloadJsonBtn) { + downloadJsonBtn.addEventListener('click', () => this.downloadReport('json')); + } + + // Close inline view + const closeInlineBtn = this.container.querySelector('#closeInlineBtn'); + if (closeInlineBtn) { + closeInlineBtn.addEventListener('click', () => this.hideInlineReport()); + } + } + + /** + * Update the status display + * @param {string} message - Status message + * @param {string} type - Status type ('info', 'success', 'warning') + */ + updateStatus(message, type = 'info') { + if (!this.container) return; + + const statusElement = this.container.querySelector('.ai-report-status'); + if (statusElement) { + statusElement.textContent = message; + statusElement.className = `ai-report-status ai-report-status-${type}`; + } + } + + /** + * Notify about session start + * @param {Object} sessionInfo - Session information + */ + onSessionStart(sessionInfo) { + this.updateStatus(`📚 Session active: ${sessionInfo.bookId}/${sessionInfo.chapterId}`, 'success'); + + if (this.config.autoShow) { + this.show(); + } + } + + /** + * Notify about session end + * @param {Object} sessionStats - Session statistics + */ + onSessionEnd(sessionStats) { + const exerciseCount = sessionStats.exerciseCount || 0; + const avgScore = sessionStats.averageScore || 0; + + this.updateStatus( + `✅ Session terminée: ${exerciseCount} exercices, ${Math.round(avgScore)}% moyen`, + 'success' + ); + } + + /** + * Show inline report + */ + async showInlineReport() { + if (!this.llmValidator || !this.llmValidator.config.enableReporting) { + alert('Le système de rapport n\'est pas disponible'); + return; + } + + try { + const report = this.llmValidator.getReport('text'); + const inlineView = this.container.querySelector('#inlineReportView'); + const inlineContent = this.container.querySelector('.ai-report-inline-content'); + + if (inlineContent && inlineView) { + inlineContent.textContent = report; + inlineView.style.display = 'block'; + } + } catch (error) { + alert(`Erreur lors de la génération du rapport: ${error.message}`); + } + } + + /** + * Hide inline report + */ + hideInlineReport() { + const inlineView = this.container.querySelector('#inlineReportView'); + if (inlineView) { + inlineView.style.display = 'none'; + } + } + + /** + * Download report in specified format + * @param {string} format - Report format ('text', 'html', 'json') + */ + async downloadReport(format) { + if (!this.llmValidator || !this.llmValidator.config.enableReporting) { + alert('Le système de rapport n\'est pas disponible'); + return; + } + + try { + await this.llmValidator.exportReport(format); + this.updateStatus(`📥 Rapport ${format.toUpperCase()} téléchargé`, 'success'); + + // Reset status after 3 seconds + setTimeout(() => { + this.updateStatus('Rapport disponible au téléchargement', 'info'); + }, 3000); + } catch (error) { + alert(`Erreur lors du téléchargement: ${error.message}`); + } + } + + /** + * Show the report interface + */ + show() { + if (!this.container) return; + this.container.style.display = 'block'; + this.isVisible = true; + } + + /** + * Hide the report interface + */ + hide() { + if (!this.container) return; + this.container.style.display = 'none'; + this.isVisible = false; + } + + /** + * Toggle the report interface visibility + */ + toggle() { + const panel = this.container.querySelector('.ai-report-panel'); + if (panel) { + panel.classList.toggle('collapsed'); + } + } + + /** + * Destroy the component and clean up + */ + destroy() { + if (this.container && this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + this.container = null; + console.log('📊 AIReportInterface destroyed'); + } +} + +// Register the component +componentRegistry.register('aiReportInterface', AIReportInterface); + +export default AIReportInterface; \ No newline at end of file diff --git a/src/components/Button.js b/src/components/Button.js new file mode 100644 index 0000000..16556fd --- /dev/null +++ b/src/components/Button.js @@ -0,0 +1,379 @@ +/** + * Button Component - Reusable button with consistent styling and behavior + * Extracted from DRS module patterns (TextModule, AudioModule, etc.) + */ + +class Button { + constructor(options = {}) { + // Default configuration + this.config = { + text: options.text || 'Button', + icon: options.icon || null, + type: options.type || 'primary', // primary, secondary, outline, success, danger + size: options.size || 'normal', // sm, normal, lg + disabled: options.disabled || false, + loading: options.loading || false, + onClick: options.onClick || (() => {}), + className: options.className || '', + id: options.id || null, + ...options + }; + + this.element = null; + this.originalText = this.config.text; + this.originalIcon = this.config.icon; + + this._createButton(); + Object.seal(this); + } + + /** + * Create the button element + * @private + */ + _createButton() { + this.element = document.createElement('button'); + this.element.className = this._getButtonClasses(); + + if (this.config.id) { + this.element.id = this.config.id; + } + + this._updateContent(); + this._attachEventListeners(); + this._updateState(); + } + + /** + * Get CSS classes for the button + * @returns {string} - Space-separated CSS classes + * @private + */ + _getButtonClasses() { + const classes = ['btn']; + + // Type classes + classes.push(`btn-${this.config.type}`); + + // Size classes + if (this.config.size !== 'normal') { + classes.push(`btn-${this.config.size}`); + } + + // Custom classes + if (this.config.className) { + classes.push(this.config.className); + } + + return classes.join(' '); + } + + /** + * Update button content (text and icon) + * @private + */ + _updateContent() { + let content = ''; + + if (this.config.loading) { + content = ` + + ${this.config.loadingText || 'Loading...'} + `; + } else { + if (this.config.icon) { + content += `${this.config.icon}`; + } + if (this.config.text) { + content += `${this.config.text}`; + } + } + + this.element.innerHTML = content; + } + + /** + * Attach event listeners + * @private + */ + _attachEventListeners() { + this.element.addEventListener('click', (event) => { + if (!this.config.disabled && !this.config.loading) { + this.config.onClick(event, this); + } + }); + + // Prevent default form submission if inside a form + this.element.addEventListener('click', (event) => { + if (this.element.type === 'button') { + event.preventDefault(); + } + }); + } + + /** + * Update button state (disabled, loading, etc.) + * @private + */ + _updateState() { + this.element.disabled = this.config.disabled || this.config.loading; + + // Update ARIA attributes + this.element.setAttribute('aria-disabled', this.config.disabled); + if (this.config.loading) { + this.element.setAttribute('aria-busy', 'true'); + } else { + this.element.removeAttribute('aria-busy'); + } + } + + // Public API + + /** + * Set button text + * @param {string} text - New button text + */ + setText(text) { + this.config.text = text; + this._updateContent(); + return this; + } + + /** + * Set button icon + * @param {string} icon - New button icon (emoji or HTML) + */ + setIcon(icon) { + this.config.icon = icon; + this._updateContent(); + return this; + } + + /** + * Enable the button + */ + enable() { + this.config.disabled = false; + this._updateState(); + return this; + } + + /** + * Disable the button + */ + disable() { + this.config.disabled = true; + this._updateState(); + return this; + } + + /** + * Set loading state + * @param {boolean} loading - Whether button is loading + * @param {string} loadingText - Optional loading text + */ + setLoading(loading, loadingText = null) { + this.config.loading = loading; + if (loadingText) { + this.config.loadingText = loadingText; + } + this._updateContent(); + this._updateState(); + return this; + } + + /** + * Update onClick handler + * @param {Function} handler - New click handler + */ + setOnClick(handler) { + this.config.onClick = handler || (() => {}); + return this; + } + + /** + * Add CSS class to button + * @param {string} className - CSS class to add + */ + addClass(className) { + this.element.classList.add(className); + return this; + } + + /** + * Remove CSS class from button + * @param {string} className - CSS class to remove + */ + removeClass(className) { + this.element.classList.remove(className); + return this; + } + + /** + * Toggle CSS class on button + * @param {string} className - CSS class to toggle + * @param {boolean} force - Force add/remove + */ + toggleClass(className, force) { + this.element.classList.toggle(className, force); + return this; + } + + /** + * Show the button + */ + show() { + this.element.style.display = ''; + return this; + } + + /** + * Hide the button + */ + hide() { + this.element.style.display = 'none'; + return this; + } + + /** + * Focus the button + */ + focus() { + this.element.focus(); + return this; + } + + /** + * Get the DOM element + * @returns {HTMLButtonElement} - The button element + */ + getElement() { + return this.element; + } + + /** + * Append button to a container + * @param {HTMLElement} container - Container to append to + */ + appendTo(container) { + if (container && container.appendChild) { + container.appendChild(this.element); + } + return this; + } + + /** + * Remove button from DOM + */ + remove() { + if (this.element && this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + } + return this; + } + + /** + * Destroy the button and clean up + */ + destroy() { + this.remove(); + this.element = null; + this.config = null; + } + + /** + * Clone the button with new options + * @param {Object} newOptions - Options to override + * @returns {Button} - New button instance + */ + clone(newOptions = {}) { + return new Button({ + ...this.config, + ...newOptions + }); + } + + // Static factory methods + + /** + * Create a primary button + * @param {string} text - Button text + * @param {Function} onClick - Click handler + * @param {string} icon - Optional icon + * @returns {Button} - Button instance + */ + static primary(text, onClick, icon = null) { + return new Button({ + text, + onClick, + icon, + type: 'primary' + }); + } + + /** + * Create a secondary button + * @param {string} text - Button text + * @param {Function} onClick - Click handler + * @param {string} icon - Optional icon + * @returns {Button} - Button instance + */ + static secondary(text, onClick, icon = null) { + return new Button({ + text, + onClick, + icon, + type: 'secondary' + }); + } + + /** + * Create an outline button + * @param {string} text - Button text + * @param {Function} onClick - Click handler + * @param {string} icon - Optional icon + * @returns {Button} - Button instance + */ + static outline(text, onClick, icon = null) { + return new Button({ + text, + onClick, + icon, + type: 'outline' + }); + } + + /** + * Create a success button + * @param {string} text - Button text + * @param {Function} onClick - Click handler + * @param {string} icon - Optional icon + * @returns {Button} - Button instance + */ + static success(text, onClick, icon = null) { + return new Button({ + text, + onClick, + icon, + type: 'success' + }); + } + + /** + * Create a danger button + * @param {string} text - Button text + * @param {Function} onClick - Click handler + * @param {string} icon - Optional icon + * @returns {Button} - Button instance + */ + static danger(text, onClick, icon = null) { + return new Button({ + text, + onClick, + icon, + type: 'danger' + }); + } +} + +export default Button; \ No newline at end of file diff --git a/src/components/Card.js b/src/components/Card.js new file mode 100644 index 0000000..d18e995 --- /dev/null +++ b/src/components/Card.js @@ -0,0 +1,474 @@ +/** + * Card Component - Reusable card container with consistent styling + * Extracted from DRS module patterns (exercise cards, question cards, result cards) + */ + +class Card { + constructor(options = {}) { + // Default configuration + this.config = { + title: options.title || null, + content: options.content || '', + footer: options.footer || null, + type: options.type || 'default', // default, exercise, question, result, info, warning, success, danger + size: options.size || 'normal', // sm, normal, lg + elevated: options.elevated !== false, // true by default + interactive: options.interactive || false, // hover effects, clickable + onClick: options.onClick || null, + className: options.className || '', + id: options.id || null, + ...options + }; + + this.element = null; + this.headerElement = null; + this.bodyElement = null; + this.footerElement = null; + + this._createCard(); + Object.seal(this); + } + + /** + * Create the card element + * @private + */ + _createCard() { + this.element = document.createElement('div'); + this.element.className = this._getCardClasses(); + + if (this.config.id) { + this.element.id = this.config.id; + } + + // Create card structure + this.element.innerHTML = this._getCardHTML(); + + // Get references to sections + this.headerElement = this.element.querySelector('.card-header'); + this.bodyElement = this.element.querySelector('.card-body'); + this.footerElement = this.element.querySelector('.card-footer'); + + this._attachEventListeners(); + this._setupARIA(); + } + + /** + * Get CSS classes for the card + * @returns {string} - Space-separated CSS classes + * @private + */ + _getCardClasses() { + const classes = ['card']; + + // Type classes + if (this.config.type !== 'default') { + classes.push(`card-${this.config.type}`); + } + + // Size classes + if (this.config.size !== 'normal') { + classes.push(`card-${this.config.size}`); + } + + // State classes + if (this.config.elevated) { + classes.push('card-elevated'); + } + + if (this.config.interactive) { + classes.push('card-interactive'); + } + + // Custom classes + if (this.config.className) { + classes.push(this.config.className); + } + + return classes.join(' '); + } + + /** + * Get card HTML structure + * @returns {string} - HTML string + * @private + */ + _getCardHTML() { + return ` + ${this.config.title ? `

${this.config.title}

` : ''} +
+ ${this.config.content} +
+ ${this.config.footer ? `` : ''} + `; + } + + /** + * Attach event listeners + * @private + */ + _attachEventListeners() { + if (this.config.interactive && this.config.onClick) { + this.element.addEventListener('click', (event) => { + this.config.onClick(event, this); + }); + + this.element.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.config.onClick(event, this); + } + }); + } + } + + /** + * Setup ARIA attributes for accessibility + * @private + */ + _setupARIA() { + if (this.config.interactive) { + this.element.setAttribute('tabindex', '0'); + this.element.setAttribute('role', 'button'); + this.element.setAttribute('aria-label', this.config.title || 'Interactive card'); + } + } + + // Public API + + /** + * Set card title + * @param {string} title - New title + */ + setTitle(title) { + this.config.title = title; + + if (title) { + if (!this.headerElement) { + // Create header if it doesn't exist + const headerHTML = `

${title}

`; + this.element.insertAdjacentHTML('afterbegin', headerHTML); + this.headerElement = this.element.querySelector('.card-header'); + } else { + const titleElement = this.headerElement.querySelector('.card-title'); + if (titleElement) { + titleElement.textContent = title; + } + } + } else if (this.headerElement) { + this.headerElement.remove(); + this.headerElement = null; + } + + return this; + } + + /** + * Set card content + * @param {string|HTMLElement} content - New content + */ + setContent(content) { + this.config.content = content; + + if (this.bodyElement) { + if (typeof content === 'string') { + this.bodyElement.innerHTML = content; + } else if (content instanceof HTMLElement) { + this.bodyElement.innerHTML = ''; + this.bodyElement.appendChild(content); + } + } + + return this; + } + + /** + * Append content to card body + * @param {string|HTMLElement} content - Content to append + */ + appendContent(content) { + if (this.bodyElement) { + if (typeof content === 'string') { + this.bodyElement.insertAdjacentHTML('beforeend', content); + } else if (content instanceof HTMLElement) { + this.bodyElement.appendChild(content); + } + } + + return this; + } + + /** + * Set card footer + * @param {string} footer - New footer content + */ + setFooter(footer) { + this.config.footer = footer; + + if (footer) { + if (!this.footerElement) { + // Create footer if it doesn't exist + const footerHTML = ``; + this.element.insertAdjacentHTML('beforeend', footerHTML); + this.footerElement = this.element.querySelector('.card-footer'); + } else { + this.footerElement.innerHTML = footer; + } + } else if (this.footerElement) { + this.footerElement.remove(); + this.footerElement = null; + } + + return this; + } + + /** + * Set card type/theme + * @param {string} type - Card type (default, exercise, question, result, info, warning, success, danger) + */ + setType(type) { + // Remove old type class + this.element.className = this.element.className.replace(/card-\w+/g, ''); + + this.config.type = type; + + // Add new type class + if (type !== 'default') { + this.element.classList.add(`card-${type}`); + } + + return this; + } + + /** + * Enable or disable interactive state + * @param {boolean} interactive - Whether card is interactive + * @param {Function} onClick - Optional click handler + */ + setInteractive(interactive, onClick = null) { + this.config.interactive = interactive; + + if (onClick) { + this.config.onClick = onClick; + } + + this.element.classList.toggle('card-interactive', interactive); + + if (interactive) { + this.element.setAttribute('tabindex', '0'); + this.element.setAttribute('role', 'button'); + this._attachEventListeners(); + } else { + this.element.removeAttribute('tabindex'); + this.element.removeAttribute('role'); + } + + return this; + } + + /** + * Enable or disable elevation shadow + * @param {boolean} elevated - Whether card has elevation + */ + setElevated(elevated) { + this.config.elevated = elevated; + this.element.classList.toggle('card-elevated', elevated); + return this; + } + + /** + * Add CSS class to card + * @param {string} className - CSS class to add + */ + addClass(className) { + this.element.classList.add(className); + return this; + } + + /** + * Remove CSS class from card + * @param {string} className - CSS class to remove + */ + removeClass(className) { + this.element.classList.remove(className); + return this; + } + + /** + * Toggle CSS class on card + * @param {string} className - CSS class to toggle + * @param {boolean} force - Force add/remove + */ + toggleClass(className, force) { + this.element.classList.toggle(className, force); + return this; + } + + /** + * Show the card with optional animation + * @param {string} animation - Animation type (fade, slide, scale) + */ + show(animation = null) { + this.element.style.display = ''; + + if (animation) { + this.element.classList.add(`card-animate-${animation}`); + setTimeout(() => { + this.element.classList.remove(`card-animate-${animation}`); + }, 300); + } + + return this; + } + + /** + * Hide the card with optional animation + * @param {string} animation - Animation type (fade, slide, scale) + */ + hide(animation = null) { + if (animation) { + this.element.classList.add(`card-animate-${animation}-out`); + setTimeout(() => { + this.element.style.display = 'none'; + this.element.classList.remove(`card-animate-${animation}-out`); + }, 300); + } else { + this.element.style.display = 'none'; + } + + return this; + } + + /** + * Get the DOM element + * @returns {HTMLElement} - The card element + */ + getElement() { + return this.element; + } + + /** + * Get card header element + * @returns {HTMLElement|null} - The header element + */ + getHeader() { + return this.headerElement; + } + + /** + * Get card body element + * @returns {HTMLElement} - The body element + */ + getBody() { + return this.bodyElement; + } + + /** + * Get card footer element + * @returns {HTMLElement|null} - The footer element + */ + getFooter() { + return this.footerElement; + } + + /** + * Append card to a container + * @param {HTMLElement} container - Container to append to + */ + appendTo(container) { + if (container && container.appendChild) { + container.appendChild(this.element); + } + return this; + } + + /** + * Remove card from DOM + */ + remove() { + if (this.element && this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + } + return this; + } + + /** + * Destroy the card and clean up + */ + destroy() { + this.remove(); + this.element = null; + this.headerElement = null; + this.bodyElement = null; + this.footerElement = null; + this.config = null; + } + + // Static factory methods + + /** + * Create an exercise card + * @param {string} title - Exercise title + * @param {string} content - Exercise content + * @param {Object} options - Additional options + * @returns {Card} - Card instance + */ + static exercise(title, content, options = {}) { + return new Card({ + title, + content, + type: 'exercise', + elevated: true, + ...options + }); + } + + /** + * Create a question card + * @param {string} question - Question text + * @param {Object} options - Additional options + * @returns {Card} - Card instance + */ + static question(question, options = {}) { + return new Card({ + content: question, + type: 'question', + elevated: true, + ...options + }); + } + + /** + * Create a result card + * @param {string} result - Result content + * @param {boolean} success - Whether result is positive + * @param {Object} options - Additional options + * @returns {Card} - Card instance + */ + static result(result, success = true, options = {}) { + return new Card({ + content: result, + type: success ? 'success' : 'danger', + elevated: true, + ...options + }); + } + + /** + * Create an info card + * @param {string} title - Info title + * @param {string} content - Info content + * @param {Object} options - Additional options + * @returns {Card} - Card instance + */ + static info(title, content, options = {}) { + return new Card({ + title, + content, + type: 'info', + ...options + }); + } +} + +export default Card; \ No newline at end of file diff --git a/src/components/ComponentRegistry.js b/src/components/ComponentRegistry.js new file mode 100644 index 0000000..55d52e4 --- /dev/null +++ b/src/components/ComponentRegistry.js @@ -0,0 +1,363 @@ +/** + * ComponentRegistry - Central registry for UI components + * Manages component registration, creation, and lifecycle + */ + +class ComponentRegistry { + constructor() { + this.components = new Map(); + this.instances = new WeakMap(); + this._initialized = false; + Object.seal(this); + } + + /** + * Initialize the component registry + */ + async init() { + if (this._initialized) return; + + console.log('🎨 Initializing Component Registry...'); + + // Auto-register core components + await this._registerCoreComponents(); + + this._initialized = true; + console.log('✅ Component Registry initialized'); + } + + /** + * Register core UI components + * @private + */ + async _registerCoreComponents() { + try { + // Import and register Button component + const { default: Button } = await import('./Button.js'); + this.register('Button', Button, { + category: 'input', + description: 'Interactive button with loading states and icons' + }); + + // Import and register ProgressBar component + const { default: ProgressBar } = await import('./ProgressBar.js'); + this.register('ProgressBar', ProgressBar, { + category: 'feedback', + description: 'Progress indicator with animation and themes' + }); + + // Import and register Card component + const { default: Card } = await import('./Card.js'); + this.register('Card', Card, { + category: 'layout', + description: 'Versatile card container for content sections' + }); + + // Import and register Panel component + const { default: Panel } = await import('./Panel.js'); + this.register('Panel', Panel, { + category: 'layout', + description: 'Collapsible content panel with themes' + }); + + console.log(`✅ Registered ${this.components.size} core components`); + } catch (error) { + console.error('❌ Failed to register core components:', error); + throw error; + } + } + + /** + * Register a component class + * @param {string} name - Component name + * @param {Class} componentClass - Component constructor + * @param {Object} metadata - Component metadata + */ + register(name, componentClass, metadata = {}) { + if (!name || typeof name !== 'string') { + throw new Error('Component name must be a non-empty string'); + } + + if (!componentClass || typeof componentClass !== 'function') { + throw new Error('Component class must be a constructor function'); + } + + if (this.components.has(name)) { + console.warn(`⚠️ Component '${name}' already registered. Overriding...`); + } + + this.components.set(name, { + class: componentClass, + metadata: { + name, + category: metadata.category || 'general', + description: metadata.description || 'No description provided', + version: metadata.version || '1.0.0', + ...metadata + }, + created: 0, + active: 0 + }); + + console.log(`📝 Registered component: ${name} (${metadata.category})`); + } + + /** + * Create a component instance + * @param {string} name - Component name + * @param {Object} options - Component options + * @param {HTMLElement} container - Optional container to append to + * @returns {Object} Component instance + */ + create(name, options = {}, container = null) { + if (!this.components.has(name)) { + throw new Error(`Component '${name}' is not registered`); + } + + const componentInfo = this.components.get(name); + const ComponentClass = componentInfo.class; + + try { + // Create instance + const instance = new ComponentClass(options); + + // Track instance + this.instances.set(instance, { + name, + created: Date.now(), + options: { ...options } + }); + + // Update stats + componentInfo.created++; + componentInfo.active++; + + // Auto-append if container provided + if (container && instance.appendTo) { + instance.appendTo(container); + } + + console.log(`🎨 Created ${name} component`); + return instance; + + } catch (error) { + console.error(`❌ Failed to create ${name} component:`, error); + throw error; + } + } + + /** + * Destroy a component instance + * @param {Object} instance - Component instance + */ + destroy(instance) { + if (!instance) return; + + const instanceInfo = this.instances.get(instance); + if (!instanceInfo) { + console.warn('⚠️ Component instance not tracked by registry'); + return; + } + + try { + // Call instance destroy method if available + if (instance.destroy && typeof instance.destroy === 'function') { + instance.destroy(); + } + + // Update stats + const componentInfo = this.components.get(instanceInfo.name); + if (componentInfo) { + componentInfo.active = Math.max(0, componentInfo.active - 1); + } + + // Remove from tracking + this.instances.delete(instance); + + console.log(`🗑️ Destroyed ${instanceInfo.name} component`); + + } catch (error) { + console.error('❌ Error destroying component:', error); + } + } + + /** + * Get registered component names + * @param {string} category - Optional category filter + * @returns {Array} Component names + */ + getComponentNames(category = null) { + const names = Array.from(this.components.keys()); + + if (category) { + return names.filter(name => { + const component = this.components.get(name); + return component.metadata.category === category; + }); + } + + return names; + } + + /** + * Get component metadata + * @param {string} name - Component name + * @returns {Object} Component metadata + */ + getMetadata(name) { + if (!this.components.has(name)) { + return null; + } + + return { ...this.components.get(name).metadata }; + } + + /** + * Get component statistics + * @param {string} name - Optional component name + * @returns {Object} Statistics + */ + getStats(name = null) { + if (name) { + const component = this.components.get(name); + if (!component) return null; + + return { + name, + created: component.created, + active: component.active, + metadata: component.metadata + }; + } + + // Return overall stats + const stats = { + totalRegistered: this.components.size, + totalCreated: 0, + totalActive: 0, + byCategory: {}, + components: [] + }; + + this.components.forEach((component, name) => { + stats.totalCreated += component.created; + stats.totalActive += component.active; + + const category = component.metadata.category; + if (!stats.byCategory[category]) { + stats.byCategory[category] = { + registered: 0, + created: 0, + active: 0 + }; + } + + stats.byCategory[category].registered++; + stats.byCategory[category].created += component.created; + stats.byCategory[category].active += component.active; + + stats.components.push({ + name, + category, + created: component.created, + active: component.active + }); + }); + + return stats; + } + + /** + * Check if component exists + * @param {string} name - Component name + * @returns {boolean} Whether component is registered + */ + has(name) { + return this.components.has(name); + } + + /** + * List all registered components with details + * @returns {Array} Component information + */ + list() { + return Array.from(this.components.entries()).map(([name, info]) => ({ + name, + category: info.metadata.category, + description: info.metadata.description, + version: info.metadata.version, + created: info.created, + active: info.active + })); + } + + /** + * Get component creation helpers + * @returns {Object} Helper methods for each component + */ + getHelpers() { + const helpers = {}; + + this.components.forEach((info, name) => { + // Create helper method for each component + helpers[name.toLowerCase()] = (options = {}, container = null) => { + return this.create(name, options, container); + }; + + // Create factory methods if component has static methods + const ComponentClass = info.class; + if (ComponentClass.primary) { + helpers[`${name.toLowerCase()}Primary`] = (...args) => { + return ComponentClass.primary(...args); + }; + } + if (ComponentClass.secondary) { + helpers[`${name.toLowerCase()}Secondary`] = (...args) => { + return ComponentClass.secondary(...args); + }; + } + if (ComponentClass.success) { + helpers[`${name.toLowerCase()}Success`] = (...args) => { + return ComponentClass.success(...args); + }; + } + }); + + return helpers; + } + + /** + * Clean up inactive components + */ + cleanup() { + console.log('🧹 Cleaning up component registry...'); + + let cleanedCount = 0; + this.components.forEach((info, name) => { + if (info.active === 0 && info.created > 0) { + // Component has been created but no active instances + console.log(`🧹 Component '${name}' has no active instances`); + cleanedCount++; + } + }); + + console.log(`✅ Component cleanup complete (${cleanedCount} components reviewed)`); + } + + /** + * Get registry status + * @returns {Object} Registry status + */ + getStatus() { + return { + initialized: this._initialized, + components: this.components.size, + stats: this.getStats() + }; + } +} + +// Create singleton instance +const componentRegistry = new ComponentRegistry(); + +export default componentRegistry; \ No newline at end of file diff --git a/src/components/Panel.js b/src/components/Panel.js new file mode 100644 index 0000000..70b32ca --- /dev/null +++ b/src/components/Panel.js @@ -0,0 +1,614 @@ +/** + * Panel Component - Reusable panel container for content sections + * Extracted from DRS module patterns (explanation panels, hint panels, content sections) + */ + +class Panel { + constructor(options = {}) { + // Default configuration + this.config = { + title: options.title || null, + content: options.content || '', + type: options.type || 'default', // default, info, success, warning, danger, hint, explanation + collapsible: options.collapsible || false, + collapsed: options.collapsed || false, + closable: options.closable || false, + onCollapse: options.onCollapse || null, + onExpand: options.onExpand || null, + onClose: options.onClose || null, + className: options.className || '', + id: options.id || null, + ...options + }; + + this.element = null; + this.headerElement = null; + this.titleElement = null; + this.bodyElement = null; + this.toggleButton = null; + this.closeButton = null; + + this._createPanel(); + Object.seal(this); + } + + /** + * Create the panel element + * @private + */ + _createPanel() { + this.element = document.createElement('div'); + this.element.className = this._getPanelClasses(); + + if (this.config.id) { + this.element.id = this.config.id; + } + + // Create panel structure + this.element.innerHTML = this._getPanelHTML(); + + // Get references to elements + this.headerElement = this.element.querySelector('.panel-header'); + this.titleElement = this.element.querySelector('.panel-title'); + this.bodyElement = this.element.querySelector('.panel-body'); + this.toggleButton = this.element.querySelector('.panel-toggle'); + this.closeButton = this.element.querySelector('.panel-close'); + + this._attachEventListeners(); + this._updateState(); + this._setupARIA(); + } + + /** + * Get CSS classes for the panel + * @returns {string} - Space-separated CSS classes + * @private + */ + _getPanelClasses() { + const classes = ['panel']; + + // Type classes + if (this.config.type !== 'default') { + classes.push(`panel-${this.config.type}`); + } + + // State classes + if (this.config.collapsible) { + classes.push('panel-collapsible'); + } + + if (this.config.collapsed) { + classes.push('panel-collapsed'); + } + + // Custom classes + if (this.config.className) { + classes.push(this.config.className); + } + + return classes.join(' '); + } + + /** + * Get panel HTML structure + * @returns {string} - HTML string + * @private + */ + _getPanelHTML() { + const hasHeader = this.config.title || this.config.collapsible || this.config.closable; + + return ` + ${hasHeader ? ` +
+ ${this.config.title ? `

${this.config.title}

` : ''} +
+ ${this.config.collapsible ? '' : ''} + ${this.config.closable ? '' : ''} +
+
+ ` : ''} +
+ ${this.config.content} +
+ `; + } + + /** + * Attach event listeners + * @private + */ + _attachEventListeners() { + if (this.toggleButton) { + this.toggleButton.addEventListener('click', () => { + this.toggle(); + }); + } + + if (this.closeButton) { + this.closeButton.addEventListener('click', () => { + this.close(); + }); + } + + // Allow clicking header to toggle if collapsible + if (this.config.collapsible && this.headerElement) { + this.headerElement.style.cursor = 'pointer'; + this.headerElement.addEventListener('click', (event) => { + // Don't toggle if clicking on buttons + if (!event.target.matches('.panel-toggle, .panel-close')) { + this.toggle(); + } + }); + } + } + + /** + * Update panel state + * @private + */ + _updateState() { + if (this.config.collapsed) { + this.bodyElement.style.display = 'none'; + if (this.toggleButton) { + this.toggleButton.textContent = '▶'; + this.toggleButton.setAttribute('aria-expanded', 'false'); + } + } else { + this.bodyElement.style.display = ''; + if (this.toggleButton) { + this.toggleButton.textContent = '▼'; + this.toggleButton.setAttribute('aria-expanded', 'true'); + } + } + } + + /** + * Setup ARIA attributes for accessibility + * @private + */ + _setupARIA() { + if (this.config.collapsible) { + this.element.setAttribute('aria-expanded', this.config.collapsed ? 'false' : 'true'); + + if (this.bodyElement && this.toggleButton) { + const bodyId = this.bodyElement.id || `panel-body-${Date.now()}`; + this.bodyElement.id = bodyId; + this.toggleButton.setAttribute('aria-controls', bodyId); + } + } + } + + // Public API + + /** + * Set panel title + * @param {string} title - New title + */ + setTitle(title) { + this.config.title = title; + + if (title) { + if (!this.titleElement) { + // Create header structure if needed + if (!this.headerElement) { + const headerHTML = ` +
+

${title}

+
+ ${this.config.collapsible ? '' : ''} + ${this.config.closable ? '' : ''} +
+
+ `; + this.element.insertAdjacentHTML('afterbegin', headerHTML); + this.headerElement = this.element.querySelector('.panel-header'); + this.titleElement = this.element.querySelector('.panel-title'); + this.toggleButton = this.element.querySelector('.panel-toggle'); + this.closeButton = this.element.querySelector('.panel-close'); + this._attachEventListeners(); + } else { + // Just add title to existing header + const titleHTML = `

${title}

`; + this.headerElement.insertAdjacentHTML('afterbegin', titleHTML); + this.titleElement = this.element.querySelector('.panel-title'); + } + } else { + this.titleElement.textContent = title; + } + } else if (this.titleElement) { + this.titleElement.remove(); + this.titleElement = null; + } + + return this; + } + + /** + * Set panel content + * @param {string|HTMLElement} content - New content + */ + setContent(content) { + this.config.content = content; + + if (this.bodyElement) { + if (typeof content === 'string') { + this.bodyElement.innerHTML = content; + } else if (content instanceof HTMLElement) { + this.bodyElement.innerHTML = ''; + this.bodyElement.appendChild(content); + } + } + + return this; + } + + /** + * Append content to panel body + * @param {string|HTMLElement} content - Content to append + */ + appendContent(content) { + if (this.bodyElement) { + if (typeof content === 'string') { + this.bodyElement.insertAdjacentHTML('beforeend', content); + } else if (content instanceof HTMLElement) { + this.bodyElement.appendChild(content); + } + } + + return this; + } + + /** + * Set panel type/theme + * @param {string} type - Panel type (default, info, success, warning, danger, hint, explanation) + */ + setType(type) { + // Remove old type class + this.element.className = this.element.className.replace(/panel-\w+/g, ''); + + this.config.type = type; + + // Add new type class + if (type !== 'default') { + this.element.classList.add(`panel-${type}`); + } + + return this; + } + + /** + * Expand the panel (if collapsible) + * @param {boolean} animate - Whether to animate the expansion + */ + expand(animate = true) { + if (!this.config.collapsible || !this.config.collapsed) return this; + + this.config.collapsed = false; + this.element.classList.remove('panel-collapsed'); + + if (animate) { + this.bodyElement.style.transition = 'max-height 0.3s ease, opacity 0.3s ease'; + this.bodyElement.style.maxHeight = '0'; + this.bodyElement.style.opacity = '0'; + this.bodyElement.style.display = ''; + + requestAnimationFrame(() => { + this.bodyElement.style.maxHeight = this.bodyElement.scrollHeight + 'px'; + this.bodyElement.style.opacity = '1'; + + setTimeout(() => { + this.bodyElement.style.transition = ''; + this.bodyElement.style.maxHeight = ''; + }, 300); + }); + } else { + this.bodyElement.style.display = ''; + } + + this._updateState(); + + if (this.config.onExpand) { + this.config.onExpand(this); + } + + return this; + } + + /** + * Collapse the panel (if collapsible) + * @param {boolean} animate - Whether to animate the collapse + */ + collapse(animate = true) { + if (!this.config.collapsible || this.config.collapsed) return this; + + this.config.collapsed = true; + this.element.classList.add('panel-collapsed'); + + if (animate) { + this.bodyElement.style.transition = 'max-height 0.3s ease, opacity 0.3s ease'; + this.bodyElement.style.maxHeight = this.bodyElement.scrollHeight + 'px'; + this.bodyElement.style.opacity = '1'; + + requestAnimationFrame(() => { + this.bodyElement.style.maxHeight = '0'; + this.bodyElement.style.opacity = '0'; + + setTimeout(() => { + this.bodyElement.style.display = 'none'; + this.bodyElement.style.transition = ''; + this.bodyElement.style.maxHeight = ''; + this.bodyElement.style.opacity = ''; + }, 300); + }); + } else { + this.bodyElement.style.display = 'none'; + } + + this._updateState(); + + if (this.config.onCollapse) { + this.config.onCollapse(this); + } + + return this; + } + + /** + * Toggle panel collapsed state + * @param {boolean} animate - Whether to animate the toggle + */ + toggle(animate = true) { + if (this.config.collapsed) { + this.expand(animate); + } else { + this.collapse(animate); + } + + return this; + } + + /** + * Close/remove the panel + * @param {boolean} animate - Whether to animate the close + */ + close(animate = true) { + if (this.config.onClose) { + // Allow onClose to prevent closing by returning false + if (this.config.onClose(this) === false) { + return this; + } + } + + if (animate) { + this.element.style.transition = 'opacity 0.3s ease, transform 0.3s ease'; + this.element.style.opacity = '0'; + this.element.style.transform = 'translateY(-10px)'; + + setTimeout(() => { + this.remove(); + }, 300); + } else { + this.remove(); + } + + return this; + } + + /** + * Enable or disable collapsible functionality + * @param {boolean} collapsible - Whether panel can be collapsed + */ + setCollapsible(collapsible) { + this.config.collapsible = collapsible; + this.element.classList.toggle('panel-collapsible', collapsible); + + if (collapsible && !this.toggleButton) { + // Add toggle button + if (!this.headerElement) { + // Create header first + const headerHTML = ` +
+ ${this.config.title ? `

${this.config.title}

` : ''} +
+ +
+
+ `; + this.element.insertAdjacentHTML('afterbegin', headerHTML); + this.headerElement = this.element.querySelector('.panel-header'); + } else { + // Add to existing controls + let controlsDiv = this.headerElement.querySelector('.panel-controls'); + if (!controlsDiv) { + controlsDiv = document.createElement('div'); + controlsDiv.className = 'panel-controls'; + this.headerElement.appendChild(controlsDiv); + } + controlsDiv.insertAdjacentHTML('afterbegin', ''); + } + + this.toggleButton = this.element.querySelector('.panel-toggle'); + this._attachEventListeners(); + this._setupARIA(); + } else if (!collapsible && this.toggleButton) { + this.toggleButton.remove(); + this.toggleButton = null; + } + + return this; + } + + /** + * Show the panel + */ + show() { + this.element.style.display = ''; + return this; + } + + /** + * Hide the panel + */ + hide() { + this.element.style.display = 'none'; + return this; + } + + /** + * Add CSS class to panel + * @param {string} className - CSS class to add + */ + addClass(className) { + this.element.classList.add(className); + return this; + } + + /** + * Remove CSS class from panel + * @param {string} className - CSS class to remove + */ + removeClass(className) { + this.element.classList.remove(className); + return this; + } + + /** + * Get the DOM element + * @returns {HTMLElement} - The panel element + */ + getElement() { + return this.element; + } + + /** + * Get panel header element + * @returns {HTMLElement|null} - The header element + */ + getHeader() { + return this.headerElement; + } + + /** + * Get panel body element + * @returns {HTMLElement} - The body element + */ + getBody() { + return this.bodyElement; + } + + /** + * Append panel to a container + * @param {HTMLElement} container - Container to append to + */ + appendTo(container) { + if (container && container.appendChild) { + container.appendChild(this.element); + } + return this; + } + + /** + * Remove panel from DOM + */ + remove() { + if (this.element && this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + } + return this; + } + + /** + * Destroy the panel and clean up + */ + destroy() { + this.remove(); + this.element = null; + this.headerElement = null; + this.titleElement = null; + this.bodyElement = null; + this.toggleButton = null; + this.closeButton = null; + this.config = null; + } + + // Static factory methods + + /** + * Create an info panel + * @param {string} title - Panel title + * @param {string} content - Panel content + * @param {Object} options - Additional options + * @returns {Panel} - Panel instance + */ + static info(title, content, options = {}) { + return new Panel({ + title, + content, + type: 'info', + ...options + }); + } + + /** + * Create a hint panel (collapsible by default) + * @param {string} content - Hint content + * @param {Object} options - Additional options + * @returns {Panel} - Panel instance + */ + static hint(content, options = {}) { + return new Panel({ + title: 'Hint', + content, + type: 'hint', + collapsible: true, + collapsed: true, + ...options + }); + } + + /** + * Create an explanation panel + * @param {string} title - Explanation title + * @param {string} content - Explanation content + * @param {Object} options - Additional options + * @returns {Panel} - Panel instance + */ + static explanation(title, content, options = {}) { + return new Panel({ + title, + content, + type: 'explanation', + ...options + }); + } + + /** + * Create a warning panel + * @param {string} message - Warning message + * @param {Object} options - Additional options + * @returns {Panel} - Panel instance + */ + static warning(message, options = {}) { + return new Panel({ + content: message, + type: 'warning', + ...options + }); + } + + /** + * Create a success panel + * @param {string} message - Success message + * @param {Object} options - Additional options + * @returns {Panel} - Panel instance + */ + static success(message, options = {}) { + return new Panel({ + content: message, + type: 'success', + ...options + }); + } +} + +export default Panel; \ No newline at end of file diff --git a/src/components/ProgressBar.js b/src/components/ProgressBar.js new file mode 100644 index 0000000..b86302a --- /dev/null +++ b/src/components/ProgressBar.js @@ -0,0 +1,467 @@ +/** + * ProgressBar Component - Reusable progress indicator + * Extracted from DRS module patterns (question progress, loading, etc.) + */ + +class ProgressBar { + constructor(options = {}) { + // Default configuration + this.config = { + value: options.value || 0, // 0-100 + max: options.max || 100, + min: options.min || 0, + animated: options.animated !== false, // true by default + striped: options.striped || false, + showLabel: options.showLabel !== false, // true by default + label: options.label || null, // custom label, defaults to percentage + size: options.size || 'normal', // sm, normal, lg + color: options.color || 'primary', // primary, success, warning, danger, info + className: options.className || '', + id: options.id || null, + ...options + }; + + this.element = null; + this.progressFill = null; + this.progressLabel = null; + + this._createProgressBar(); + Object.seal(this); + } + + /** + * Create the progress bar element + * @private + */ + _createProgressBar() { + this.element = document.createElement('div'); + this.element.className = this._getProgressClasses(); + + if (this.config.id) { + this.element.id = this.config.id; + } + + // Create progress structure + this.element.innerHTML = this._getProgressHTML(); + + // Get references to inner elements + this.progressFill = this.element.querySelector('.progress-fill'); + this.progressLabel = this.element.querySelector('.progress-label'); + + this._updateProgress(); + this._setupARIA(); + } + + /** + * Get CSS classes for the progress bar + * @returns {string} - Space-separated CSS classes + * @private + */ + _getProgressClasses() { + const classes = ['progress-bar']; + + // Size classes + if (this.config.size !== 'normal') { + classes.push(`progress-bar-${this.config.size}`); + } + + // Animation classes + if (this.config.animated) { + classes.push('progress-bar-animated'); + } + + if (this.config.striped) { + classes.push('progress-bar-striped'); + } + + // Custom classes + if (this.config.className) { + classes.push(this.config.className); + } + + return classes.join(' '); + } + + /** + * Get progress bar HTML structure + * @returns {string} - HTML string + * @private + */ + _getProgressHTML() { + return ` +
+
+
+ ${this.config.showLabel ? '
' : ''} + `; + } + + /** + * Update progress value and display + * @private + */ + _updateProgress() { + const percentage = this._calculatePercentage(); + + // Update fill width + if (this.progressFill) { + this.progressFill.style.width = `${percentage}%`; + } + + // Update label + if (this.progressLabel && this.config.showLabel) { + this.progressLabel.textContent = this._getDisplayLabel(); + } + + // Update ARIA + this._updateARIA(); + } + + /** + * Calculate percentage value + * @returns {number} - Percentage (0-100) + * @private + */ + _calculatePercentage() { + const { value, min, max } = this.config; + const clampedValue = Math.max(min, Math.min(max, value)); + return ((clampedValue - min) / (max - min)) * 100; + } + + /** + * Get display label text + * @returns {string} - Label text + * @private + */ + _getDisplayLabel() { + if (this.config.label !== null) { + return this.config.label; + } + + return `${Math.round(this._calculatePercentage())}%`; + } + + /** + * Setup ARIA attributes for accessibility + * @private + */ + _setupARIA() { + this.element.setAttribute('role', 'progressbar'); + this._updateARIA(); + } + + /** + * Update ARIA attributes + * @private + */ + _updateARIA() { + this.element.setAttribute('aria-valuenow', this.config.value); + this.element.setAttribute('aria-valuemin', this.config.min); + this.element.setAttribute('aria-valuemax', this.config.max); + + if (this.config.label) { + this.element.setAttribute('aria-label', this.config.label); + } else { + this.element.setAttribute('aria-label', `${Math.round(this._calculatePercentage())}% complete`); + } + } + + // Public API + + /** + * Set progress value + * @param {number} value - New progress value + * @param {boolean} animate - Whether to animate the change + */ + setValue(value, animate = true) { + this.config.value = value; + + if (animate && this.config.animated) { + this._animateProgress(); + } else { + this._updateProgress(); + } + return this; + } + + /** + * Get current progress value + * @returns {number} - Current value + */ + getValue() { + return this.config.value; + } + + /** + * Set progress to a percentage + * @param {number} percentage - Percentage (0-100) + * @param {boolean} animate - Whether to animate + */ + setPercentage(percentage, animate = true) { + const value = this.config.min + ((percentage / 100) * (this.config.max - this.config.min)); + return this.setValue(value, animate); + } + + /** + * Get current percentage + * @returns {number} - Percentage (0-100) + */ + getPercentage() { + return this._calculatePercentage(); + } + + /** + * Set custom label + * @param {string|null} label - Label text or null for percentage + */ + setLabel(label) { + this.config.label = label; + this._updateProgress(); + return this; + } + + /** + * Show or hide the label + * @param {boolean} show - Whether to show label + */ + showLabel(show = true) { + this.config.showLabel = show; + + if (show && !this.progressLabel) { + // Add label if it doesn't exist + const labelElement = document.createElement('div'); + labelElement.className = 'progress-label'; + this.element.appendChild(labelElement); + this.progressLabel = labelElement; + } else if (!show && this.progressLabel) { + // Hide label + this.progressLabel.style.display = 'none'; + } else if (show && this.progressLabel) { + // Show existing label + this.progressLabel.style.display = ''; + } + + this._updateProgress(); + return this; + } + + /** + * Set progress bar color + * @param {string} color - Color theme (primary, success, warning, danger, info) + */ + setColor(color) { + if (this.progressFill) { + // Remove old color class + this.progressFill.className = this.progressFill.className + .replace(/progress-fill-\w+/g, ''); + + // Add new color class + this.progressFill.classList.add(`progress-fill-${color}`); + } + this.config.color = color; + return this; + } + + /** + * Enable or disable animation + * @param {boolean} animated - Whether to animate + */ + setAnimated(animated) { + this.config.animated = animated; + this.element.classList.toggle('progress-bar-animated', animated); + return this; + } + + /** + * Enable or disable striped pattern + * @param {boolean} striped - Whether to show stripes + */ + setStriped(striped) { + this.config.striped = striped; + this.element.classList.toggle('progress-bar-striped', striped); + return this; + } + + /** + * Animate progress change smoothly + * @private + */ + _animateProgress() { + if (!this.progressFill) return; + + const targetPercentage = this._calculatePercentage(); + const currentWidth = parseFloat(this.progressFill.style.width) || 0; + + // Use CSS transition for smooth animation + this.progressFill.style.transition = 'width 0.5s ease'; + this.progressFill.style.width = `${targetPercentage}%`; + + // Update label after animation + setTimeout(() => { + if (this.progressLabel && this.config.showLabel) { + this.progressLabel.textContent = this._getDisplayLabel(); + } + this._updateARIA(); + }, 500); + } + + /** + * Increment progress by a certain amount + * @param {number} amount - Amount to increment + * @param {boolean} animate - Whether to animate + */ + increment(amount = 1, animate = true) { + return this.setValue(this.config.value + amount, animate); + } + + /** + * Decrement progress by a certain amount + * @param {number} amount - Amount to decrement + * @param {boolean} animate - Whether to animate + */ + decrement(amount = 1, animate = true) { + return this.setValue(this.config.value - amount, animate); + } + + /** + * Reset progress to minimum value + * @param {boolean} animate - Whether to animate + */ + reset(animate = true) { + return this.setValue(this.config.min, animate); + } + + /** + * Set progress to maximum value + * @param {boolean} animate - Whether to animate + */ + complete(animate = true) { + return this.setValue(this.config.max, animate); + } + + /** + * Check if progress is complete + * @returns {boolean} - Whether progress is at maximum + */ + isComplete() { + return this.config.value >= this.config.max; + } + + /** + * Show the progress bar + */ + show() { + this.element.style.display = ''; + return this; + } + + /** + * Hide the progress bar + */ + hide() { + this.element.style.display = 'none'; + return this; + } + + /** + * Get the DOM element + * @returns {HTMLElement} - The progress bar element + */ + getElement() { + return this.element; + } + + /** + * Append progress bar to a container + * @param {HTMLElement} container - Container to append to + */ + appendTo(container) { + if (container && container.appendChild) { + container.appendChild(this.element); + } + return this; + } + + /** + * Remove progress bar from DOM + */ + remove() { + if (this.element && this.element.parentNode) { + this.element.parentNode.removeChild(this.element); + } + return this; + } + + /** + * Destroy the progress bar and clean up + */ + destroy() { + this.remove(); + this.element = null; + this.progressFill = null; + this.progressLabel = null; + this.config = null; + } + + // Static factory methods + + /** + * Create a primary progress bar + * @param {number} value - Initial value + * @param {Object} options - Additional options + * @returns {ProgressBar} - ProgressBar instance + */ + static primary(value = 0, options = {}) { + return new ProgressBar({ + value, + color: 'primary', + ...options + }); + } + + /** + * Create a success progress bar + * @param {number} value - Initial value + * @param {Object} options - Additional options + * @returns {ProgressBar} - ProgressBar instance + */ + static success(value = 0, options = {}) { + return new ProgressBar({ + value, + color: 'success', + ...options + }); + } + + /** + * Create a warning progress bar + * @param {number} value - Initial value + * @param {Object} options - Additional options + * @returns {ProgressBar} - ProgressBar instance + */ + static warning(value = 0, options = {}) { + return new ProgressBar({ + value, + color: 'warning', + ...options + }); + } + + /** + * Create a loading progress bar with indeterminate animation + * @param {Object} options - Additional options + * @returns {ProgressBar} - ProgressBar instance + */ + static loading(options = {}) { + return new ProgressBar({ + value: 50, + animated: true, + striped: true, + showLabel: false, + className: 'progress-bar-indeterminate', + ...options + }); + } +} + +export default ProgressBar; \ No newline at end of file diff --git a/src/components/SettingsDebug.js b/src/components/SettingsDebug.js new file mode 100644 index 0000000..8cd60f9 --- /dev/null +++ b/src/components/SettingsDebug.js @@ -0,0 +1,851 @@ +import Module from '../core/Module.js'; + +class SettingsDebug extends Module { + constructor(name, dependencies, config) { + super(name, ['eventBus', 'router']); + + if (!dependencies.eventBus || !dependencies.router) { + throw new Error('SettingsDebug requires EventBus and Router dependencies'); + } + + this._eventBus = dependencies.eventBus; + this._router = dependencies.router; + this._config = config || {}; + + // Internal state + this._container = null; + this._debugMessages = []; + this._availableVoices = []; + this._ttsSettings = { + rate: 0.8, + volume: 1.0, + selectedVoice: null + }; + + Object.seal(this); + } + + async init() { + this._validateNotDestroyed(); + + this._loadTTSSettings(); + this._injectCSS(); + this._setupEventListeners(); + this._exposePublicAPI(); + + this._setInitialized(); + } + + async destroy() { + this._validateNotDestroyed(); + + if (this._container) { + this._container.innerHTML = ''; + } + + this._removeInjectedCSS(); + this._eventBus.off('settings:show', this._handleShowSettings.bind(this), this.name); + + this._setDestroyed(); + } + + // Public API + show(container) { + this._validateInitialized(); + + this._container = container; + this._render(); + this._loadVoices(); + this._updateBrowserInfo(); + this._addDebugMessage('Settings/Debug panel opened', 'info'); + } + + hide() { + this._validateInitialized(); + + if (this._container) { + this._container.innerHTML = ''; + this._container = null; + } + } + + // Private methods + _setupEventListeners() { + this._eventBus.on('settings:show', this._handleShowSettings.bind(this), this.name); + this._eventBus.on('router:navigate', this._handleNavigation.bind(this), this.name); + this._eventBus.on('navigation:settings', this._handleNavigationSettings.bind(this), this.name); + } + + _handleShowSettings(event) { + if (event.data && event.data.container) { + this.show(event.data.container); + } + } + + _handleNavigation(event) { + if (event.data && event.data.path !== '/settings') { + this.hide(); + } + } + + _handleNavigationSettings(event) { + // Find the main container or create one + let container = document.getElementById('main-content'); + if (!container) { + container = document.querySelector('main') || document.body; + } + + this.show(container); + } + + _loadTTSSettings() { + try { + const saved = localStorage.getItem('tts-settings'); + if (saved) { + this._ttsSettings = { ...this._ttsSettings, ...JSON.parse(saved) }; + } + } catch (e) { + this._addDebugMessage(`Failed to load TTS settings: ${e.message}`, 'warning'); + } + } + + _saveTTSSettings() { + try { + localStorage.setItem('tts-settings', JSON.stringify(this._ttsSettings)); + this._addDebugMessage('TTS settings saved', 'success'); + } catch (e) { + this._addDebugMessage(`Failed to save TTS settings: ${e.message}`, 'error'); + } + } + + _injectCSS() { + if (document.getElementById('settings-debug-styles')) return; + + const styleSheet = document.createElement('style'); + styleSheet.id = 'settings-debug-styles'; + styleSheet.textContent = ` + .settings-container { + max-width: 1000px; + margin: 0 auto; + padding: 20px; + display: flex; + flex-direction: column; + gap: 30px; + } + + .settings-section { + background: var(--card-background, #fff); + border-radius: 12px; + padding: 25px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + border: 1px solid var(--border-color, #e5e7eb); + } + + .settings-section h3 { + font-size: 1.4em; + margin-bottom: 20px; + color: var(--text-primary, #111827); + display: flex; + align-items: center; + gap: 8px; + } + + .setting-group { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 15px; + padding: 12px 0; + border-bottom: 1px solid #f0f0f0; + } + + .setting-group:last-child { + border-bottom: none; + margin-bottom: 0; + } + + .setting-group label { + font-weight: 500; + color: var(--text-primary, #111827); + min-width: 140px; + } + + .setting-group input[type="range"] { + flex: 1; + margin: 0 15px; + accent-color: var(--primary-color, #3b82f6); + } + + .setting-group select { + min-width: 200px; + padding: 8px 12px; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 6px; + font-size: 14px; + background: white; + } + + .setting-group span { + min-width: 40px; + text-align: center; + font-weight: 600; + color: var(--primary-color, #3b82f6); + } + + .debug-info { + background: #f8f9fa; + border-radius: 8px; + padding: 15px; + margin-bottom: 20px; + } + + .info-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #e9ecef; + } + + .info-item:last-child { + border-bottom: none; + } + + .info-item .label { + font-weight: 500; + color: var(--text-secondary, #6b7280); + } + + .info-item .value { + font-weight: 600; + color: var(--text-primary, #111827); + } + + .info-item .value.small { + font-size: 0.85em; + max-width: 400px; + word-break: break-all; + } + + .debug-controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin-bottom: 20px; + } + + .debug-btn { + padding: 12px 16px; + border: none; + border-radius: 8px; + background: var(--primary-color, #3b82f6); + color: white; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + + .debug-btn:hover { + background: #2563eb; + transform: translateY(-2px); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + } + + .debug-btn:active { + transform: translateY(0); + } + + .debug-output { + background: #1a1a1a; + border-radius: 8px; + padding: 15px; + color: #e0e0e0; + } + + .debug-output h4 { + color: #ffffff; + margin-bottom: 10px; + font-size: 1.1em; + } + + .debug-log { + min-height: 120px; + max-height: 300px; + overflow-y: auto; + font-family: 'Courier New', monospace; + font-size: 13px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + background: #000000; + padding: 10px; + border-radius: 4px; + margin-bottom: 10px; + } + + .debug-log:empty::before { + content: "No debug output yet. Click test buttons above."; + color: #888; + font-style: italic; + } + + .clear-btn { + padding: 6px 12px; + border: 1px solid #555; + border-radius: 4px; + background: #333; + color: #fff; + font-size: 12px; + cursor: pointer; + transition: all 0.3s ease; + } + + .clear-btn:hover { + background: #444; + border-color: #666; + } + + .voice-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; + max-height: 300px; + overflow-y: auto; + } + + .voice-item { + background: #f8f9fa; + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 8px; + padding: 12px; + transition: all 0.3s ease; + cursor: pointer; + } + + .voice-item:hover { + background: #e9ecef; + border-color: var(--primary-color, #3b82f6); + } + + .voice-item.selected { + background: var(--primary-light, #dbeafe); + border-color: var(--primary-color, #3b82f6); + } + + .voice-name { + font-weight: 600; + color: var(--text-primary, #111827); + margin-bottom: 4px; + } + + .voice-lang { + font-size: 0.9em; + color: var(--text-secondary, #6b7280); + margin-bottom: 4px; + } + + .voice-type { + font-size: 0.8em; + color: var(--accent-color, #f59e0b); + font-weight: 500; + } + + .debug-log .success { color: #4ade80; } + .debug-log .error { color: #f87171; } + .debug-log .warning { color: #fbbf24; } + .debug-log .info { color: #60a5fa; } + + @media (max-width: 768px) { + .settings-container { + padding: 15px; + gap: 20px; + } + + .setting-group { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .setting-group input[type="range"] { + width: 100%; + margin: 10px 0; + } + + .debug-controls { + grid-template-columns: 1fr; + } + + .voice-list { + grid-template-columns: 1fr; + } + } + `; + document.head.appendChild(styleSheet); + } + + _removeInjectedCSS() { + const styleSheet = document.getElementById('settings-debug-styles'); + if (styleSheet) { + styleSheet.remove(); + } + } + + _render() { + if (!this._container) return; + + this._container.innerHTML = ` +
+ +
+

🔧 System Information

+
+
+ Application Status: + Running +
+
+ Modules Loaded: + 0 +
+
+ EventBus Status: + Active +
+
+ Current Route: + /settings +
+
+ Browser Support: + Checking... +
+
+
+ + +
+

🔊 Text-to-Speech Settings

+
+ + + ${this._ttsSettings.rate} +
+
+ + + ${this._ttsSettings.volume} +
+
+ + +
+
+ + +
+

🎤 Voice Information

+
+
+ Total Voices: + 0 +
+
+ English Voices: + 0 +
+
+
+
+ + +
+

🧪 Debug Controls

+
+ + + + +
+
+ + +
+
+

Debug Log

+
+ +
+
+ + +
+

🌐 Browser Information

+
+
+ User Agent: + +
+
+ Platform: + +
+
+ Language: + +
+
+
+
+ `; + + this._setupControlListeners(); + this._updateSystemInfo(); + } + + _setupControlListeners() { + // TTS Rate slider + const rateSlider = document.getElementById('tts-rate'); + if (rateSlider) { + rateSlider.addEventListener('input', (e) => { + this._ttsSettings.rate = parseFloat(e.target.value); + document.getElementById('tts-rate-value').textContent = this._ttsSettings.rate; + this._saveTTSSettings(); + }); + } + + // Volume slider + const volumeSlider = document.getElementById('tts-volume'); + if (volumeSlider) { + volumeSlider.addEventListener('input', (e) => { + this._ttsSettings.volume = parseFloat(e.target.value); + document.getElementById('tts-volume-value').textContent = this._ttsSettings.volume; + this._saveTTSSettings(); + }); + } + + // Voice selection + const voiceSelect = document.getElementById('tts-voice'); + if (voiceSelect) { + voiceSelect.addEventListener('change', (e) => { + this._ttsSettings.selectedVoice = e.target.value; + this._saveTTSSettings(); + }); + } + } + + _updateSystemInfo() { + // Get application instance from global + if (window.app) { + const status = window.app.getStatus(); + const moduleLoader = window.app.getCore()?.moduleLoader; + + if (status) { + document.getElementById('app-status').textContent = status.status; + } + + if (moduleLoader) { + const moduleStatus = moduleLoader.getStatus(); + document.getElementById('modules-count').textContent = moduleStatus?.loaded?.length || 0; + } + } + + // Current route + document.getElementById('current-route').textContent = window.location.pathname || '/'; + + // EventBus status + const eventBusStatus = this._eventBus ? 'Active' : 'Inactive'; + document.getElementById('eventbus-status').textContent = eventBusStatus; + } + + _updateBrowserInfo() { + const elements = { + 'user-agent': navigator.userAgent, + 'platform': navigator.platform, + 'browser-language': navigator.language + }; + + Object.entries(elements).forEach(([id, value]) => { + const element = document.getElementById(id); + if (element) { + element.textContent = value; + } + }); + + this._checkBrowserSupport(); + } + + _checkBrowserSupport() { + const checks = [ + { name: 'speechSynthesis', available: 'speechSynthesis' in window }, + { name: 'SpeechSynthesisUtterance', available: 'SpeechSynthesisUtterance' in window }, + { name: 'getVoices', available: speechSynthesis && typeof speechSynthesis.getVoices === 'function' }, + { name: 'speak', available: speechSynthesis && typeof speechSynthesis.speak === 'function' } + ]; + + const support = checks.every(check => check.available); + const supportElement = document.getElementById('browser-support'); + if (supportElement) { + supportElement.textContent = support ? '✅ Full Support' : '❌ Limited Support'; + supportElement.style.color = support ? '#22C55E' : '#EF4444'; + } + + this._addDebugMessage(`Browser TTS Support: ${support ? 'Full' : 'Limited'}`, support ? 'success' : 'warning'); + return support; + } + + _loadVoices() { + const loadVoicesImpl = () => { + this._availableVoices = speechSynthesis.getVoices(); + this._updateVoiceInfo(); + this._populateVoiceSelect(); + this._displayVoiceList(); + }; + + loadVoicesImpl(); + setTimeout(loadVoicesImpl, 100); + + if (speechSynthesis.onvoiceschanged !== undefined) { + speechSynthesis.onvoiceschanged = loadVoicesImpl; + } + } + + _updateVoiceInfo() { + const voiceCountElement = document.getElementById('voice-count'); + const englishVoiceCountElement = document.getElementById('english-voice-count'); + + if (voiceCountElement) { + voiceCountElement.textContent = this._availableVoices.length; + } + + const englishVoices = this._availableVoices.filter(voice => voice.lang.startsWith('en')); + if (englishVoiceCountElement) { + englishVoiceCountElement.textContent = englishVoices.length; + } + } + + _populateVoiceSelect() { + const voiceSelect = document.getElementById('tts-voice'); + if (!voiceSelect) return; + + voiceSelect.innerHTML = ''; + + const englishVoices = this._availableVoices.filter(voice => voice.lang.startsWith('en')); + + englishVoices.forEach(voice => { + const option = document.createElement('option'); + option.value = voice.name; + option.textContent = `${voice.name} (${voice.lang})`; + if (voice.name === this._ttsSettings.selectedVoice) { + option.selected = true; + } + voiceSelect.appendChild(option); + }); + } + + _displayVoiceList() { + const voiceListElement = document.getElementById('voice-list'); + if (!voiceListElement) return; + + if (this._availableVoices.length === 0) { + voiceListElement.innerHTML = '
No voices available
'; + return; + } + + voiceListElement.innerHTML = ''; + this._availableVoices.forEach(voice => { + const voiceItem = document.createElement('div'); + voiceItem.className = 'voice-item'; + voiceItem.innerHTML = ` +
${voice.name}
+
${voice.lang}
+
${voice.localService ? 'Local' : 'Remote'}
+ `; + + voiceItem.addEventListener('click', () => { + this._testVoice(voice); + document.querySelectorAll('.voice-item').forEach(item => item.classList.remove('selected')); + voiceItem.classList.add('selected'); + }); + + voiceListElement.appendChild(voiceItem); + }); + } + + _testVoice(voice) { + try { + const utterance = new SpeechSynthesisUtterance('Hello, this is a voice test'); + utterance.voice = voice; + utterance.rate = this._ttsSettings.rate; + utterance.volume = this._ttsSettings.volume; + + utterance.onstart = () => { + this._addDebugMessage(`Testing voice: ${voice.name}`, 'info'); + }; + + utterance.onerror = (event) => { + this._addDebugMessage(`Voice test error: ${event.error}`, 'error'); + }; + + speechSynthesis.speak(utterance); + } catch (error) { + this._addDebugMessage(`Voice test failed: ${error.message}`, 'error'); + } + } + + _addDebugMessage(message, type = 'info') { + const timestamp = new Date().toLocaleTimeString(); + const logEntry = `[${timestamp}] ${message}`; + + this._debugMessages.push({ message: logEntry, type }); + this._updateDebugDisplay(); + + console.log(`[Settings] ${logEntry}`); + } + + _updateDebugDisplay() { + const debugLogElement = document.getElementById('debug-log'); + if (!debugLogElement) return; + + const lastEntries = this._debugMessages.slice(-50); + debugLogElement.innerHTML = lastEntries + .map(entry => `${entry.message}`) + .join('\n'); + + debugLogElement.scrollTop = debugLogElement.scrollHeight; + } + + // Public test methods (exposed via window.settingsDebug) + testBasicTTS() { + this._addDebugMessage('Testing basic TTS...', 'info'); + this._speak('Hello world, this is a basic test') + .then(() => this._addDebugMessage('✅ Basic TTS test completed', 'success')) + .catch(error => this._addDebugMessage(`❌ Basic TTS test failed: ${error.message}`, 'error')); + } + + testGameWords() { + this._addDebugMessage('Testing game vocabulary words...', 'info'); + const words = ['apple', 'cat', 'house', 'car', 'tree', 'book', 'sun', 'dog']; + + let index = 0; + const speakNext = () => { + if (index >= words.length) { + this._addDebugMessage('✅ Game words test completed', 'success'); + return; + } + + const word = words[index++]; + this._speak(word) + .then(() => { + this._addDebugMessage(`✅ Spoke: ${word}`, 'info'); + setTimeout(speakNext, 500); + }) + .catch(error => { + this._addDebugMessage(`❌ Failed to speak ${word}: ${error.message}`, 'error'); + setTimeout(speakNext, 500); + }); + }; + + speakNext(); + } + + refreshVoices() { + this._addDebugMessage('Refreshing voice list...', 'info'); + this._loadVoices(); + this._addDebugMessage('✅ Voice list refreshed', 'success'); + } + + testSystem() { + this._addDebugMessage('Testing system components...', 'info'); + + // Test EventBus + if (this._eventBus) { + this._addDebugMessage('✅ EventBus: Active', 'success'); + } else { + this._addDebugMessage('❌ EventBus: Not found', 'error'); + } + + // Test Router + if (this._router) { + this._addDebugMessage('✅ Router: Active', 'success'); + } else { + this._addDebugMessage('❌ Router: Not found', 'error'); + } + + // Test Application + if (window.app) { + this._addDebugMessage('✅ Application: Running', 'success'); + } else { + this._addDebugMessage('❌ Application: Not found', 'error'); + } + + this._addDebugMessage('System test completed', 'info'); + this._updateSystemInfo(); + } + + clearDebugLog() { + this._debugMessages = []; + this._updateDebugDisplay(); + this._addDebugMessage('Debug log cleared', 'info'); + } + + _exposePublicAPI() { + // Expose API for debug buttons to use + window.settingsDebug = { + testBasicTTS: () => this.testBasicTTS(), + testGameWords: () => this.testGameWords(), + refreshVoices: () => this.refreshVoices(), + testSystem: () => this.testSystem(), + clearDebugLog: () => this.clearDebugLog() + }; + } + + _speak(text, options = {}) { + return new Promise((resolve, reject) => { + try { + if (!('speechSynthesis' in window)) { + reject(new Error('Speech synthesis not supported')); + return; + } + + const utterance = new SpeechSynthesisUtterance(text); + + utterance.rate = options.rate || this._ttsSettings.rate; + utterance.volume = options.volume || this._ttsSettings.volume; + utterance.lang = options.lang || 'en-US'; + + if (this._ttsSettings.selectedVoice) { + const selectedVoice = this._availableVoices.find( + voice => voice.name === this._ttsSettings.selectedVoice + ); + if (selectedVoice) { + utterance.voice = selectedVoice; + } + } + + utterance.onend = () => resolve(); + utterance.onerror = (event) => reject(new Error(event.error)); + + speechSynthesis.speak(utterance); + } catch (error) { + reject(error); + } + }); + } +} + +export default SettingsDebug; \ No newline at end of file diff --git a/src/core/ContentLoader.js b/src/core/ContentLoader.js new file mode 100644 index 0000000..b56397e --- /dev/null +++ b/src/core/ContentLoader.js @@ -0,0 +1,2226 @@ +/** + * ContentLoader - Loads exercise content for DRS modules + * Provides consistent interface for loading educational content + */ + +import Module from './Module.js'; + +class ContentLoader extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); + + if (!dependencies.eventBus) { + throw new Error('ContentLoader requires EventBus dependency'); + } + + this._eventBus = dependencies.eventBus; + this._config = { + contentPath: config.contentPath || './content/', + exerciseTypes: config.exerciseTypes || ['text', 'audio', 'image', 'grammar'], + difficulties: config.difficulties || ['easy', 'medium', 'hard'], + ...config + }; + + // Content cache + this._contentCache = new Map(); + this._loadingPromises = new Map(); + + Object.seal(this); + } + + async init() { + this._validateNotDestroyed(); + + console.log('📚 Initializing ContentLoader...'); + + // Set up event listeners + this._eventBus.on('content:request', this._handleContentRequest.bind(this), this.name); + this._eventBus.on('content:clear-cache', this._handleClearCache.bind(this), this.name); + + this._setInitialized(); + console.log('✅ ContentLoader initialized'); + } + + async destroy() { + this._validateNotDestroyed(); + + console.log('🧹 Destroying ContentLoader...'); + + // Clear cache + this._contentCache.clear(); + this._loadingPromises.clear(); + + this._setDestroyed(); + console.log('✅ ContentLoader destroyed'); + } + + // Public API + + /** + * Load exercise content + * @param {Object} request - Content request + * @returns {Promise} - Exercise content + */ + async loadExercise(request) { + this._validateInitialized(); + + const { type, subtype, difficulty = 'medium', ...options } = request; + + console.log(`📖 Loading ${subtype || type} exercise (${difficulty})...`); + + try { + // Generate cache key + const cacheKey = this._generateCacheKey(request); + + // Check cache first + if (this._contentCache.has(cacheKey)) { + console.log(`💾 Found cached content for ${cacheKey}`); + return this._contentCache.get(cacheKey); + } + + // Check if already loading + if (this._loadingPromises.has(cacheKey)) { + console.log(`⏳ Already loading ${cacheKey}, waiting...`); + return await this._loadingPromises.get(cacheKey); + } + + // Load content + const loadingPromise = this._loadContent(request); + this._loadingPromises.set(cacheKey, loadingPromise); + + try { + const content = await loadingPromise; + + // Cache the result + this._contentCache.set(cacheKey, content); + + // Emit success event + this._eventBus.emit('content:loaded', { + request, + content, + cached: false + }, this.name); + + console.log(`✅ Content loaded: ${content.title || cacheKey}`); + return content; + + } finally { + this._loadingPromises.delete(cacheKey); + } + + } catch (error) { + console.error(`❌ Failed to load exercise content:`, error); + + // Emit error event + this._eventBus.emit('content:error', { + request, + error: error.message + }, this.name); + + throw error; + } + } + + /** + * Get available content types + * @returns {Object} - Available content information + */ + getAvailableContent() { + return { + types: [...this._config.exerciseTypes], + difficulties: [...this._config.difficulties], + cached: this._contentCache.size, + loading: this._loadingPromises.size + }; + } + + /** + * Clear content cache + * @param {string} pattern - Optional pattern to match keys + */ + clearCache(pattern = null) { + if (pattern) { + const regex = new RegExp(pattern); + for (const [key] of this._contentCache) { + if (regex.test(key)) { + this._contentCache.delete(key); + } + } + console.log(`🧹 Cleared cache entries matching: ${pattern}`); + } else { + this._contentCache.clear(); + console.log('🧹 Cleared entire content cache'); + } + + this._eventBus.emit('content:cache-cleared', { pattern }, this.name); + } + + // Private methods + + /** + * Generate cache key for request + * @param {Object} request - Content request + * @returns {string} - Cache key + * @private + */ + _generateCacheKey(request) { + const { type, subtype, difficulty, ...options } = request; + const key = `${type}_${subtype || 'default'}_${difficulty}`; + + // Add hash of options if present + if (Object.keys(options).length > 0) { + const optionsHash = JSON.stringify(options); + return `${key}_${this._simpleHash(optionsHash)}`; + } + + return key; + } + + /** + * Simple hash function for options + * @param {string} str - String to hash + * @returns {string} - Hash + * @private + */ + _simpleHash(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32-bit integer + } + return Math.abs(hash).toString(16); + } + + /** + * Load content based on request type + * @param {Object} request - Content request + * @returns {Promise} - Content data + * @private + */ + async _loadContent(request) { + const { subtype, bookId, chapterId } = request; + + // Load real content from file system first + try { + const contentPath = `/content/chapters/${chapterId || bookId}.json`; + console.log(`📂 Loading content from: ${contentPath}`); + + const response = await fetch(contentPath); + if (!response.ok) { + console.warn(`❌ Failed to load content from ${contentPath}, using mock content`); + return this._generateMockContent(request); + } + + const realContent = await response.json(); + console.log('📚 Real content loaded from', contentPath); + console.log('📊 Content structure:', { + hasVocabulary: !!realContent.vocabulary, + vocabularyCount: realContent.vocabulary ? Object.keys(realContent.vocabulary).length : 0, + contentKeys: Object.keys(realContent) + }); + + // Generate exercise based on real content + return this._generateExerciseFromRealContent(realContent, request); + + } catch (error) { + console.warn(`❌ Error loading real content:`, error); + console.log('🔄 Falling back to mock content'); + return this._generateMockContent(request); + } + } + + /** + * Generate exercise from real content + */ + _generateExerciseFromRealContent(realContent, request) { + const { subtype, difficulty } = request; + + switch (subtype) { + case 'text': + // Text = vocabulary questions + return this._generateTextFromRealContent(realContent, difficulty); + case 'reading': + // Reading = comprehension with real passages + return this._generateReadingFromRealContent(realContent, difficulty); + case 'audio': + return this._generateAudioFromRealContent(realContent, difficulty); + case 'image': + return this._generateImageFromRealContent(realContent, difficulty); + case 'grammar': + return this._generateGrammarFromRealContent(realContent, difficulty); + default: + throw new Error(`Unknown exercise subtype: ${subtype}`); + } + } + + /** + * Fallback to mock content + */ + _generateMockContent(request) { + const { subtype } = request; + + switch (subtype) { + case 'text': + return this._generateTextExercise(request); + case 'audio': + return this._generateAudioExercise(request); + case 'image': + return this._generateImageExercise(request); + case 'grammar': + return this._generateGrammarExercise(request); + default: + return this._generateDefaultExercise(request); + } + } + + // Real content generation methods + + /** + * Generate text exercise from real content + */ + async _generateTextFromRealContent(realContent, difficulty) { + console.log('🎯 Generating text comprehension exercise with AI'); + + // Check if we have sentences/texts for phrase-by-phrase analysis + if (realContent.sentences && realContent.sentences.length > 0) { + console.log('📖 Using sentences for phrase-by-phrase comprehension'); + return await this._generateSentenceComprehensionWithAI(realContent, difficulty); + } + + // Check for dialogue texts + if (realContent.dialogs && Object.keys(realContent.dialogs).length > 0) { + console.log('💬 Using dialogue texts for comprehension'); + return await this._generateDialogTextComprehensionWithAI(realContent, difficulty); + } + + // Fallback to vocabulary if no sentences/texts available + console.log('📚 Falling back to vocabulary comprehension'); + return this._generateVocabularyTextExercise(realContent, difficulty); + } + + _generateVocabularyTextExercise(realContent, difficulty) { + console.log('📚 Available vocabulary:', realContent.vocabulary ? Object.keys(realContent.vocabulary).length : 'NONE'); + + if (!realContent.vocabulary) { + console.error('❌ No vocabulary found in content!'); + throw new Error('No vocabulary found in content'); + } + + const vocab = Object.entries(realContent.vocabulary); + const numQuestions = this._getQuestionCount(difficulty); + + // Get or initialize used words for this content + const contentId = realContent.id || 'unknown'; + if (!this._usedWords) { + this._usedWords = new Map(); + } + + if (!this._usedWords.has(contentId)) { + this._usedWords.set(contentId, new Set()); + } + + const usedWordsSet = this._usedWords.get(contentId); + + // Filter out already used words + const availableWords = vocab.filter(([word]) => !usedWordsSet.has(word)); + + // If we've used most words, reset the used set but keep the last few + if (availableWords.length < numQuestions) { + console.log('🔄 Resetting used words set, keeping recent ones'); + const recentWords = Array.from(usedWordsSet).slice(-Math.floor(vocab.length * 0.3)); + usedWordsSet.clear(); + recentWords.forEach(word => usedWordsSet.add(word)); + } + + // Select from available words + const finalAvailable = vocab.filter(([word]) => !usedWordsSet.has(word)); + const selectedWords = this._selectRandomItems(finalAvailable, numQuestions); + + // Mark these words as used + selectedWords.forEach(([word]) => usedWordsSet.add(word)); + + console.log(`🎯 Selected ${selectedWords.length} NEW words for ${difficulty} difficulty (requested: ${numQuestions})`); + console.log('📝 Selected words:', selectedWords.map(([word]) => word)); + + const steps = selectedWords.map(([word, data], index) => ({ + id: `text-step-${index + 1}`, + type: 'multiple-choice', + content: { + passage: `What does "${word}" mean?`, + question: `Choose the correct definition for "${word}":`, + options: this._generateTextOptions(word, data, vocab), + correctAnswer: 0 // Real answer is always first, then shuffled + }, + hint: `Remember: "${word}" is a ${data.type}. Pronunciation: ${data.pronunciation || 'N/A'}` + })); + + // Shuffle options for each step + steps.forEach(step => { + const shuffled = this._shuffleOptions(step.content.options, step.content.correctAnswer); + step.content.options = shuffled.options; + step.content.correctAnswer = shuffled.correctAnswer; + }); + + return { + id: `text-exercise-${Date.now()}`, + type: 'text', + title: `Vocabulary: ${realContent.name}`, + description: `Learn vocabulary from ${realContent.name}`, + difficulty, + steps, + metadata: { + source: 'vocabulary-fallback', + chapterInfo: realContent.metadata + } + }; + } + + _generateReadingFromRealContent(realContent, difficulty) { + console.log('📖 Generating reading exercise from real content'); + + // Priority 1: Check if reading exercises are defined in JSON + if (realContent.exercises) { + const readingExercise = this._findReadingExerciseInJSON(realContent.exercises); + if (readingExercise) { + console.log('✅ Using predefined reading exercises from JSON'); + return this._convertJSONExerciseToReading(readingExercise, realContent, difficulty); + } + } + + // Priority 2: Use stories if available AND they have questions + if (realContent.stories && realContent.stories.length > 0) { + const storiesWithQuestions = realContent.stories.filter(story => story.questions && story.questions.length > 0); + if (storiesWithQuestions.length > 0) { + console.log('✅ Using real stories with questions from chapter content'); + return this._generateStoryReadingExercise(realContent, difficulty, storiesWithQuestions); + } + } + + // Priority 3: Use texts if available AND they have questions + if (realContent.texts && realContent.texts.length > 0) { + const textsWithQuestions = realContent.texts.filter(text => text.questions && text.questions.length > 0); + if (textsWithQuestions.length > 0) { + console.log('✅ Using real texts with questions from chapter content'); + return this._generateTextReadingExercise(realContent, difficulty, textsWithQuestions); + } + } + + // Priority 4: Use dialogs if available AND they have questions + if (realContent.dialogs && Object.keys(realContent.dialogs).length > 0) { + const dialogsWithQuestions = Object.values(realContent.dialogs).filter(dialog => + dialog.questions && dialog.questions.length > 0 + ); + if (dialogsWithQuestions.length > 0) { + console.log('✅ Using real dialogs with questions from chapter content'); + return this._generateDialogReadingExercise(realContent, difficulty, dialogsWithQuestions); + } + } + + // Priority 5: Use AI to generate questions for available content + console.log('🤖 No predefined questions found - attempting to use AI to generate reading questions'); + + try { + return await this._generateAIReadingExercise(realContent, difficulty); + } catch (error) { + console.log('⚠️ AI question generation failed - skipping reading exercises'); + throw new Error('SKIP_READING: No reading content with predefined questions available and AI generation failed'); + } + } + + _generateDialogReadingExercise(realContent, difficulty) { + const dialogs = Object.values(realContent.dialogs); + const numDialogs = difficulty === 'easy' ? 1 : Math.min(dialogs.length, 2); + const selectedDialogs = this._selectRandomItems(dialogs, numDialogs); + + const steps = []; + + selectedDialogs.forEach((dialog, dialogIndex) => { + // Create dialog text + const dialogText = this._formatDialogAsText(dialog); + + // Generate comprehension questions based on dialog content + const questions = this._generateDialogQuestions(dialog, difficulty); + + questions.forEach((questionData, questionIndex) => { + steps.push({ + id: `dialog-reading-${dialogIndex}-${questionIndex}`, + type: 'multiple-choice', + content: { + passage: dialogText, + question: questionData.question, + options: questionData.options, + correctAnswer: 0 + }, + hint: `Dialog: "${dialog.title}" - ${questionData.hint}` + }); + }); + }); + + // Shuffle options for each step + steps.forEach(step => { + const shuffled = this._shuffleOptions(step.content.options, step.content.correctAnswer); + step.content.options = shuffled.options; + step.content.correctAnswer = shuffled.correctAnswer; + }); + + return { + id: `dialog-reading-${Date.now()}`, + type: 'reading', + title: `Dialog Comprehension: ${realContent.name}`, + description: `Read real dialogues and answer comprehension questions`, + difficulty, + steps, + metadata: { + source: 'real-dialogs', + chapterInfo: realContent.metadata, + dialogsUsed: selectedDialogs.map(d => d.title) + } + }; + } + + _formatDialogAsText(dialog) { + const lines = dialog.lines.map(line => `${line.speaker}: ${line.text}`); + return `**${dialog.title}**\n\n${lines.join('\n')}`; + } + + _generateDialogQuestions(dialog, difficulty) { + const questions = []; + const vocab = this._extractVocabFromDialog(dialog); + + // Question 1: Who said what + const randomLine = dialog.lines[Math.floor(Math.random() * dialog.lines.length)]; + questions.push({ + question: `Who said: "${randomLine.text}"?`, + options: [ + randomLine.speaker, + ...dialog.participants.filter(p => p !== randomLine.speaker), + 'The narrator' + ].slice(0, 4), + hint: `Look for the speaker in the dialog` + }); + + // Question 2: Content comprehension + if (dialog.title.includes('Apartment')) { + questions.push({ + question: "What is Alex looking for?", + options: [ + "A two-bedroom apartment", + "A one-bedroom apartment", + "A house", + "A hotel room" + ], + hint: "Read Alex's first line carefully" + }); + } else if (dialog.title.includes('Clothes') || dialog.title.includes('Shopping')) { + questions.push({ + question: "What does the customer need?", + options: [ + "A shirt for work", + "A jacket for winter", + "Shoes for running", + "A dress for party" + ], + hint: "Check what the customer asks for" + }); + } else { + // Generic content question + const firstLine = dialog.lines[0]; + questions.push({ + question: `According to the dialog, what does ${firstLine.speaker} want?`, + options: [ + firstLine.text, + "Something different", + "Nothing specific", + "To leave" + ], + hint: `Focus on ${firstLine.speaker}'s first statement` + }); + } + + return questions.slice(0, difficulty === 'easy' ? 1 : 2); + } + + _extractVocabFromDialog(dialog) { + // Extract vocabulary words used in the dialog + const dialogText = dialog.lines.map(line => line.text.toLowerCase()).join(' '); + const words = dialogText.match(/\b\w+\b/g) || []; + return [...new Set(words)]; + } + + _generateStoryReadingExercise(realContent, difficulty) { + const stories = realContent.stories; + const numStories = difficulty === 'easy' ? 1 : Math.min(stories.length, 2); + const selectedStories = this._selectRandomItems(stories, numStories); + + const steps = []; + + selectedStories.forEach((story, storyIndex) => { + // Generate comprehension questions based on story content + const questions = this._generateStoryQuestions(story, difficulty); + + questions.forEach((questionData, questionIndex) => { + steps.push({ + id: `story-reading-${storyIndex}-${questionIndex}`, + type: 'multiple-choice', + content: { + passage: this._formatStoryText(story), + question: questionData.question, + options: questionData.options, + correctAnswer: 0 + }, + hint: `Story: "${story.title}" - ${questionData.hint}` + }); + }); + }); + + // Shuffle options for each step + steps.forEach(step => { + const shuffled = this._shuffleOptions(step.content.options, step.content.correctAnswer); + step.content.options = shuffled.options; + step.content.correctAnswer = shuffled.correctAnswer; + }); + + return { + id: `story-reading-${Date.now()}`, + type: 'reading', + title: `Story Comprehension: ${realContent.name}`, + description: `Read real stories and answer comprehension questions`, + difficulty, + steps, + metadata: { + source: 'real-stories', + chapterInfo: realContent.metadata, + storiesUsed: selectedStories.map(s => s.title || s.name) + } + }; + } + + _generateTextReadingExercise(realContent, difficulty) { + const texts = realContent.texts; + const numTexts = difficulty === 'easy' ? 1 : Math.min(texts.length, 2); + const selectedTexts = this._selectRandomItems(texts, numTexts); + + const steps = []; + + selectedTexts.forEach((text, textIndex) => { + // Generate comprehension questions based on text content + const questions = this._generateTextQuestions(text, difficulty); + + questions.forEach((questionData, questionIndex) => { + steps.push({ + id: `text-reading-${textIndex}-${questionIndex}`, + type: 'multiple-choice', + content: { + passage: this._formatTextContent(text), + question: questionData.question, + options: questionData.options, + correctAnswer: 0 + }, + hint: `Text: "${text.title}" - ${questionData.hint}` + }); + }); + }); + + // Shuffle options for each step + steps.forEach(step => { + const shuffled = this._shuffleOptions(step.content.options, step.content.correctAnswer); + step.content.options = shuffled.options; + step.content.correctAnswer = shuffled.correctAnswer; + }); + + return { + id: `text-reading-${Date.now()}`, + type: 'reading', + title: `Text Comprehension: ${realContent.name}`, + description: `Read real texts and answer comprehension questions`, + difficulty, + steps, + metadata: { + source: 'real-texts', + chapterInfo: realContent.metadata, + textsUsed: selectedTexts.map(t => t.title || t.name) + } + }; + } + + _formatStoryText(story) { + return `**${story.title || story.name}**\n\n${story.content || story.text}`; + } + + _formatTextContent(text) { + return `**${text.title || text.name}**\n\n${text.content || text.text}`; + } + + _generateStoryQuestions(story, difficulty) { + const questions = []; + const storyText = story.content || story.text; + const sentences = storyText.split(/[.!?]+/).filter(s => s.trim().length > 10); + + // Question 1: Main idea/theme + questions.push({ + question: `What is this story mainly about?`, + options: [ + this._extractMainTheme(story, sentences), + "A different topic", + "Nothing specific", + "A random event" + ], + hint: "Look at the overall theme and key events" + }); + + // Question 2: Specific detail (if medium/hard difficulty) + if (difficulty !== 'easy' && sentences.length > 1) { + const randomSentence = sentences[Math.floor(Math.random() * sentences.length)].trim(); + questions.push({ + question: `According to the story, what happens?`, + options: [ + randomSentence, + "Something completely different", + "The story doesn't mention this", + "Nothing important" + ], + hint: "Look for specific details mentioned in the text" + }); + } + + return questions.slice(0, difficulty === 'easy' ? 1 : 2); + } + + _generateTextQuestions(text, difficulty) { + const questions = []; + const textContent = text.content || text.text; + const sentences = textContent.split(/[.!?]+/).filter(s => s.trim().length > 10); + + // Question 1: Main topic + questions.push({ + question: `What is the main topic of this text?`, + options: [ + this._extractMainTheme(text, sentences), + "A different subject", + "No clear topic", + "Random information" + ], + hint: "Focus on the key subject discussed" + }); + + // Question 2: Supporting detail (if medium/hard difficulty) + if (difficulty !== 'easy' && sentences.length > 1) { + const detailSentence = sentences[Math.floor(Math.random() * sentences.length)].trim(); + questions.push({ + question: `What does the text mention?`, + options: [ + detailSentence, + "Something not in the text", + "No specific details", + "Unrelated information" + ], + hint: "Find the specific information mentioned" + }); + } + + return questions.slice(0, difficulty === 'easy' ? 1 : 2); + } + + _extractMainTheme(content, sentences) { + // Simple theme extraction based on title and first sentence + const title = content.title || content.name || ""; + const firstSentence = sentences[0] || ""; + + if (title) { + return title; + } else if (firstSentence.length > 50) { + return firstSentence.substring(0, 50) + "..."; + } else { + return firstSentence || "The main topic"; + } + } + + _findReadingExerciseInJSON(exercises) { + // Look for reading comprehension exercises in various forms + for (const [key, exercise] of Object.entries(exercises)) { + if (key.includes('reading') || key.includes('comprehension') || + exercise.type === 'reading' || exercise.type === 'comprehension') { + return exercise; + } + + // Check if exercise has passages with questions + if (exercise.passages || exercise.texts || exercise.stories) { + return exercise; + } + } + return null; + } + + _convertJSONExerciseToReading(exercise, realContent, difficulty) { + const steps = []; + + // Handle different JSON exercise formats + if (exercise.passages) { + exercise.passages.forEach((passage, index) => { + if (passage.questions) { + passage.questions.forEach((question, qIndex) => { + steps.push(this._createReadingStep( + `json-passage-${index}-${qIndex}`, + passage.text || passage.content, + question + )); + }); + } + }); + } else if (exercise.texts) { + exercise.texts.forEach((text, index) => { + if (text.questions) { + text.questions.forEach((question, qIndex) => { + steps.push(this._createReadingStep( + `json-text-${index}-${qIndex}`, + text.content || text.text, + question + )); + }); + } + }); + } else if (exercise.stories) { + exercise.stories.forEach((story, index) => { + if (story.questions) { + story.questions.forEach((question, qIndex) => { + steps.push(this._createReadingStep( + `json-story-${index}-${qIndex}`, + story.content || story.text, + question + )); + }); + } + }); + } + + // If no steps created, return null + if (steps.length === 0) { + return null; + } + + // Shuffle options for each step + steps.forEach(step => { + if (step.content.options) { + const shuffled = this._shuffleOptions(step.content.options, step.content.correctAnswer); + step.content.options = shuffled.options; + step.content.correctAnswer = shuffled.correctAnswer; + } + }); + + return { + id: `json-reading-${Date.now()}`, + type: 'reading', + title: exercise.title || `Reading Comprehension: ${realContent.name}`, + description: exercise.instructions || exercise.description || 'Read and answer questions', + difficulty, + steps, + metadata: { + source: 'json-exercises', + chapterInfo: realContent.metadata, + originalExercise: exercise + } + }; + } + + _createReadingStep(stepId, passageText, question) { + return { + id: stepId, + type: question.type || 'multiple-choice', + content: { + passage: passageText, + question: question.question || question.text, + options: question.options || question.choices, + correctAnswer: question.correctAnswer || question.correct || 0 + }, + hint: question.hint || question.explanation || "Read the passage carefully" + }; + } + + async _generateAIReadingExercise(realContent, difficulty) { + // Import IAEngine dynamically + let IAEngine; + try { + const module = await import('../DRS/services/IAEngine.js'); + IAEngine = module.default; + } catch (error) { + throw new Error('IAEngine not available'); + } + + const iaEngine = new IAEngine(); + + // Select the best content for AI question generation + const contentForAI = this._selectContentForAI(realContent); + if (!contentForAI) { + throw new Error('No suitable content found for AI question generation'); + } + + console.log(`🤖 Using AI to generate questions for: ${contentForAI.type}`); + + const steps = []; + + for (let i = 0; i < contentForAI.items.length; i++) { + const item = contentForAI.items[i]; + const contentText = this._extractTextFromContent(item, contentForAI.type); + + try { + // Generate questions using AI + const aiQuestions = await this._generateQuestionsWithAI(iaEngine, contentText, difficulty); + + if (aiQuestions && aiQuestions.length > 0) { + aiQuestions.forEach((aiQuestion, qIndex) => { + steps.push({ + id: `ai-${contentForAI.type}-${i}-${qIndex}`, + type: 'multiple-choice', + content: { + passage: contentText, + question: aiQuestion.question, + options: aiQuestion.options, + correctAnswer: 0 // AI puts correct answer first + }, + hint: aiQuestion.hint || "Read the content carefully and think about the meaning" + }); + }); + } + } catch (aiError) { + console.warn(`⚠️ AI failed for item ${i}:`, aiError.message); + continue; // Try next item + } + } + + if (steps.length === 0) { + throw new Error('AI could not generate any questions'); + } + + // Shuffle options for each step + steps.forEach(step => { + const shuffled = this._shuffleOptions(step.content.options, step.content.correctAnswer); + step.content.options = shuffled.options; + step.content.correctAnswer = shuffled.correctAnswer; + }); + + return { + id: `ai-reading-${Date.now()}`, + type: 'reading', + title: `AI-Generated Reading: ${realContent.name}`, + description: `Read content and answer AI-generated comprehension questions`, + difficulty, + steps, + metadata: { + source: 'ai-generated', + chapterInfo: realContent.metadata, + aiProvider: 'multiple-providers', + contentType: contentForAI.type + } + }; + } + + _selectContentForAI(realContent) { + // Priority order for AI question generation + if (realContent.dialogs && Object.keys(realContent.dialogs).length > 0) { + return { + type: 'dialogs', + items: Object.values(realContent.dialogs) + }; + } + + if (realContent.stories && realContent.stories.length > 0) { + return { + type: 'stories', + items: realContent.stories + }; + } + + if (realContent.texts && realContent.texts.length > 0) { + return { + type: 'texts', + items: realContent.texts + }; + } + + return null; + } + + _extractTextFromContent(item, contentType) { + switch (contentType) { + case 'dialogs': + return this._formatDialogAsText(item); + case 'stories': + return this._formatStoryText(item); + case 'texts': + return this._formatTextContent(item); + default: + return item.content || item.text || ''; + } + } + + async _generateQuestionsWithAI(iaEngine, contentText, difficulty) { + const prompt = `Generate reading comprehension questions for this content: + +Content: +${contentText} + +Requirements: +- Create ${difficulty === 'easy' ? '1' : '2'} multiple choice questions +- Questions should test understanding of the content +- Each question needs exactly 4 answer options +- Put the correct answer FIRST in the options list +- Make questions appropriate for ${difficulty} level +- Be encouraging and educational + +Return ONLY valid JSON in this format: +[ + { + "question": "What is the main topic discussed?", + "options": ["Correct answer", "Wrong option 1", "Wrong option 2", "Wrong option 3"], + "hint": "Focus on the key subject" + } +]`; + + try { + const result = await iaEngine.validateEducationalContent(prompt, { + systemPrompt: 'You are an expert reading comprehension teacher. Generate clear, educational questions. Return ONLY valid JSON.', + temperature: 0.4 + }); + + // Parse AI response + let questions = []; + + if (result.content) { + // If it's text content, try to parse as JSON + try { + questions = JSON.parse(result.content); + } catch (parseError) { + console.warn('AI returned non-JSON content, trying to extract JSON...'); + const jsonMatch = result.content.match(/\[[\s\S]*\]/); + if (jsonMatch) { + questions = JSON.parse(jsonMatch[0]); + } + } + } else if (Array.isArray(result)) { + questions = result; + } else if (result.questions) { + questions = result.questions; + } + + if (!Array.isArray(questions) || questions.length === 0) { + throw new Error('AI did not return valid questions array'); + } + + return questions.slice(0, difficulty === 'easy' ? 1 : 2); // Limit based on difficulty + + } catch (error) { + console.warn('AI question generation failed:', error.message); + throw new Error(`AI generation failed: ${error.message}`); + } + } + + async _generateSentenceComprehensionWithAI(realContent, difficulty) { + // Import IAEngine + let IAEngine; + try { + const module = await import('../DRS/services/IAEngine.js'); + IAEngine = module.default; + } catch (error) { + throw new Error('IAEngine not available for sentence comprehension'); + } + + const iaEngine = new IAEngine(); + const sentences = realContent.sentences.slice(0, difficulty === 'easy' ? 2 : 3); + const steps = []; + + for (let i = 0; i < sentences.length; i++) { + const sentence = sentences[i]; + const sentenceText = sentence.english || sentence.text || sentence.content; + const translation = sentence.chinese || sentence.user_language || sentence.translation; + + try { + console.log(`🤖 Generating comprehension questions for sentence: "${sentenceText}"`); + + const prompt = `Analyze this English sentence phrase by phrase for language learning comprehension: + +Sentence: "${sentenceText}" +Translation: "${translation}" + +Create ONE comprehension question that tests understanding of: +- Key meaning or main action +- Important vocabulary within context +- Phrase structure understanding + +Requirements: +- Make it ${difficulty} level appropriate +- Focus on comprehension, not just vocabulary +- Provide 4 multiple choice options +- Put correct answer FIRST + +Return ONLY valid JSON: +{ + "question": "What is the main meaning of this sentence?", + "options": ["Correct interpretation", "Wrong option 1", "Wrong option 2", "Wrong option 3"], + "hint": "Focus on the key action or meaning" +}`; + + const result = await iaEngine.validateEducationalContent(prompt, { + systemPrompt: 'You are a language comprehension expert. Create questions that test real understanding, not just vocabulary. Return ONLY valid JSON.', + temperature: 0.4 + }); + + const aiQuestion = this._parseAIQuestionResponse(result); + if (aiQuestion) { + steps.push({ + id: `sentence-ai-${i}`, + type: 'multiple-choice', + content: { + passage: `**Sentence Analysis**\n\n"${sentenceText}"\n\n*Translation: ${translation}*`, + question: aiQuestion.question, + options: aiQuestion.options, + correctAnswer: 0 + }, + hint: aiQuestion.hint || "Think about the overall meaning and context" + }); + } + } catch (aiError) { + console.warn(`⚠️ AI failed for sentence ${i}:`, aiError.message); + continue; + } + } + + if (steps.length === 0) { + throw new Error('AI could not generate sentence comprehension questions'); + } + + // Shuffle options + steps.forEach(step => { + const shuffled = this._shuffleOptions(step.content.options, step.content.correctAnswer); + step.content.options = shuffled.options; + step.content.correctAnswer = shuffled.correctAnswer; + }); + + return { + id: `sentence-comprehension-${Date.now()}`, + type: 'text', + title: `AI Sentence Comprehension: ${realContent.name}`, + description: `Understand sentences phrase by phrase with AI analysis`, + difficulty, + steps, + metadata: { + source: 'ai-sentence-comprehension', + chapterInfo: realContent.metadata + } + }; + } + + async _generateDialogTextComprehensionWithAI(realContent, difficulty) { + // Import IAEngine + let IAEngine; + try { + const module = await import('../DRS/services/IAEngine.js'); + IAEngine = module.default; + } catch (error) { + throw new Error('IAEngine not available for dialog comprehension'); + } + + const iaEngine = new IAEngine(); + const dialogs = Object.values(realContent.dialogs).slice(0, difficulty === 'easy' ? 1 : 2); + const steps = []; + + for (let i = 0; i < dialogs.length; i++) { + const dialog = dialogs[i]; + const dialogText = dialog.lines.map(line => + `${line.speaker}: ${line.text} (${line.user_language})` + ).join('\n'); + + try { + console.log(`🤖 Generating text comprehension for dialog: "${dialog.title}"`); + + const prompt = `Analyze this dialogue for detailed text comprehension: + +**${dialog.title}** +${dialogText} + +Create ONE comprehensive question that tests: +- Understanding of the conversation flow +- Grasp of context and situation +- Key information exchange +- Communication purpose + +Requirements: +- ${difficulty} difficulty level +- Focus on comprehension and analysis +- Test deeper understanding than surface meaning +- 4 multiple choice options, correct answer FIRST + +Return ONLY valid JSON: +{ + "question": "What is the main purpose or outcome of this conversation?", + "options": ["Correct analysis", "Wrong interpretation 1", "Wrong interpretation 2", "Wrong interpretation 3"], + "hint": "Consider the overall context and what the speakers achieve" +}`; + + const result = await iaEngine.validateEducationalContent(prompt, { + systemPrompt: 'You are a dialogue analysis expert for language learning. Focus on comprehension and communication context. Return ONLY valid JSON.', + temperature: 0.4 + }); + + const aiQuestion = this._parseAIQuestionResponse(result); + if (aiQuestion) { + steps.push({ + id: `dialog-text-ai-${i}`, + type: 'multiple-choice', + content: { + passage: `**${dialog.title}**\n\n${dialog.lines.map(line => `**${line.speaker}:** ${line.text}`).join('\n')}`, + question: aiQuestion.question, + options: aiQuestion.options, + correctAnswer: 0 + }, + hint: aiQuestion.hint || "Think about what the speakers are trying to accomplish" + }); + } + } catch (aiError) { + console.warn(`⚠️ AI failed for dialog ${i}:`, aiError.message); + continue; + } + } + + if (steps.length === 0) { + throw new Error('AI could not generate dialog text comprehension questions'); + } + + // Shuffle options + steps.forEach(step => { + const shuffled = this._shuffleOptions(step.content.options, step.content.correctAnswer); + step.content.options = shuffled.options; + step.content.correctAnswer = shuffled.correctAnswer; + }); + + return { + id: `dialog-text-comprehension-${Date.now()}`, + type: 'text', + title: `AI Dialog Analysis: ${realContent.name}`, + description: `Analyze conversations for deeper comprehension`, + difficulty, + steps, + metadata: { + source: 'ai-dialog-text-comprehension', + chapterInfo: realContent.metadata + } + }; + } + + _parseAIQuestionResponse(result) { + try { + let question = null; + + if (result.content) { + // Try to parse as JSON + try { + question = JSON.parse(result.content); + } catch (parseError) { + // Try to extract JSON from text + const jsonMatch = result.content.match(/\{[\s\S]*?\}/); + if (jsonMatch) { + question = JSON.parse(jsonMatch[0]); + } + } + } else if (result.question) { + question = result; + } + + if (question && question.question && question.options && Array.isArray(question.options)) { + return question; + } + + return null; + } catch (error) { + console.warn('Failed to parse AI question response:', error.message); + return null; + } + } + + _generateReadingPassages(vocabEntries, difficulty) { + const passages = []; + const numPassages = difficulty === 'easy' ? 2 : difficulty === 'medium' ? 3 : 4; + + for (let i = 0; i < numPassages; i++) { + const wordsForPassage = vocabEntries.slice(i * 2, (i * 2) + 3); // 3 words per passage + + if (wordsForPassage.length === 0) break; + + const focusWords = wordsForPassage.map(([word]) => word); + const text = this._createReadingPassage(wordsForPassage, difficulty); + const question = this._createReadingQuestion(wordsForPassage, difficulty); + const options = this._createReadingOptions(wordsForPassage, vocabEntries); + + passages.push({ + text, + question, + options, + focusWords + }); + } + + return passages; + } + + _createReadingPassage(wordsForPassage, difficulty) { + const templates = { + easy: [ + `Today I learned about {word1}. It means {meaning1}. I also saw a {word2} and it was {word3}. These words help me understand daily life better.`, + `In my English class, we studied {word1}. The teacher explained that {word1} is {meaning1}. Then we learned {word2} and {word3}.` + ], + medium: [ + `During our conversation, she mentioned that the {word1} was quite {word3}. I didn't understand what she meant by {word1} at first, but then I remembered it means {meaning1}. We also talked about the {word2} nearby.`, + `The lesson focused on practical vocabulary. When discussing {word1}, which means {meaning1}, the instructor emphasized its importance. We also covered {word2} and {word3} in the same context.` + ], + hard: [ + `The comprehensive discussion about {word1} revealed its significance in everyday communication. Understanding that {word1} refers to {meaning1} helped clarify many previous conversations. The correlation between {word2} and {word3} became apparent through practical examples.`, + `Contemporary language learning emphasizes contextual understanding. The term {word1}, meaning {meaning1}, frequently appears in discussions about {word2}. Students often find {word3} challenging initially.` + ] + }; + + const templateArray = templates[difficulty] || templates.medium; + const template = templateArray[Math.floor(Math.random() * templateArray.length)]; + + let passage = template; + wordsForPassage.forEach(([word, data], index) => { + passage = passage.replace(`{word${index + 1}}`, word); + passage = passage.replace(`{meaning${index + 1}}`, data.user_language); + }); + + return passage; + } + + _createReadingQuestion(wordsForPassage, difficulty) { + const questions = [ + `What does "${wordsForPassage[0][0]}" mean in this context?`, + `According to the passage, "${wordsForPassage[0][0]}" refers to:`, + `The text suggests that "${wordsForPassage[0][0]}" means:`, + `In this passage, what is the meaning of "${wordsForPassage[0][0]}"?` + ]; + + return questions[Math.floor(Math.random() * questions.length)]; + } + + _createReadingOptions(wordsForPassage, allVocab) { + const correctWord = wordsForPassage[0]; + const options = [correctWord[1].user_language]; // Correct answer + + // Add 3 wrong answers from other vocabulary + const otherWords = allVocab.filter(([w]) => w !== correctWord[0]); + const wrongAnswers = this._selectRandomItems(otherWords, 3); + wrongAnswers.forEach(([, data]) => options.push(data.user_language)); + + return options; + } + + async _generateAudioFromRealContent(realContent, difficulty) { + console.log('🎧 Generating audio comprehension exercise with AI'); + + // Check if we have audio content for AI comprehension + if (realContent.audio && realContent.audio.length > 0) { + console.log('🎵 Using real audio content for AI comprehension'); + return await this._generateAudioComprehensionWithAI(realContent, difficulty); + } + + // Check if we have dialogues for audio comprehension simulation + if (realContent.dialogs && Object.keys(realContent.dialogs).length > 0) { + console.log('💬 Using dialogues for simulated audio comprehension'); + return await this._generateDialogAudioComprehensionWithAI(realContent, difficulty); + } + + // Fallback to vocabulary + console.log('📚 Falling back to vocabulary audio'); + return this._generateVocabularyAudioExercise(realContent, difficulty); + } + + _generateVocabularyAudioExercise(realContent, difficulty) { + if (!realContent.vocabulary) { + throw new Error('No vocabulary found in content'); + } + + const vocab = Object.entries(realContent.vocabulary); + const numQuestions = this._getQuestionCount(difficulty); + const selectedWords = this._selectRandomItems(vocab, numQuestions); + + const steps = selectedWords.map(([word, data], index) => ({ + id: `audio-step-${index + 1}`, + type: 'multiple-choice', + content: { + audioText: `Listen to this word: ${word}. What does it mean?`, + question: `You heard "${word}". What does it mean?`, + options: this._generateTextOptions(word, data, vocab), + correctAnswer: 0, + audioUrl: null // Would use real audio in production + }, + hint: `The word was "${word}". It's pronounced: ${data.pronunciation || word}` + })); + + // Shuffle options for each step + steps.forEach(step => { + const shuffled = this._shuffleOptions(step.content.options, step.content.correctAnswer); + step.content.options = shuffled.options; + step.content.correctAnswer = shuffled.correctAnswer; + }); + + return { + id: `audio-exercise-${Date.now()}`, + type: 'audio', + title: `Audio Vocabulary: ${realContent.name}`, + description: `Listen and learn vocabulary from ${realContent.name}`, + difficulty, + steps, + metadata: { + source: 'real-content', + chapterInfo: realContent.metadata + } + }; + } + + async _generateAudioComprehensionWithAI(realContent, difficulty) { + // Import IAEngine + let IAEngine; + try { + const module = await import('../DRS/services/IAEngine.js'); + IAEngine = module.default; + } catch (error) { + throw new Error('IAEngine not available for audio comprehension'); + } + + const iaEngine = new IAEngine(); + const audioItems = realContent.audio.slice(0, difficulty === 'easy' ? 2 : 3); + const steps = []; + + for (let i = 0; i < audioItems.length; i++) { + const audio = audioItems[i]; + const transcript = audio.transcript || audio.text || audio.content; + const title = audio.title || `Audio ${i + 1}`; + + try { + console.log(`🤖 Generating audio comprehension for: "${title}"`); + + const prompt = `Create a listening comprehension question for this audio content: + +**${title}** +Audio transcript: "${transcript}" + +Create ONE question that tests: +- Main idea or key information from the audio +- Understanding of spoken content context +- Ability to extract important details from listening + +Requirements: +- ${difficulty} difficulty level +- Focus on comprehension skills +- Simulate "What did you hear?" type questions +- 4 multiple choice options, correct answer FIRST + +Return ONLY valid JSON: +{ + "question": "What is the main information you heard?", + "options": ["Correct comprehension", "Misunderstood info 1", "Misunderstood info 2", "Misunderstood info 3"], + "hint": "Focus on the key message of what was said" +}`; + + const result = await iaEngine.validateEducationalContent(prompt, { + systemPrompt: 'You are a listening comprehension expert. Create questions that test understanding of spoken content. Return ONLY valid JSON.', + temperature: 0.4 + }); + + const aiQuestion = this._parseAIQuestionResponse(result); + if (aiQuestion) { + steps.push({ + id: `audio-ai-${i}`, + type: 'multiple-choice', + content: { + passage: `**🎧 Audio Comprehension: ${title}**\n\n*Listen carefully and answer based on what you heard.*\n\n**Transcript:** "${transcript}"`, + question: aiQuestion.question, + options: aiQuestion.options, + correctAnswer: 0, + audioText: `Audio content: ${transcript}`, + audioUrl: audio.audioUrl || null + }, + hint: aiQuestion.hint || "Think about the main message you heard" + }); + } + } catch (aiError) { + console.warn(`⚠️ AI failed for audio ${i}:`, aiError.message); + continue; + } + } + + if (steps.length === 0) { + throw new Error('AI could not generate audio comprehension questions'); + } + + // Shuffle options + steps.forEach(step => { + const shuffled = this._shuffleOptions(step.content.options, step.content.correctAnswer); + step.content.options = shuffled.options; + step.content.correctAnswer = shuffled.correctAnswer; + }); + + return { + id: `audio-comprehension-${Date.now()}`, + type: 'audio', + title: `AI Audio Comprehension: ${realContent.name}`, + description: `Listen and understand audio content with AI questions`, + difficulty, + steps, + metadata: { + source: 'ai-audio-comprehension', + chapterInfo: realContent.metadata + } + }; + } + + async _generateDialogAudioComprehensionWithAI(realContent, difficulty) { + // Import IAEngine + let IAEngine; + try { + const module = await import('../DRS/services/IAEngine.js'); + IAEngine = module.default; + } catch (error) { + throw new Error('IAEngine not available for dialog audio comprehension'); + } + + const iaEngine = new IAEngine(); + const dialogs = Object.values(realContent.dialogs).slice(0, difficulty === 'easy' ? 1 : 2); + const steps = []; + + for (let i = 0; i < dialogs.length; i++) { + const dialog = dialogs[i]; + const dialogScript = dialog.lines.map(line => `${line.speaker}: ${line.text}`).join('\n'); + + try { + console.log(`🤖 Generating dialog audio comprehension for: "${dialog.title}"`); + + const prompt = `Create a listening comprehension question for this dialogue as if it were heard as audio: + +**${dialog.title}** +Dialogue: +${dialogScript} + +Create ONE question that tests: +- Understanding of the conversation when heard +- Ability to follow speakers and their messages +- Comprehension of the overall exchange +- What listeners would catch from hearing this + +Requirements: +- ${difficulty} difficulty level +- Simulate listening comprehension skills +- Test understanding of spoken interaction +- 4 multiple choice options, correct answer FIRST + +Return ONLY valid JSON: +{ + "question": "What did you hear in this conversation?", + "options": ["Correct understanding", "Wrong interpretation 1", "Wrong interpretation 2", "Wrong interpretation 3"], + "hint": "Focus on what the speakers were discussing" +}`; + + const result = await iaEngine.validateEducationalContent(prompt, { + systemPrompt: 'You are an expert in dialogue listening comprehension. Focus on what learners would understand from hearing spoken conversation. Return ONLY valid JSON.', + temperature: 0.4 + }); + + const aiQuestion = this._parseAIQuestionResponse(result); + if (aiQuestion) { + steps.push({ + id: `dialog-audio-ai-${i}`, + type: 'multiple-choice', + content: { + passage: `**🎧 Listen to this conversation: ${dialog.title}**\n\n*Imagine you are listening to this dialogue.*\n\n${dialog.lines.map(line => `**${line.speaker}:** "${line.text}"`).join('\n')}`, + question: aiQuestion.question, + options: aiQuestion.options, + correctAnswer: 0, + audioText: `Conversation between ${dialog.participants.join(' and ')}: ${dialogScript}` + }, + hint: aiQuestion.hint || "Think about what you heard the speakers saying" + }); + } + } catch (aiError) { + console.warn(`⚠️ AI failed for dialog audio ${i}:`, aiError.message); + continue; + } + } + + if (steps.length === 0) { + throw new Error('AI could not generate dialog audio comprehension questions'); + } + + // Shuffle options + steps.forEach(step => { + const shuffled = this._shuffleOptions(step.content.options, step.content.correctAnswer); + step.content.options = shuffled.options; + step.content.correctAnswer = shuffled.correctAnswer; + }); + + return { + id: `dialog-audio-comprehension-${Date.now()}`, + type: 'audio', + title: `AI Dialog Listening: ${realContent.name}`, + description: `Listen to conversations and test your comprehension`, + difficulty, + steps, + metadata: { + source: 'ai-dialog-audio-comprehension', + chapterInfo: realContent.metadata + } + }; + } + + async _generateImageFromRealContent(realContent, difficulty) { + console.log('🖼️ Generating image analysis exercise with AI'); + + // Check if we have real images for AI analysis + if (realContent.images && realContent.images.length > 0) { + console.log('📸 Using real images for AI analysis'); + return await this._generateImageAnalysisWithAI(realContent, difficulty); + } + + // Check for visual vocabulary concepts for AI explanation + if (realContent.vocabulary) { + const vocab = Object.entries(realContent.vocabulary); + const visualWords = vocab.filter(([word, data]) => + data.type === 'noun' || word.includes('shirt') || word.includes('apartment') || word.includes('building') + ); + + if (visualWords.length > 0) { + console.log('👁️ Using vocabulary for AI visual analysis'); + return await this._generateVocabularyImageAnalysisWithAI(realContent, difficulty, visualWords); + } + } + + // Fallback to basic image vocabulary + console.log('📚 Falling back to basic image vocabulary'); + return this._generateBasicImageExercise(realContent, difficulty); + } + + _generateBasicImageExercise(realContent, difficulty) { + if (!realContent.vocabulary) { + throw new Error('No vocabulary found in content'); + } + + const vocab = Object.entries(realContent.vocabulary); + const imageableWords = vocab.filter(([word, data]) => + data.type === 'noun' || word.includes(' ') === false + ); + + if (imageableWords.length === 0) { + throw new Error('No imageable vocabulary found'); + } + + const numQuestions = this._getQuestionCount(difficulty); + const selectedWords = this._selectRandomItems(imageableWords, numQuestions); + + const steps = selectedWords.map(([word, data], index) => ({ + id: `image-step-${index + 1}`, + type: 'multiple-choice', + content: { + imageUrl: `https://via.placeholder.com/300x200?text=${encodeURIComponent(word)}`, + imageAlt: `Image representing ${word}`, + question: `What word does this image represent?`, + options: this._generateWordOptions(word, imageableWords), + correctAnswer: 0 + }, + hint: `This is a ${data.type}. Think about: ${data.user_language}` + })); + + steps.forEach(step => { + const shuffled = this._shuffleOptions(step.content.options, step.content.correctAnswer); + step.content.options = shuffled.options; + step.content.correctAnswer = shuffled.correctAnswer; + }); + + return { + id: `image-exercise-${Date.now()}`, + type: 'image', + title: `Visual Vocabulary: ${realContent.name}`, + description: `Identify vocabulary from ${realContent.name} using images`, + difficulty, + steps, + metadata: { + source: 'real-content', + chapterInfo: realContent.metadata + } + }; + } + + _generateGrammarFromRealContent(realContent, difficulty) { + if (!realContent.vocabulary) { + throw new Error('No vocabulary found in content'); + } + + const vocab = Object.entries(realContent.vocabulary); + const numQuestions = this._getQuestionCount(difficulty); + const selectedWords = this._selectRandomItems(vocab, numQuestions); + + const steps = selectedWords.map(([word, data], index) => ({ + id: `grammar-step-${index + 1}`, + type: 'fill-blank', + content: { + sentence: this._createSentenceWithBlank(word, data), + correctAnswer: word, + question: `Complete the sentence with the correct word:`, + options: this._generateWordOptions(word, vocab.slice(0, 20)) + }, + hint: `The missing word is a ${data.type}. Meaning: ${data.user_language}` + })); + + return { + id: `grammar-exercise-${Date.now()}`, + type: 'grammar', + title: `Grammar Practice: ${realContent.name}`, + description: `Practice using vocabulary from ${realContent.name} in context`, + difficulty, + steps, + metadata: { + source: 'real-content', + chapterInfo: realContent.metadata + } + }; + } + + // Helper methods for real content generation + _getQuestionCount(difficulty) { + switch (difficulty) { + case 'easy': return 3; + case 'medium': return 5; + case 'hard': return 8; + default: return 5; + } + } + + _selectRandomItems(array, count) { + const shuffled = [...array].sort(() => Math.random() - 0.5); + return shuffled.slice(0, Math.min(count, array.length)); + } + + _generateTextOptions(correctWord, correctData, allVocab) { + const options = [correctData.user_language]; + const otherWords = allVocab.filter(([w]) => w !== correctWord); + const randomWrong = this._selectRandomItems(otherWords, 3); + randomWrong.forEach(([, data]) => options.push(data.user_language)); + return options; + } + + _generateWordOptions(correctWord, allWords) { + const options = [correctWord]; + const otherWords = allWords.filter(([w]) => w !== correctWord); + const randomWrong = this._selectRandomItems(otherWords, 3); + randomWrong.forEach(([word]) => options.push(word)); + return options; + } + + _shuffleOptions(options, correctIndex) { + const shuffled = [...options]; + const correctAnswer = shuffled[correctIndex]; + 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]]; + } + const newCorrectIndex = shuffled.indexOf(correctAnswer); + return { options: shuffled, correctAnswer: newCorrectIndex }; + } + + _createSentenceWithBlank(word, data) { + const templates = { + noun: [ + `The _____ is very important in daily life.`, + `I need to buy a new _____ for my home.`, + `The _____ was broken and needed repair.` + ], + verb: [ + `I like to _____ every morning.`, + `We need to _____ before it's too late.`, + `They will _____ tomorrow.` + ], + adjective: [ + `The weather is very _____ today.`, + `This place looks _____ and comfortable.`, + `The food tastes _____ and fresh.` + ], + adverb: [ + `She speaks English _____.`, + `He works _____ every day.`, + `They completed the task _____.` + ] + }; + + const typeTemplates = templates[data.type] || templates.noun; + return typeTemplates[Math.floor(Math.random() * typeTemplates.length)]; + } + + /** + * Generate text exercise content + * @param {Object} request - Request details + * @returns {Object} - Text exercise + * @private + */ + _generateTextExercise(request) { + const passages = { + easy: { + text: "The cat sits on the mat. It is a warm day. The cat likes to sleep in the sun.", + questions: [ + { + question: "Where does the cat sit?", + options: ["On the chair", "On the mat", "On the bed", "On the floor"], + correct: 1, + hint: "Look at the first sentence." + } + ] + }, + medium: { + text: "Solar energy is becoming increasingly popular around the world. Many countries are investing in solar panels and wind turbines to reduce their dependence on fossil fuels. This shift towards renewable energy is helping to combat climate change and create jobs in the green energy sector.", + questions: [ + { + question: "What is helping to combat climate change?", + options: ["Fossil fuels", "Nuclear power", "Renewable energy", "Oil drilling"], + correct: 2, + hint: "Look for what countries are investing in." + }, + { + question: "What benefits are mentioned for renewable energy?", + options: ["Only environmental", "Only economic", "Both environmental and economic", "Neither"], + correct: 2, + hint: "The passage mentions both climate change and job creation." + } + ] + }, + hard: { + text: "The phenomenon of quantum entanglement, first described by Einstein as 'spooky action at a distance,' has evolved from a theoretical curiosity to a cornerstone of modern quantum physics. When two particles become entangled, measuring the state of one instantly affects the state of the other, regardless of the distance separating them. This seemingly impossible connection has profound implications for quantum computing, cryptography, and our understanding of the fundamental nature of reality.", + questions: [ + { + question: "How did Einstein describe quantum entanglement?", + options: ["Theoretical impossibility", "Spooky action at a distance", "Quantum computing", "Fundamental reality"], + correct: 1, + hint: "Look for Einstein's specific phrase in the text." + }, + { + question: "What happens when entangled particles are measured?", + options: ["Nothing occurs", "One affects the other instantly", "They separate", "They disappear"], + correct: 1, + hint: "Focus on what happens when measuring one particle." + } + ] + } + }; + + const content = passages[request.difficulty] || passages.medium; + + return { + title: `Reading Comprehension - ${request.difficulty}`, + type: 'text', + difficulty: request.difficulty, + steps: content.questions.map((q, index) => ({ + instruction: index === 0 ? "Read the following passage and answer the questions." : "Continue answering questions about the passage.", + text: content.text, + question: q.question, + options: q.options, + correct: q.correct, + hint: q.hint + })) + }; + } + + /** + * Generate audio exercise content + * @param {Object} request - Request details + * @returns {Object} - Audio exercise + * @private + */ + _generateAudioExercise(request) { + const audioExercises = { + easy: { + title: "Basic Listening", + transcript: "Hello, my name is Sarah. I am twenty years old. I live in Paris.", + questions: [ + { + question: "What is the speaker's name?", + options: ["Maria", "Sarah", "Anna", "Lisa"], + correct: 1, + hint: "Listen to the beginning of the recording." + } + ] + }, + medium: { + title: "Weather Forecast", + transcript: "Good morning! Today's weather will be partly cloudy with temperatures reaching 22 degrees Celsius. There's a 30% chance of rain in the afternoon, so you might want to bring an umbrella. Tomorrow will be sunny with temperatures up to 25 degrees.", + questions: [ + { + question: "What will the temperature be today?", + options: ["20 degrees", "22 degrees", "25 degrees", "30 degrees"], + correct: 1, + hint: "Listen for today's temperature, not tomorrow's." + }, + { + question: "What should you bring today?", + options: ["Sunglasses", "Jacket", "Umbrella", "Hat"], + correct: 2, + hint: "Think about what you need when there's a chance of rain." + } + ] + } + }; + + const content = audioExercises[request.difficulty] || audioExercises.medium; + + return { + title: content.title, + type: 'audio', + difficulty: request.difficulty, + steps: [ + { + instruction: "Listen to the audio carefully and answer the questions.", + audioUrl: "data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdJivrJBhNjVgodDbq2EcBj+a2/LDciUFLIHO8tiJNwgZaLvt559NEAxQp+PwtmMcBjiR1/LMeSwFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhBjiR1/LNeSsFJHfH8N2QQAoUXrTp66hVFApGn+DyvmwhhBkHVoUEjPh==", + transcript: content.transcript, + questions: content.questions + } + ] + }; + } + + /** + * Generate image exercise content + * @param {Object} request - Request details + * @returns {Object} - Image exercise + * @private + */ + _generateImageExercise(request) { + const images = { + easy: "https://via.placeholder.com/400x300/87CEEB/ffffff?text=Sunny+Day", + medium: "https://via.placeholder.com/400x300/228B22/ffffff?text=Green+Forest", + hard: "https://via.placeholder.com/400x300/FF6347/ffffff?text=Complex+Scene" + }; + + return { + title: `Image Analysis - ${request.difficulty}`, + type: 'image', + difficulty: request.difficulty, + steps: [ + { + instruction: "Look at the image carefully and answer the question.", + imageUrl: images[request.difficulty] || images.medium, + question: "What do you see in this image?", + options: ["A sunny day", "A forest", "A complex scene", "Cannot determine"], + correct: 0, + hint: "Look at the text and colors in the placeholder image." + } + ] + }; + } + + /** + * Generate grammar exercise content + * @param {Object} request - Request details + * @returns {Object} - Grammar exercise + * @private + */ + _generateGrammarExercise(request) { + const exercises = { + easy: [ + { + instruction: "Fill in the blank with the correct verb form.", + sentence: "The dog _____ in the park.", + question: "Complete the sentence with the correct present tense form of 'run'.", + options: ["run", "runs", "running", "ran"], + correct: 1, + explanation: "Use 'runs' because 'dog' is singular and requires the third person singular form.", + hint: "Remember: he/she/it + verb + s" + } + ], + medium: [ + { + instruction: "Choose the correct grammatical form.", + question: "Which sentence uses the present perfect correctly?", + options: [ + "I have went to the store", + "I have gone to the store", + "I have go to the store", + "I have going to the store" + ], + correct: 1, + explanation: "The correct past participle of 'go' is 'gone', not 'went'.", + hint: "Present perfect uses 'have/has + past participle'" + }, + { + instruction: "Fill in the blank with the correct form.", + sentence: "If I _____ rich, I would travel the world.", + question: "Complete the conditional sentence.", + options: ["am", "was", "were", "be"], + correct: 2, + explanation: "In hypothetical conditionals, use 'were' for all persons with 'if'.", + hint: "This is a hypothetical condition about the present." + } + ] + }; + + const content = exercises[request.difficulty] || exercises.medium; + + return { + title: `Grammar Practice - ${request.difficulty}`, + type: 'grammar', + difficulty: request.difficulty, + steps: content.map(exercise => ({ + ...exercise, + hint: exercise.hint || "Think about the grammatical rules that apply here." + })) + }; + } + + /** + * Generate default exercise content + * @param {Object} request - Request details + * @returns {Object} - Default exercise + * @private + */ + _generateDefaultExercise(request) { + return { + title: `General Exercise - ${request.difficulty || 'medium'}`, + type: request.subtype || 'general', + difficulty: request.difficulty || 'medium', + steps: [ + { + instruction: "This is a sample exercise.", + question: "What type of exercise is this?", + options: ["Text", "Audio", "Image", "General"], + correct: 3, + hint: "Look at the title of the exercise." + } + ] + }; + } + + // Event handlers + + /** + * Handle content request events + * @param {Object} event - Content request event + * @private + */ + async _handleContentRequest(event) { + try { + const content = await this.loadExercise(event.data); + + // Emit response + this._eventBus.emit('content:response', { + requestId: event.data.requestId, + content + }, this.name); + + } catch (error) { + this._eventBus.emit('content:response', { + requestId: event.data.requestId, + error: error.message + }, this.name); + } + } + + /** + * Handle cache clear events + * @param {Object} event - Cache clear event + * @private + */ + _handleClearCache(event) { + this.clearCache(event.data?.pattern); + } + + async _generateImageAnalysisWithAI(realContent, difficulty) { + // Import IAEngine + let IAEngine; + try { + const module = await import('../DRS/services/IAEngine.js'); + IAEngine = module.default; + } catch (error) { + throw new Error('IAEngine not available for image analysis'); + } + + const iaEngine = new IAEngine(); + const images = realContent.images.slice(0, difficulty === 'easy' ? 2 : 3); + const steps = []; + + for (let i = 0; i < images.length; i++) { + const image = images[i]; + const imageUrl = image.url || image.src || image.imageUrl; + const description = image.description || image.alt || image.caption || ''; + const title = image.title || `Image ${i + 1}`; + + try { + console.log(`🤖 Generating image analysis for: "${title}"`); + + const prompt = `Create an image analysis and explanation question for language learning: + +**Image: ${title}** +Description: "${description}" +Image URL: ${imageUrl} + +Create ONE question that tests: +- Visual analysis and description skills +- Ability to explain what is seen +- Understanding of visual context and details +- Language skills for describing images + +Requirements: +- ${difficulty} difficulty level +- Focus on analysis and explanation +- Test descriptive language abilities +- 4 multiple choice options, correct answer FIRST + +Return ONLY valid JSON: +{ + "question": "What is the best way to describe or explain this image?", + "options": ["Correct description/analysis", "Wrong description 1", "Wrong description 2", "Wrong description 3"], + "hint": "Look carefully at the details and context" +}`; + + const result = await iaEngine.validateEducationalContent(prompt, { + systemPrompt: 'You are a visual analysis expert for language learning. Create questions that test descriptive and analytical skills. Return ONLY valid JSON.', + temperature: 0.4 + }); + + const aiQuestion = this._parseAIQuestionResponse(result); + if (aiQuestion) { + steps.push({ + id: `image-analysis-ai-${i}`, + type: 'multiple-choice', + content: { + passage: `**🖼️ Image Analysis: ${title}**\n\n*Study this image carefully and analyze what you see.*\n\n**Description:** ${description}`, + question: aiQuestion.question, + options: aiQuestion.options, + correctAnswer: 0, + imageUrl: imageUrl, + imageAlt: description || `Analysis image ${i + 1}` + }, + hint: aiQuestion.hint || "Look at the details and think about how to describe them" + }); + } + } catch (aiError) { + console.warn(`⚠️ AI failed for image ${i}:`, aiError.message); + continue; + } + } + + if (steps.length === 0) { + throw new Error('AI could not generate image analysis questions'); + } + + // Shuffle options + steps.forEach(step => { + const shuffled = this._shuffleOptions(step.content.options, step.content.correctAnswer); + step.content.options = shuffled.options; + step.content.correctAnswer = shuffled.correctAnswer; + }); + + return { + id: `image-analysis-${Date.now()}`, + type: 'image', + title: `AI Image Analysis: ${realContent.name}`, + description: `Analyze and explain images with AI guidance`, + difficulty, + steps, + metadata: { + source: 'ai-image-analysis', + chapterInfo: realContent.metadata + } + }; + } + + async _generateVocabularyImageAnalysisWithAI(realContent, difficulty, visualWords) { + // Import IAEngine + let IAEngine; + try { + const module = await import('../DRS/services/IAEngine.js'); + IAEngine = module.default; + } catch (error) { + throw new Error('IAEngine not available for vocabulary image analysis'); + } + + const iaEngine = new IAEngine(); + const selectedWords = visualWords.slice(0, difficulty === 'easy' ? 2 : 3); + const steps = []; + + for (let i = 0; i < selectedWords.length; i++) { + const [word, data] = selectedWords[i]; + + try { + console.log(`🤖 Generating image analysis for word: "${word}"`); + + const prompt = `Create a visual analysis question for this vocabulary word in a learning context: + +**Word:** "${word}" +**Meaning:** "${data.user_language}" +**Type:** ${data.type} + +Create ONE question that tests: +- Visual understanding and explanation of the concept +- Ability to analyze and describe the word's visual representation +- Language skills for explaining what the word represents visually + +Requirements: +- ${difficulty} difficulty level +- Focus on visual analysis and description +- Help students understand how to describe and explain this concept +- 4 multiple choice options, correct answer FIRST + +Return ONLY valid JSON: +{ + "question": "How would you visually describe or analyze what '${word}' represents?", + "options": ["Correct visual analysis", "Wrong description 1", "Wrong description 2", "Wrong description 3"], + "hint": "Think about the visual characteristics and what you would see" +}`; + + const result = await iaEngine.validateEducationalContent(prompt, { + systemPrompt: 'You are a vocabulary visualization expert for language learning. Create questions that test visual understanding and descriptive skills. Return ONLY valid JSON.', + temperature: 0.4 + }); + + const aiQuestion = this._parseAIQuestionResponse(result); + if (aiQuestion) { + steps.push({ + id: `vocab-image-ai-${i}`, + type: 'multiple-choice', + content: { + passage: `**🔍 Visual Analysis: "${word}"**\n\n*Think about how you would visually describe or analyze this concept.*\n\n**Word:** ${word}\n**Meaning:** ${data.user_language}\n**Type:** ${data.type}`, + question: aiQuestion.question, + options: aiQuestion.options, + correctAnswer: 0, + imageUrl: `https://via.placeholder.com/300x200?text=${encodeURIComponent(word)}`, + imageAlt: `Visual representation of ${word}` + }, + hint: aiQuestion.hint || "Think about what you would see and how you would describe it" + }); + } + } catch (aiError) { + console.warn(`⚠️ AI failed for vocabulary image ${i}:`, aiError.message); + continue; + } + } + + if (steps.length === 0) { + throw new Error('AI could not generate vocabulary image analysis questions'); + } + + // Shuffle options + steps.forEach(step => { + const shuffled = this._shuffleOptions(step.content.options, step.content.correctAnswer); + step.content.options = shuffled.options; + step.content.correctAnswer = shuffled.correctAnswer; + }); + + return { + id: `vocab-image-analysis-${Date.now()}`, + type: 'image', + title: `AI Visual Analysis: ${realContent.name}`, + description: `Analyze and explain vocabulary concepts visually`, + difficulty, + steps, + metadata: { + source: 'ai-vocabulary-image-analysis', + chapterInfo: realContent.metadata + } + }; + } +} + +export default ContentLoader; \ No newline at end of file diff --git a/src/core/GameLoader.js b/src/core/GameLoader.js new file mode 100644 index 0000000..e4a8f62 --- /dev/null +++ b/src/core/GameLoader.js @@ -0,0 +1,313 @@ +import Module from './Module.js'; + +/** + * GameLoader - Discovers and manages game modules + * Handles dynamic loading, compatibility scoring, and game lifecycle + */ +class GameLoader extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); + + // Validate dependencies + if (!dependencies.eventBus) { + throw new Error('GameLoader requires EventBus dependency'); + } + + this._eventBus = dependencies.eventBus; + this._config = config; + + // Game management + this._games = new Map(); // gameId -> game info + this._gameInstances = new Map(); // instanceId -> game instance + this._currentContent = null; + + // Game discovery paths + this._gamePaths = [ + '../games/FlashcardLearning.js', + '../games/StoryReader.js', + '../games/LetterDiscovery.js', + '../games/QuizGame.js', + '../games/AdventureReader.js', + '../games/WizardSpellCaster.js', + '../games/WordStorm.js', + '../games/WhackAMole.js', + '../games/WordDiscovery.js', + '../games/GrammarDiscovery.js', + '../games/FillTheBlank.js', + '../games/StoryBuilder.js', + '../games/RiverRun.js', + '../games/ChineseStudy.js', + '../games/WhackAMoleHard.js', + '../games/MarioEducational.js' + // All current games with Module architecture + ]; + + Object.seal(this); + } + + async init() { + this._validateNotDestroyed(); + + // Set up event listeners + this._eventBus.on('content:loaded', this._handleContentUpdate.bind(this), this.name); + this._eventBus.on('game:launch-request', this._handleLaunchRequest.bind(this), this.name); + this._eventBus.on('game:exit-request', this._handleExitRequest.bind(this), this.name); + + // Discover available games + await this._discoverGames(); + + this._setInitialized(); + } + + async destroy() { + this._validateNotDestroyed(); + + // Destroy all active game instances + for (const [instanceId, gameInstance] of this._gameInstances) { + try { + await gameInstance.destroy(); + } catch (error) { + console.warn(`Error destroying game instance ${instanceId}:`, error); + } + } + + this._gameInstances.clear(); + this._games.clear(); + + this._setDestroyed(); + } + + /** + * Get all discovered games with compatibility scores + * @returns {Array} Array of game info objects + */ + getAvailableGames() { + this._validateInitialized(); + return Array.from(this._games.values()); + } + + /** + * Get games compatible with current content + * @param {number} minScore - Minimum compatibility score (0-1) + * @returns {Array} Array of compatible games sorted by score + */ + getCompatibleGames(minScore = 0.3) { + this._validateInitialized(); + + return this.getAvailableGames() + .filter(game => game.compatibility.score >= minScore) + .sort((a, b) => b.compatibility.score - a.compatibility.score); + } + + /** + * Launch a game instance + * @param {string} gameId - ID of the game to launch + * @param {Object} options - Launch options + * @returns {Promise} Instance ID + */ + async launchGame(gameId, options = {}) { + this._validateInitialized(); + + const gameInfo = this._games.get(gameId); + if (!gameInfo) { + throw new Error(`Game not found: ${gameId}`); + } + + // Check compatibility + if (gameInfo.compatibility.score < 0.1) { + throw new Error(`Game ${gameId} is not compatible with current content`); + } + + try { + // Create unique instance ID + const instanceId = `${gameId}-${Date.now()}`; + + // Prepare game dependencies + const gameDependencies = { + eventBus: this._eventBus, + content: this._currentContent, + ...options.dependencies + }; + + // Register the game instance as a module with EventBus + this._eventBus.registerModule({ name: instanceId }); + + // Create game instance + const gameInstance = new gameInfo.GameClass(instanceId, gameDependencies, { + container: options.container, + ...options.config + }); + + // Initialize the game + await gameInstance.init(); + + // Track the instance + this._gameInstances.set(instanceId, gameInstance); + + // Emit game launched event + this._eventBus.emit('game:launched', { + gameId, + instanceId, + compatibility: gameInfo.compatibility + }, this.name); + + return instanceId; + + } catch (error) { + console.error(`Error launching game ${gameId}:`, error); + throw error; + } + } + + /** + * Exit a game instance + * @param {string} instanceId - Instance ID to exit + */ + async exitGame(instanceId) { + this._validateInitialized(); + + const gameInstance = this._gameInstances.get(instanceId); + if (!gameInstance) { + console.warn(`Game instance not found: ${instanceId}`); + return; + } + + try { + await gameInstance.destroy(); + this._gameInstances.delete(instanceId); + + // Unregister the game instance from EventBus + this._eventBus.unregisterModule(instanceId); + + this._eventBus.emit('game:exited', { instanceId }, this.name); + + } catch (error) { + console.error(`Error exiting game ${instanceId}:`, error); + } + } + + // Private methods + async _discoverGames() { + console.log('🎮 Discovering available games...'); + + for (const gamePath of this._gamePaths) { + try { + // Dynamically import the game module (resolve relative to current module) + const gameModule = await import(gamePath); + const GameClass = gameModule.default; + + if (!GameClass) { + console.warn(`No default export found in ${gamePath}`); + continue; + } + + // Get game metadata + const metadata = GameClass.getMetadata?.() || { + name: GameClass.name, + description: 'No description available', + difficulty: 'unknown' + }; + + // Calculate compatibility score + const compatibility = this._calculateCompatibility(GameClass); + + // Store game info + const gameInfo = { + id: GameClass.name.toLowerCase().replace(/game$/, ''), + GameClass, + metadata, + compatibility, + path: gamePath + }; + + this._games.set(gameInfo.id, gameInfo); + console.log(`✅ Discovered game: ${metadata.name} (compatibility: ${compatibility.score.toFixed(2)})`); + + } catch (error) { + console.warn(`Failed to load game from ${gamePath}:`, error.message); + } + } + + console.log(`🎮 Game discovery complete: ${this._games.size} games found`); + } + + _calculateCompatibility(GameClass) { + if (!this._currentContent) { + return { score: 0, reason: 'No content loaded' }; + } + + // Use game's own compatibility function if available + if (typeof GameClass.getCompatibilityScore === 'function') { + const result = GameClass.getCompatibilityScore(this._currentContent); + + // Normalize different return formats + if (typeof result === 'number') { + // Format 2: Direct integer (0-100) -> Convert to decimal object + return { + score: result / 100, + reason: `Compatibility score: ${result}%`, + requirements: ['content'] + }; + } else if (result && typeof result === 'object' && typeof result.score === 'number') { + // Format 1: Object with decimal score (0-1) -> Use as-is + return result; + } else { + // Invalid format -> Default to 0 + return { score: 0, reason: 'Invalid compatibility format' }; + } + } + + // Default compatibility calculation + const vocab = this._currentContent.vocabulary || {}; + const vocabCount = Object.keys(vocab).length; + + if (vocabCount < 5) { + return { score: 0, reason: 'Insufficient vocabulary (need at least 5 words)' }; + } + + // Basic scoring based on vocabulary count + const score = Math.min(vocabCount / 20, 1); // Full score at 20+ words + + return { + score, + reason: `${vocabCount} vocabulary words available`, + requirements: ['vocabulary'], + minWords: 5, + optimalWords: 20 + }; + } + + _handleContentUpdate(event) { + console.log('🔄 GameLoader: Content updated, recalculating compatibility scores'); + this._currentContent = event.data.content; + + // Recalculate compatibility scores for all games + for (const [gameId, gameInfo] of this._games) { + const oldScore = gameInfo.compatibility?.score || 0; + gameInfo.compatibility = this._calculateCompatibility(gameInfo.GameClass); + const newScore = gameInfo.compatibility?.score || 0; + console.log(`🎯 ${gameId}: ${oldScore.toFixed(2)} → ${newScore.toFixed(2)}`); + } + + this._eventBus.emit('games:compatibility-updated', { + gamesCount: this._games.size, + compatibleCount: this.getCompatibleGames().length + }, this.name); + + console.log('✅ Compatibility scores updated'); + } + + _handleLaunchRequest(event) { + const { gameId, options } = event.data; + this.launchGame(gameId, options).catch(error => { + this._eventBus.emit('game:launch-error', { gameId, error: error.message }, this.name); + }); + } + + _handleExitRequest(event) { + const { instanceId } = event.data; + this.exitGame(instanceId); + } +} + +export default GameLoader; \ No newline at end of file diff --git a/src/core/IntelligentSequencer.js b/src/core/IntelligentSequencer.js new file mode 100644 index 0000000..a674270 --- /dev/null +++ b/src/core/IntelligentSequencer.js @@ -0,0 +1,704 @@ +/** + * IntelligentSequencer - Smart exercise sequencing with performance tracking + * Creates intelligent, non-repetitive exercise proposals based on user performance + */ + +import Module from './Module.js'; + +class IntelligentSequencer extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); + + if (!dependencies.eventBus) { + throw new Error('IntelligentSequencer requires EventBus dependency'); + } + + this._eventBus = dependencies.eventBus; + + // Performance tracking + this._performanceHistory = new Map(); // type -> performance data + this._exerciseHistory = []; // chronological exercise history + this._sessionStats = { + startTime: null, + exerciseCount: 0, + totalTime: 0, + averageAccuracy: 0, + currentStreak: 0 + }; + + // Algorithm configuration + this._config = { + maxHistorySize: config.maxHistorySize || 50, + repetitionAvoidanceWindow: config.repetitionAvoidanceWindow || 5, + difficultyAdaptationThreshold: config.difficultyAdaptationThreshold || 0.8, + varietyWeight: config.varietyWeight || 0.3, + performanceWeight: config.performanceWeight || 0.4, + freshnessWeight: config.freshnessWeight || 0.3, + minSessionLength: config.minSessionLength || 3, + maxSessionLength: config.maxSessionLength || 15, + ...config + }; + + // Available exercise types and difficulties + this._exerciseTypes = ['text', 'reading', 'audio', 'grammar']; + this._difficulties = ['easy', 'medium', 'hard']; + + // Current session state + this._currentSession = null; + this._isGuiding = false; + + Object.seal(this); + } + + async init() { + this._validateNotDestroyed(); + + // Listen for exercise completion events + this._eventBus.on('drs:completed', this._handleExerciseCompleted.bind(this), this.name); + this._eventBus.on('drs:step-completed', this._handleStepCompleted.bind(this), this.name); + + // Load saved performance data if available + this._loadPerformanceData(); + + this._setInitialized(); + console.log('🧠 IntelligentSequencer initialized with smart algorithm'); + } + + async destroy() { + this._validateNotDestroyed(); + + // Save performance data before destruction + this._savePerformanceData(); + + this._setDestroyed(); + } + + /** + * Start intelligent guided session + */ + startGuidedSession(options = {}) { + this._validateInitialized(); + + const session = { + id: `session_${Date.now()}`, + startTime: Date.now(), + bookId: options.bookId || 'sbs', + chapterId: options.chapterId || 'level-1-2', + targetLength: Math.min( + Math.max(options.targetLength || 8, this._config.minSessionLength), + this._config.maxSessionLength + ), + completed: 0, + exercises: [], + currentExercise: null + }; + + this._currentSession = session; + this._isGuiding = true; + + console.log('🚀 Starting guided session:', session.id); + + // Emit session start event + this._eventBus.emit('sequencer:session-started', { + sessionId: session.id, + targetLength: session.targetLength + }, this.name); + + return session; + } + + /** + * Get next intelligent exercise recommendation + */ + async getNextExercise() { + this._validateInitialized(); + + if (!this._isGuiding || !this._currentSession) { + return null; + } + + const session = this._currentSession; + + // Check if session is complete + if (session.completed >= session.targetLength) { + this._completeSession(); + return null; + } + + try { + // Calculate scores for each exercise type/difficulty combination + const candidates = await this._generateCandidates(); + + if (candidates.length === 0) { + console.warn('⚠️ No available exercise candidates found', { + sessionProgress: `${session.completed}/${session.targetLength}`, + exerciseTypes: this._exerciseTypes, + bookId: session.bookId, + chapterId: session.chapterId + }); + this._completeSession(); + return null; + } + + const scoredCandidates = candidates.map(candidate => ({ + ...candidate, + score: this._calculateExerciseScore(candidate) + })); + + // Sort by score (higher is better) + scoredCandidates.sort((a, b) => b.score - a.score); + + // Select the best candidate with some randomness for variety + const topCandidates = scoredCandidates.slice(0, Math.min(3, scoredCandidates.length)); + const selectedCandidate = topCandidates[Math.floor(Math.random() * topCandidates.length)]; + + // Create exercise recommendation + const exercise = { + type: selectedCandidate.type, + difficulty: selectedCandidate.difficulty, + bookId: session.bookId, + chapterId: session.chapterId, + sessionPosition: session.completed + 1, + totalInSession: session.targetLength, + reasoning: selectedCandidate.reasoning || 'Intelligent selection based on performance and variety' + }; + + session.currentExercise = exercise; + + console.log('🎯 Next exercise recommendation:', { + type: exercise.type, + difficulty: exercise.difficulty, + position: `${exercise.sessionPosition}/${exercise.totalInSession}`, + score: selectedCandidate.score.toFixed(2), + reasoning: exercise.reasoning, + availableCandidates: candidates.length + }); + + return exercise; + + } catch (error) { + console.error('❌ Error generating next exercise:', error); + return null; + } + } + + /** + * Record exercise completion and update performance + */ + recordExerciseCompletion(exerciseData, performanceData) { + this._validateInitialized(); + + const completionRecord = { + timestamp: Date.now(), + type: exerciseData.type, + difficulty: exerciseData.difficulty, + bookId: exerciseData.bookId, + chapterId: exerciseData.chapterId, + performance: { + timeSpent: performanceData.timeSpent || 0, + accuracy: performanceData.accuracy || 0, + hintsUsed: performanceData.hintsUsed || 0, + attempts: performanceData.attempts || 1, + completed: performanceData.completed !== false + } + }; + + // Update exercise history + this._exerciseHistory.push(completionRecord); + if (this._exerciseHistory.length > this._config.maxHistorySize) { + this._exerciseHistory.shift(); + } + + // Update performance tracking + this._updatePerformanceTracking(completionRecord); + + // Update current session + if (this._currentSession && this._isGuiding) { + this._currentSession.exercises.push(completionRecord); + this._currentSession.completed++; + } + + console.log('📊 Exercise recorded:', { + type: completionRecord.type, + difficulty: completionRecord.difficulty, + accuracy: completionRecord.performance.accuracy, + timeSpent: Math.round(completionRecord.performance.timeSpent / 1000) + 's' + }); + } + + /** + * Get performance insights and recommendations + */ + getPerformanceInsights() { + this._validateInitialized(); + + const insights = { + overallStats: this._calculateOverallStats(), + typePerformance: this._getTypePerformance(), + difficultyProgress: this._getDifficultyProgress(), + recommendations: this._generateRecommendations(), + recentTrends: this._getRecentTrends() + }; + + return insights; + } + + /** + * Check if currently in guided session + */ + isGuiding() { + return this._isGuiding && this._currentSession !== null; + } + + /** + * Stop current guided session + */ + stopGuidedSession() { + if (!this._isGuiding || !this._currentSession) { + return; + } + + this._completeSession(); + } + + // Private methods + + async _generateCandidates() { + const candidates = []; + + // Check resource availability for current session + const session = this._currentSession; + if (!session) return candidates; + + const { bookId, chapterId } = session; + + for (const type of this._exerciseTypes) { + for (const difficulty of this._difficulties) { + // Check if this type/difficulty combination has available content + const hasContent = await this._checkContentAvailability(type, bookId, chapterId); + + if (hasContent) { + candidates.push({ + type, + difficulty, + reasoning: `${type} exercise at ${difficulty} level using real content from ${chapterId}` + }); + } else { + console.log(`⚠️ Skipping ${type}/${difficulty} - no content available for ${chapterId}`); + } + } + } + + console.log('📊 Generated candidates:', { + total: candidates.length, + types: candidates.map(c => `${c.type}/${c.difficulty}`).join(', ') + }); + + return candidates; + } + + /** + * Check if content is available for a specific exercise type + */ + async _checkContentAvailability(exerciseType, bookId, chapterId) { + try { + const contentPath = `/content/chapters/${chapterId || bookId}.json`; + const response = await fetch(contentPath); + + if (!response.ok) { + console.warn(`❌ Content not available: ${contentPath}`); + return false; + } + + const content = await response.json(); + + // Check based on exercise type requirements + switch (exerciseType) { + case 'text': + case 'audio': + // Text and audio need vocabulary + return content.vocabulary && Object.keys(content.vocabulary).length > 3; + + case 'image': + // For now, disable image exercises as we don't have real images + console.log('⚠️ Image exercises disabled - no image resources available'); + return false; + + case 'reading': + // Reading needs predefined exercises or content with questions + return this._hasReadingContent(content); + + case 'grammar': + // Grammar needs varied word types + if (!content.vocabulary) return false; + const vocab = Object.entries(content.vocabulary); + return vocab.length > 5; // Need enough variety + + default: + return false; + } + + } catch (error) { + console.warn(`❌ Error checking content availability:`, error); + return false; + } + } + + _hasReadingContent(content) { + // Check if there are predefined reading exercises in JSON + if (content.exercises) { + for (const [key, exercise] of Object.entries(content.exercises)) { + if (key.includes('reading') || key.includes('comprehension') || + exercise.type === 'reading' || exercise.type === 'comprehension') { + return true; + } + + // Check if exercise has passages/texts/stories with questions + if ((exercise.passages && exercise.passages.some(p => p.questions)) || + (exercise.texts && exercise.texts.some(t => t.questions)) || + (exercise.stories && exercise.stories.some(s => s.questions))) { + return true; + } + } + } + + // Check if stories have questions + if (content.stories && content.stories.some(story => story.questions && story.questions.length > 0)) { + return true; + } + + // Check if texts have questions + if (content.texts && content.texts.some(text => text.questions && text.questions.length > 0)) { + return true; + } + + // Check if dialogs have questions + if (content.dialogs) { + const dialogsWithQuestions = Object.values(content.dialogs).some(dialog => + dialog.questions && dialog.questions.length > 0 + ); + if (dialogsWithQuestions) { + return true; + } + } + + // No reading content with questions found + console.log(`⚠️ No reading content with questions found for this chapter`); + return false; + } + + _calculateExerciseScore(candidate) { + // Calculate variety score (avoid recent repetition) + const varietyScore = this._calculateVarietyScore(candidate); + + // Calculate performance-based score (adaptive difficulty) + const performanceScore = this._calculatePerformanceScore(candidate); + + // Calculate freshness score (prefer less practiced combinations) + const freshnessScore = this._calculateFreshnessScore(candidate); + + // Weighted combination + const totalScore = ( + varietyScore * this._config.varietyWeight + + performanceScore * this._config.performanceWeight + + freshnessScore * this._config.freshnessWeight + ); + + return totalScore; + } + + _calculateVarietyScore(candidate) { + // Check recent exercises for repetition + const recentWindow = this._exerciseHistory.slice(-this._config.repetitionAvoidanceWindow); + const recentTypes = recentWindow.map(ex => ex.type); + const recentDifficulties = recentWindow.map(ex => ex.difficulty); + const recentCombos = recentWindow.map(ex => `${ex.type}_${ex.difficulty}`); + + let varietyScore = 1.0; + + // Penalize if type was used recently + const typeFreq = recentTypes.filter(t => t === candidate.type).length; + varietyScore -= (typeFreq * 0.2); + + // Penalize if difficulty was used recently + const difficultyFreq = recentDifficulties.filter(d => d === candidate.difficulty).length; + varietyScore -= (difficultyFreq * 0.1); + + // Heavy penalty if exact combination was used very recently + const comboKey = `${candidate.type}_${candidate.difficulty}`; + const comboFreq = recentCombos.filter(c => c === comboKey).length; + varietyScore -= (comboFreq * 0.4); + + return Math.max(varietyScore, 0); + } + + _calculatePerformanceScore(candidate) { + const typePerf = this._performanceHistory.get(candidate.type); + + if (!typePerf || typePerf.attempts === 0) { + return 0.5; // Neutral score for untested areas + } + + const avgAccuracy = typePerf.totalAccuracy / typePerf.attempts; + const avgTime = typePerf.totalTime / typePerf.attempts; + + // Adapt difficulty based on performance + let difficultyFit = 0.5; + + if (avgAccuracy > this._config.difficultyAdaptationThreshold) { + // Good performance, prefer harder exercises + if (candidate.difficulty === 'hard') difficultyFit = 0.9; + else if (candidate.difficulty === 'medium') difficultyFit = 0.6; + else difficultyFit = 0.3; + } else if (avgAccuracy < 0.6) { + // Poor performance, prefer easier exercises + if (candidate.difficulty === 'easy') difficultyFit = 0.9; + else if (candidate.difficulty === 'medium') difficultyFit = 0.6; + else difficultyFit = 0.3; + } else { + // Moderate performance, prefer medium difficulty + if (candidate.difficulty === 'medium') difficultyFit = 0.9; + else difficultyFit = 0.7; + } + + return difficultyFit; + } + + _calculateFreshnessScore(candidate) { + const comboKey = `${candidate.type}_${candidate.difficulty}`; + + // Count how many times this combination has been practiced + const comboCount = this._exerciseHistory.filter(ex => + ex.type === candidate.type && ex.difficulty === candidate.difficulty + ).length; + + // Prefer less practiced combinations + return Math.max(1.0 - (comboCount * 0.1), 0.1); + } + + _updatePerformanceTracking(record) { + const type = record.type; + + if (!this._performanceHistory.has(type)) { + this._performanceHistory.set(type, { + attempts: 0, + totalAccuracy: 0, + totalTime: 0, + lastPracticed: 0, + bestAccuracy: 0, + averageTime: 0 + }); + } + + const typeData = this._performanceHistory.get(type); + typeData.attempts++; + typeData.totalAccuracy += record.performance.accuracy; + typeData.totalTime += record.performance.timeSpent; + typeData.lastPracticed = record.timestamp; + typeData.bestAccuracy = Math.max(typeData.bestAccuracy, record.performance.accuracy); + typeData.averageTime = typeData.totalTime / typeData.attempts; + } + + _calculateOverallStats() { + if (this._exerciseHistory.length === 0) { + return { + totalExercises: 0, + averageAccuracy: 0, + totalTime: 0, + completionRate: 0 + }; + } + + const total = this._exerciseHistory.length; + const totalAccuracy = this._exerciseHistory.reduce((sum, ex) => sum + ex.performance.accuracy, 0); + const totalTime = this._exerciseHistory.reduce((sum, ex) => sum + ex.performance.timeSpent, 0); + const completed = this._exerciseHistory.filter(ex => ex.performance.completed).length; + + return { + totalExercises: total, + averageAccuracy: totalAccuracy / total, + totalTime, + completionRate: completed / total + }; + } + + _getTypePerformance() { + const typePerf = {}; + + for (const [type, data] of this._performanceHistory.entries()) { + typePerf[type] = { + averageAccuracy: data.totalAccuracy / data.attempts, + averageTime: data.averageTime, + attempts: data.attempts, + bestAccuracy: data.bestAccuracy, + lastPracticed: data.lastPracticed + }; + } + + return typePerf; + } + + _getDifficultyProgress() { + const difficultyStats = { + easy: { attempts: 0, accuracy: 0 }, + medium: { attempts: 0, accuracy: 0 }, + hard: { attempts: 0, accuracy: 0 } + }; + + for (const exercise of this._exerciseHistory) { + const diff = exercise.difficulty; + if (difficultyStats[diff]) { + difficultyStats[diff].attempts++; + difficultyStats[diff].accuracy += exercise.performance.accuracy; + } + } + + // Calculate averages + for (const diff of Object.keys(difficultyStats)) { + const stats = difficultyStats[diff]; + if (stats.attempts > 0) { + stats.averageAccuracy = stats.accuracy / stats.attempts; + } + } + + return difficultyStats; + } + + _generateRecommendations() { + const recommendations = []; + const typePerf = this._getTypePerformance(); + + // Identify weak areas + for (const [type, perf] of Object.entries(typePerf)) { + if (perf.averageAccuracy < 0.7 && perf.attempts >= 2) { + recommendations.push({ + type: 'improvement', + message: `Consider practicing more ${type} exercises to improve accuracy`, + priority: 'medium', + targetType: type + }); + } + } + + // Suggest variety + const recentTypes = this._exerciseHistory.slice(-10).map(ex => ex.type); + const typeDistribution = {}; + for (const type of recentTypes) { + typeDistribution[type] = (typeDistribution[type] || 0) + 1; + } + + const overusedType = Object.entries(typeDistribution).find(([_, count]) => count > 5); + if (overusedType) { + recommendations.push({ + type: 'variety', + message: `Try mixing in other exercise types - you've done a lot of ${overusedType[0]} recently`, + priority: 'low' + }); + } + + return recommendations; + } + + _getRecentTrends() { + if (this._exerciseHistory.length < 5) { + return { trend: 'insufficient_data' }; + } + + const recent = this._exerciseHistory.slice(-5); + const accuracies = recent.map(ex => ex.performance.accuracy); + + // Simple trend calculation + const firstHalf = accuracies.slice(0, Math.floor(accuracies.length / 2)); + const secondHalf = accuracies.slice(Math.floor(accuracies.length / 2)); + + const firstAvg = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length; + const secondAvg = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length; + + let trend = 'stable'; + if (secondAvg > firstAvg + 0.1) trend = 'improving'; + else if (secondAvg < firstAvg - 0.1) trend = 'declining'; + + return { + trend, + recentAccuracy: secondAvg, + change: secondAvg - firstAvg + }; + } + + _completeSession() { + if (!this._currentSession) return; + + const session = this._currentSession; + const sessionTime = Date.now() - session.startTime; + + console.log('🎉 Guided session completed:', { + exercises: session.completed, + duration: Math.round(sessionTime / 1000) + 's', + sessionId: session.id + }); + + // Emit session completion + this._eventBus.emit('sequencer:session-completed', { + sessionId: session.id, + exercisesCompleted: session.completed, + duration: sessionTime, + exercises: session.exercises + }, this.name); + + this._currentSession = null; + this._isGuiding = false; + } + + _handleExerciseCompleted(event) { + if (!this.isGuiding()) return; + + const data = event.data; + this.recordExerciseCompletion( + { + type: data.exerciseType || 'text', + difficulty: data.difficulty || 'medium', + bookId: data.bookId, + chapterId: data.chapterId + }, + { + timeSpent: data.stats?.timeSpent || 0, + accuracy: data.stats?.accuracy || 0.8, + hintsUsed: data.stats?.hintsUsed || 0, + completed: true + } + ); + } + + _handleStepCompleted(event) { + // Could track individual step performance here + console.log('📊 Step completed in guided session:', event.data); + } + + _loadPerformanceData() { + try { + const saved = localStorage.getItem('intelligentSequencer_performance'); + if (saved) { + const data = JSON.parse(saved); + this._performanceHistory = new Map(data.performanceHistory || []); + this._exerciseHistory = data.exerciseHistory || []; + console.log('📚 Loaded performance history:', this._exerciseHistory.length, 'exercises'); + } + } catch (error) { + console.warn('⚠️ Failed to load performance data:', error); + } + } + + _savePerformanceData() { + try { + const data = { + performanceHistory: Array.from(this._performanceHistory.entries()), + exerciseHistory: this._exerciseHistory + }; + localStorage.setItem('intelligentSequencer_performance', JSON.stringify(data)); + } catch (error) { + console.warn('⚠️ Failed to save performance data:', error); + } + } +} + +export default IntelligentSequencer; \ No newline at end of file diff --git a/src/core/Module.js b/src/core/Module.js index 455a65d..1c81a6e 100644 --- a/src/core/Module.js +++ b/src/core/Module.js @@ -24,8 +24,7 @@ class Module { destroyed: false }); - // Seal the module to prevent external modification - Object.seal(this); + // Note: Object.seal(this) is called by child classes after they define their properties } // Public getters (read-only) diff --git a/src/core/ModuleLoader.js b/src/core/ModuleLoader.js index 6a0ad79..3195268 100644 --- a/src/core/ModuleLoader.js +++ b/src/core/ModuleLoader.js @@ -98,13 +98,12 @@ class ModuleLoader { throw new Error(`Module ${name} must be loaded before initialization`); } - if (moduleInfo.initialized) { + if (moduleInfo.initialized || moduleInfo.instance.isInitialized) { return moduleInfo.instance; } try { await moduleInfo.instance.init(); - moduleInfo.instance._setInitialized(); moduleInfo.initialized = true; this._initializationOrder.push(name); diff --git a/src/core/Router.js b/src/core/Router.js index d4a9e0b..8a486e1 100644 --- a/src/core/Router.js +++ b/src/core/Router.js @@ -21,26 +21,22 @@ class Router extends Module { this._maxHistorySize = config.maxHistorySize || 100; this._defaultRoute = config.defaultRoute || '/'; - // Bind methods to prevent context loss - this._handlePopState = this._handlePopState.bind(this); - - Object.seal(this); + // TODO: Re-add Object.seal(this) after debugging } async init() { this._validateNotDestroyed(); // Set up browser navigation handling - window.addEventListener('popstate', this._handlePopState); + if (typeof window !== 'undefined') { + window.addEventListener('popstate', this._handlePopState.bind(this)); + } // Set up route change listener this._eventBus.on('router:navigate', (event) => { this.navigate(event.data.path, event.data.state); }, this.name); - // Handle initial route - this._handleCurrentRoute(); - this._setInitialized(); } @@ -48,7 +44,9 @@ class Router extends Module { this._validateNotDestroyed(); // Clean up event listeners - window.removeEventListener('popstate', this._handlePopState); + if (typeof window !== 'undefined') { + window.removeEventListener('popstate', this._handlePopState.bind(this)); + } // Clear routes and history this._routes.clear(); diff --git a/src/games/AdventureReader.js b/src/games/AdventureReader.js new file mode 100644 index 0000000..69dde93 --- /dev/null +++ b/src/games/AdventureReader.js @@ -0,0 +1,1966 @@ +import Module from '../core/Module.js'; + +/** + * AdventureReader - Zelda-style RPG adventure with vocabulary and sentence reading + * Players move around a map, click pots for vocabulary and defeat enemies for reading content + */ +class AdventureReader extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); + + // Validate dependencies + if (!dependencies.eventBus || !dependencies.content) { + throw new Error('AdventureReader requires eventBus and content dependencies'); + } + + this._eventBus = dependencies.eventBus; + this._content = dependencies.content; + this._config = { + container: null, + autoPlayTTS: true, + ttsEnabled: true, + maxPots: 8, + maxEnemies: 8, + ...config + }; + + // Game state + this._score = 0; + this._currentSentenceIndex = 0; + this._currentVocabIndex = 0; + this._potsDestroyed = 0; + this._enemiesDefeated = 0; + this._isGamePaused = false; + this._gameStartTime = null; + + // Game objects + this._pots = []; + this._enemies = []; + this._player = { x: 0, y: 0 }; + this._isPlayerMoving = false; + this._isPlayerInvulnerable = false; + this._invulnerabilityTimeout = null; + + // Content + this._vocabulary = null; + this._sentences = null; + this._stories = null; + this._dialogues = null; + + Object.seal(this); + } + + /** + * Get game metadata + * @returns {Object} Game metadata + */ + static getMetadata() { + return { + name: 'Adventure Reader', + description: 'Zelda-style RPG adventure with vocabulary discovery and reading quests', + difficulty: 'intermediate', + category: 'adventure', + estimatedTime: 12, // minutes + skills: ['vocabulary', 'reading', 'exploration', 'comprehension'] + }; + } + + /** + * Calculate compatibility score with content + * @param {Object} content - Content to check compatibility with + * @returns {Object} Compatibility score and details + */ + static getCompatibilityScore(content) { + const vocab = content?.vocabulary || {}; + const sentences = content?.sentences || []; + const stories = content?.story?.chapters || content?.texts || []; + const dialogues = content?.dialogues || []; + + const vocabCount = Object.keys(vocab).length; + const sentenceCount = sentences.length; + const storyCount = stories.length; + const dialogueCount = dialogues.length; + + const totalContent = vocabCount + sentenceCount + storyCount + dialogueCount; + + if (totalContent < 5) { + return { + score: 0, + reason: `Insufficient adventure content (${totalContent}/5 required)`, + requirements: ['vocabulary', 'sentences', 'stories', 'dialogues'], + minContent: 5, + details: 'Adventure Reader needs vocabulary, sentences, stories, or dialogues for exploration' + }; + } + + // Calculate weighted score based on content diversity + 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); + + 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 }, + details: `Rich adventure content with ${totalContent} total elements` + }; + } + + async init() { + this._validateNotDestroyed(); + + try { + // Validate container + if (!this._config.container) { + throw new Error('Game container is required'); + } + + // Extract content + this._extractContent(); + + // Validate content + if (!this._hasValidContent()) { + throw new Error('No compatible adventure content found'); + } + + // Set up event listeners + this._eventBus.on('game:pause', this._handlePause.bind(this), this.name); + this._eventBus.on('game:resume', this._handleResume.bind(this), this.name); + + // Inject CSS + this._injectCSS(); + + // Initialize game interface + this._createGameInterface(); + this._initializePlayer(); + this._setupEventListeners(); + this._updateContentInfo(); + this._generateGameObjects(); + this._generateDecorations(); + this._startGameLoop(); + + // Start the game + this._gameStartTime = Date.now(); + + // Emit game ready event + this._eventBus.emit('game:ready', { + gameId: 'adventure-reader', + instanceId: this.name, + vocabulary: this._vocabulary.length, + sentences: this._sentences.length, + stories: this._stories.length, + dialogues: this._dialogues.length + }, this.name); + + this._setInitialized(); + + } catch (error) { + this._showError(error.message); + throw error; + } + } + + async destroy() { + this._validateNotDestroyed(); + + // Clear timeouts + if (this._invulnerabilityTimeout) { + clearTimeout(this._invulnerabilityTimeout); + this._invulnerabilityTimeout = null; + } + + // Cancel any ongoing TTS + if (typeof speechSynthesis !== 'undefined') { + speechSynthesis.cancel(); + } + + // Remove CSS + this._removeCSS(); + + // Clean up event listeners + if (this._config.container) { + this._config.container.innerHTML = ''; + } + + // Emit game end event + this._eventBus.emit('game:ended', { + gameId: 'adventure-reader', + instanceId: this.name, + score: this._score, + potsDestroyed: this._potsDestroyed, + enemiesDefeated: this._enemiesDefeated, + duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0 + }, this.name); + + this._setDestroyed(); + } + + /** + * Get current game state + * @returns {Object} Current game state + */ + getGameState() { + this._validateInitialized(); + + return { + score: this._score, + potsDestroyed: this._potsDestroyed, + enemiesDefeated: this._enemiesDefeated, + totalPots: this._pots.length, + totalEnemies: this._enemies.length, + isComplete: this._isGameComplete(), + isPaused: this._isGamePaused, + duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0 + }; + } + + // Private methods + _extractContent() { + this._vocabulary = this._extractVocabulary(); + this._sentences = this._extractSentences(); + this._stories = this._extractStories(); + this._dialogues = this._extractDialogues(); + } + + _extractVocabulary() { + const vocab = this._content?.vocabulary || {}; + const vocabulary = []; + + for (const [word, data] of Object.entries(vocab)) { + if (data.user_language || (typeof data === 'string')) { + vocabulary.push({ + original_language: word, + user_language: data.user_language || data, + type: data.type || 'unknown', + pronunciation: data.pronunciation + }); + } + } + + return vocabulary; + } + + _extractSentences() { + let sentences = []; + + // Support for Dragon's Pearl structure + if (this._content.story?.chapters) { + this._content.story.chapters.forEach(chapter => { + if (chapter.sentences) { + chapter.sentences.forEach(sentence => { + if (sentence.original && sentence.translation) { + sentences.push({ + original_language: sentence.original, + user_language: sentence.translation, + pronunciation: sentence.pronunciation, + chapter: chapter.title || '' + }); + } + }); + } + }); + } + + // Support for modular format + if (this._content.sentences) { + this._content.sentences.forEach(sentence => { + sentences.push({ + original_language: sentence.english || sentence.original_language, + user_language: sentence.chinese || sentence.french || sentence.user_language || sentence.translation, + pronunciation: sentence.pronunciation || sentence.prononciation + }); + }); + } + + return sentences.filter(s => s.original_language && s.user_language); + } + + _extractStories() { + let stories = []; + + // Support for Dragon's Pearl structure + if (this._content.story?.chapters) { + stories.push({ + title: this._content.story.title || this._content.name || "Adventure Story", + chapters: this._content.story.chapters + }); + } + + // Support for modular texts + if (this._content.texts) { + stories = stories.concat(this._content.texts.filter(text => + text.original_language && text.user_language + )); + } + + return stories; + } + + _extractDialogues() { + let dialogues = []; + + if (this._content.dialogues) { + dialogues = this._content.dialogues.filter(dialogue => + dialogue.conversation && dialogue.conversation.length > 0 + ); + } + + return dialogues; + } + + _hasValidContent() { + const hasVocab = this._vocabulary.length > 0; + const hasSentences = this._sentences.length > 0; + const hasStories = this._stories.length > 0; + const hasDialogues = this._dialogues.length > 0; + + return hasVocab || hasSentences || hasStories || hasDialogues; + } + + _injectCSS() { + const cssId = `adventure-reader-styles-${this.name}`; + if (document.getElementById(cssId)) return; + + const style = document.createElement('style'); + style.id = cssId; + style.textContent = ` + .adventure-reader-wrapper { + height: 100vh; + display: flex; + flex-direction: column; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%); + overflow: hidden; + } + + .adventure-hud { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); + color: white; + border-bottom: 3px solid rgba(255, 255, 255, 0.1); + z-index: 100; + } + + .hud-section { + display: flex; + gap: 20px; + align-items: center; + } + + .stat-item { + display: flex; + align-items: center; + gap: 8px; + background: rgba(255, 255, 255, 0.1); + padding: 8px 12px; + border-radius: 20px; + font-weight: 500; + } + + .stat-icon { + font-size: 1.2rem; + } + + .progress-info { + background: rgba(255, 255, 255, 0.1); + padding: 8px 15px; + border-radius: 15px; + font-size: 0.9rem; + } + + .game-map { + flex: 1; + position: relative; + background: linear-gradient(135deg, #27ae60 0%, #2ecc71 100%); + overflow: hidden; + cursor: crosshair; + } + + .player { + position: absolute; + font-size: 2.5rem; + 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)); + user-select: none; + } + + .pot, .enemy { + position: absolute; + font-size: 2rem; + cursor: pointer; + z-index: 30; + transition: all 0.3s ease; + filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.3)); + user-select: none; + } + + .pot:hover, .enemy:hover { + transform: scale(1.1); + filter: drop-shadow(2px 2px 6px rgba(0,0,0,0.5)); + } + + .pot.destroyed, .enemy.defeated { + pointer-events: none; + transition: all 0.5s ease; + } + + .decoration { + position: absolute; + z-index: 10; + pointer-events: none; + user-select: none; + opacity: 0.8; + } + + .decoration.tree { + filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.2)); + } + + .decoration.grass { + opacity: 0.6; + } + + .decoration.rock { + filter: drop-shadow(1px 1px 3px rgba(0,0,0,0.3)); + } + + .adventure-controls { + padding: 15px 20px; + background: rgba(0, 0, 0, 0.8); + color: white; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 15px; + } + + .instructions { + font-size: 0.9rem; + opacity: 0.9; + } + + .content-summary { + font-size: 0.85rem; + background: rgba(255, 255, 255, 0.1); + padding: 8px 12px; + border-radius: 8px; + } + + .control-btn { + padding: 8px 15px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 500; + transition: all 0.3s ease; + } + + .control-btn.primary { + background: #3b82f6; + color: white; + } + + .control-btn.primary:hover { + background: #2563eb; + transform: translateY(-2px); + } + + .control-btn.secondary { + background: #10b981; + color: white; + } + + .control-btn.secondary:hover { + background: #059669; + transform: translateY(-2px); + } + + .reading-modal, .vocab-popup { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: all 0.3s ease; + } + + .reading-modal.show, .vocab-popup.show { + opacity: 1; + visibility: visible; + } + + .modal-content { + background: white; + border-radius: 15px; + max-width: 600px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + transform: translateY(20px); + transition: transform 0.3s ease; + } + + .reading-modal.show .modal-content { + transform: translateY(0); + } + + .modal-header { + padding: 20px 25px 15px; + border-bottom: 2px solid #e5e7eb; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 15px 15px 0 0; + } + + .modal-header h3 { + margin: 0; + font-size: 1.3rem; + } + + .modal-body { + padding: 25px; + } + + .sentence-content { + text-align: center; + } + + .sentence-content.dialogue-content { + background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); + padding: 20px; + border-radius: 12px; + margin-bottom: 15px; + } + + .speaker-info, .story-title, .emotion-info { + font-weight: 600; + margin-bottom: 10px; + color: #374151; + } + + .text-content { + margin: 20px 0; + } + + .original-text { + font-size: 1.4rem; + font-weight: 600; + color: #1f2937; + margin-bottom: 15px; + line-height: 1.4; + } + + .translation-text { + font-size: 1.1rem; + color: #6b7280; + margin-bottom: 10px; + line-height: 1.3; + } + + .pronunciation-text { + font-size: 1rem; + color: #7c3aed; + font-style: italic; + } + + .modal-footer { + padding: 15px 25px 25px; + text-align: center; + } + + .popup-content { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 20px 25px; + border-radius: 15px; + text-align: center; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); + transform: scale(0.9); + transition: transform 0.3s ease; + } + + .vocab-popup.show .popup-content { + transform: scale(1); + } + + .vocab-word { + font-size: 2rem; + font-weight: bold; + margin-bottom: 10px; + } + + .vocab-translation { + font-size: 1.3rem; + margin-bottom: 10px; + opacity: 0.9; + } + + .vocab-pronunciation { + font-size: 1rem; + opacity: 0.8; + font-style: italic; + } + + .game-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + text-align: center; + padding: 40px; + background: linear-gradient(135deg, #f87171 0%, #ef4444 100%); + color: white; + } + + .game-error h3 { + font-size: 2rem; + margin-bottom: 20px; + } + + .game-error ul { + text-align: left; + background: rgba(255, 255, 255, 0.1); + padding: 20px; + border-radius: 10px; + margin: 20px 0; + } + + .back-btn { + padding: 12px 25px; + background: white; + color: #ef4444; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + } + + .back-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(255, 255, 255, 0.3); + } + + /* Animations */ + @keyframes protectionFloat { + 0% { + transform: translate(-50%, -50%) scale(0.8); + opacity: 0; + } + 20% { + transform: translate(-50%, -50%) scale(1.2); + opacity: 1; + } + 80% { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + } + 100% { + transform: translate(-50%, -50%) scale(0.8); + opacity: 0; + } + } + + @keyframes damageFloat { + 0% { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + } + 50% { + transform: translate(-50%, -80%) scale(1.2); + opacity: 1; + } + 100% { + transform: translate(-50%, -120%) scale(0.8); + opacity: 0; + } + } + + @media (max-width: 768px) { + .adventure-hud { + flex-direction: column; + gap: 15px; + padding: 12px 15px; + } + + .hud-section { + gap: 15px; + } + + .stat-item { + padding: 6px 10px; + font-size: 0.9rem; + } + + .player { + font-size: 2rem; + } + + .pot, .enemy { + font-size: 1.8rem; + } + + .adventure-controls { + flex-direction: column; + gap: 10px; + padding: 12px 15px; + } + + .modal-content { + width: 95%; + } + + .modal-body { + padding: 20px 15px; + } + } + + /* Victory Popup Styles */ + .victory-popup { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: fadeIn 0.3s ease-out; + } + + .victory-content { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 20px; + padding: 40px; + text-align: center; + color: white; + max-width: 500px; + width: 90%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: slideUp 0.4s ease-out; + } + + .victory-header { + margin-bottom: 30px; + } + + .victory-icon { + font-size: 4rem; + margin-bottom: 15px; + animation: bounce 0.6s ease-out; + } + + .victory-title { + font-size: 2rem; + font-weight: bold; + margin: 0 0 10px 0; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + + .new-best-badge { + background: linear-gradient(45deg, #f093fb 0%, #f5576c 100%); + color: white; + padding: 8px 20px; + border-radius: 25px; + font-size: 0.9rem; + font-weight: bold; + display: inline-block; + margin-top: 10px; + animation: glow 1s ease-in-out infinite alternate; + } + + .victory-scores { + display: flex; + justify-content: space-around; + margin: 30px 0; + gap: 20px; + } + + .score-display { + background: rgba(255, 255, 255, 0.1); + border-radius: 15px; + padding: 20px; + flex: 1; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + } + + .score-label { + font-size: 0.9rem; + opacity: 0.9; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 1px; + } + + .score-value { + font-size: 2rem; + font-weight: bold; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + + .victory-stats { + background: rgba(255, 255, 255, 0.1); + border-radius: 15px; + padding: 20px; + margin: 30px 0; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + } + + .stat-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + + .stat-row:last-child { + border-bottom: none; + } + + .stat-name { + font-size: 0.95rem; + opacity: 0.9; + } + + .stat-value { + font-weight: bold; + font-size: 1rem; + } + + .victory-buttons { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 30px; + } + + .victory-btn { + padding: 15px 30px; + border: none; + border-radius: 25px; + font-size: 1rem; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; + text-transform: uppercase; + letter-spacing: 1px; + } + + .victory-btn.primary { + background: linear-gradient(45deg, #4facfe 0%, #00f2fe 100%); + color: white; + box-shadow: 0 8px 25px rgba(79, 172, 254, 0.3); + } + + .victory-btn.primary:hover { + transform: translateY(-2px); + box-shadow: 0 12px 35px rgba(79, 172, 254, 0.4); + } + + .victory-btn.secondary { + background: linear-gradient(45deg, #a8edea 0%, #fed6e3 100%); + color: #333; + box-shadow: 0 8px 25px rgba(168, 237, 234, 0.3); + } + + .victory-btn.secondary:hover { + transform: translateY(-2px); + box-shadow: 0 12px 35px rgba(168, 237, 234, 0.4); + } + + .victory-btn.tertiary { + background: rgba(255, 255, 255, 0.2); + color: white; + border: 2px solid rgba(255, 255, 255, 0.3); + backdrop-filter: blur(10px); + } + + .victory-btn.tertiary:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); + } + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px) scale(0.9); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes bounce { + 0%, 20%, 50%, 80%, 100% { + transform: translateY(0); + } + 40% { + transform: translateY(-10px); + } + 60% { + transform: translateY(-5px); + } + } + + @keyframes glow { + from { + box-shadow: 0 0 20px rgba(245, 87, 108, 0.5); + } + to { + box-shadow: 0 0 30px rgba(245, 87, 108, 0.8); + } + } + + @media (max-width: 768px) { + .victory-content { + padding: 30px 20px; + width: 95%; + } + + .victory-scores { + flex-direction: column; + gap: 15px; + } + + .victory-icon { + font-size: 3rem; + } + + .victory-title { + font-size: 1.5rem; + } + + .victory-buttons { + gap: 10px; + } + + .victory-btn { + padding: 12px 25px; + font-size: 0.9rem; + } + } + `; + + document.head.appendChild(style); + } + + _removeCSS() { + const cssId = `adventure-reader-styles-${this.name}`; + const existingStyle = document.getElementById(cssId); + if (existingStyle) { + existingStyle.remove(); + } + } + + _createGameInterface() { + this._config.container.innerHTML = ` +
+ +
+
+
+ 🏆 + 0 +
+
+ 🏺 + 0 +
+
+ ⚔️ + 0 +
+
+
+
+ Start your adventure! +
+ +
+
+ + +
+ +
🧙‍♂️
+ + +
+ + +
+
+ Click 🏺 pots for vocabulary • Click 👹 enemies for sentences +
+ + +
+ + +
+ +
+ + +
+ +
+
+ `; + } + + _initializePlayer() { + const gameMap = document.getElementById('game-map'); + if (!gameMap) { + console.error('AdventureReader: game-map element not found for player initialization'); + return; + } + + const mapRect = gameMap.getBoundingClientRect(); + this._player.x = mapRect.width / 2 - 20; + this._player.y = mapRect.height / 2 - 20; + + const playerElement = document.getElementById('player'); + if (!playerElement) { + console.error('AdventureReader: player element not found for positioning'); + return; + } + + playerElement.style.left = this._player.x + 'px'; + playerElement.style.top = this._player.y + 'px'; + } + + _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'); + if (exitButton) { + exitButton.addEventListener('click', () => { + this._eventBus.emit('game:exit-request', { instanceId: this.name }, this.name); + }); + } + + // Map click handler + const gameMap = document.getElementById('game-map'); + gameMap.addEventListener('click', (e) => this._handleMapClick(e)); + + // Window resize handler + window.addEventListener('resize', () => { + setTimeout(() => { + if (!this._isDestroyed) { + this._initializePlayer(); + } + }, 100); + }); + } + + _updateContentInfo() { + const contentInfoEl = document.getElementById('content-info'); + if (!contentInfoEl) return; + + const contentTypes = []; + + if (this._stories.length > 0) { + contentTypes.push(`📚 ${this._stories.length} stories`); + } + if (this._dialogues.length > 0) { + contentTypes.push(`💬 ${this._dialogues.length} dialogues`); + } + if (this._vocabulary.length > 0) { + contentTypes.push(`📝 ${this._vocabulary.length} words`); + } + if (this._sentences.length > 0) { + contentTypes.push(`📖 ${this._sentences.length} sentences`); + } + + if (contentTypes.length > 0) { + contentInfoEl.innerHTML = ` +
+ Adventure Content: ${contentTypes.join(' • ')} +
+ `; + } + } + + _generateGameObjects() { + const gameMap = document.getElementById('game-map'); + + // Clear existing objects + gameMap.querySelectorAll('.pot, .enemy').forEach(el => el.remove()); + + this._pots = []; + this._enemies = []; + + // Generate pots (for vocabulary) + const numPots = Math.min(this._config.maxPots, this._vocabulary.length); + for (let i = 0; i < numPots; i++) { + const pot = this._createPot(); + this._pots.push(pot); + gameMap.appendChild(pot.element); + } + + // Generate enemies (for sentences) + const numEnemies = Math.min(this._config.maxEnemies, this._sentences.length); + for (let i = 0; i < numEnemies; i++) { + const enemy = this._createEnemy(); + this._enemies.push(enemy); + gameMap.appendChild(enemy.element); + } + + this._updateHUD(); + } + + _createPot() { + const pot = document.createElement('div'); + pot.className = 'pot'; + pot.innerHTML = '🏺'; + + const position = this._getRandomPosition(); + pot.style.left = position.x + 'px'; + pot.style.top = position.y + 'px'; + + return { + element: pot, + x: position.x, + y: position.y, + destroyed: false + }; + } + + _createEnemy() { + const enemy = document.createElement('div'); + enemy.className = 'enemy'; + enemy.innerHTML = '👹'; + + const position = this._getRandomPosition(true); + enemy.style.left = position.x + 'px'; + enemy.style.top = position.y + 'px'; + + const patterns = ['patrol', 'chase', 'wander', 'circle']; + const pattern = patterns[Math.floor(Math.random() * patterns.length)]; + + return { + element: enemy, + x: position.x, + y: position.y, + defeated: false, + moveDirection: Math.random() * Math.PI * 2, + speed: 0.6 + Math.random() * 0.6, + pattern: pattern, + patrolStartX: position.x, + patrolStartY: position.y, + patrolDistance: 80 + Math.random() * 60, + circleCenter: { x: position.x, y: position.y }, + circleRadius: 60 + Math.random() * 40, + circleAngle: Math.random() * Math.PI * 2, + changeDirectionTimer: 0, + dashCooldown: 0, + isDashing: false + }; + } + + _getRandomPosition(forceAwayFromPlayer = false) { + const gameMap = document.getElementById('game-map'); + const mapRect = gameMap.getBoundingClientRect(); + const mapWidth = mapRect.width; + const mapHeight = mapRect.height; + const margin = 40; + + let x, y; + let tooClose; + const minDistance = forceAwayFromPlayer ? 150 : 80; + + do { + x = margin + Math.random() * (mapWidth - margin * 2); + y = margin + Math.random() * (mapHeight - margin * 2); + + const distFromPlayer = Math.sqrt( + Math.pow(x - this._player.x, 2) + Math.pow(y - this._player.y, 2) + ); + tooClose = distFromPlayer < minDistance; + + } while (tooClose); + + return { x, y }; + } + + _generateDecorations() { + const gameMap = document.getElementById('game-map'); + const mapRect = gameMap.getBoundingClientRect(); + const mapWidth = mapRect.width; + const mapHeight = mapRect.height; + + // Remove existing decorations + gameMap.querySelectorAll('.decoration').forEach(el => el.remove()); + + // Generate trees + const numTrees = 4 + Math.floor(Math.random() * 4); + for (let i = 0; i < numTrees; i++) { + const tree = document.createElement('div'); + tree.className = 'decoration tree'; + tree.innerHTML = Math.random() < 0.5 ? '🌳' : '🌲'; + + 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'; + + gameMap.appendChild(tree); + } + + // Generate grass patches + const numGrass = 15 + Math.floor(Math.random() * 10); + for (let i = 0; i < numGrass; i++) { + const grass = document.createElement('div'); + grass.className = 'decoration grass'; + const grassTypes = ['🌿', '🌱', '🍀', '🌾']; + grass.innerHTML = grassTypes[Math.floor(Math.random() * grassTypes.length)]; + + 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'; + + gameMap.appendChild(grass); + } + + // Generate rocks + const numRocks = 3 + Math.floor(Math.random() * 3); + for (let i = 0; i < numRocks; i++) { + const rock = document.createElement('div'); + rock.className = 'decoration rock'; + rock.innerHTML = Math.random() < 0.5 ? '🪨' : '⛰️'; + + 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'; + + gameMap.appendChild(rock); + } + } + + _getDecorationPosition(mapWidth, mapHeight, keepAwayDistance) { + const margin = 20; + let x, y; + let attempts = 0; + let validPosition = false; + + do { + x = margin + Math.random() * (mapWidth - margin * 2); + y = margin + Math.random() * (mapHeight - margin * 2); + + const distFromPlayer = Math.sqrt( + Math.pow(x - this._player.x, 2) + Math.pow(y - this._player.y, 2) + ); + + let tooClose = distFromPlayer < keepAwayDistance; + + if (!tooClose) { + this._pots.forEach(pot => { + const dist = Math.sqrt(Math.pow(x - pot.x, 2) + Math.pow(y - pot.y, 2)); + if (dist < keepAwayDistance) tooClose = true; + }); + } + + if (!tooClose) { + this._enemies.forEach(enemy => { + const dist = Math.sqrt(Math.pow(x - enemy.x, 2) + Math.pow(y - enemy.y, 2)); + if (dist < keepAwayDistance) tooClose = true; + }); + } + + validPosition = !tooClose; + attempts++; + + } while (!validPosition && attempts < 50); + + return { x, y }; + } + + _startGameLoop() { + const animate = () => { + if (!this._isGamePaused) { + this._moveEnemies(); + } + requestAnimationFrame(animate); + }; + animate(); + } + + _moveEnemies() { + const gameMap = document.getElementById('game-map'); + const mapRect = gameMap.getBoundingClientRect(); + const mapWidth = mapRect.width; + const mapHeight = mapRect.height; + + this._enemies.forEach(enemy => { + if (enemy.defeated) return; + + this._applyMovementPattern(enemy, mapWidth, mapHeight); + + // Bounce off walls + if (enemy.x < 10 || enemy.x > mapWidth - 50) { + enemy.moveDirection = Math.PI - enemy.moveDirection; + enemy.x = Math.max(10, Math.min(mapWidth - 50, enemy.x)); + } + if (enemy.y < 10 || enemy.y > mapHeight - 50) { + enemy.moveDirection = -enemy.moveDirection; + enemy.y = Math.max(10, Math.min(mapHeight - 50, enemy.y)); + } + + enemy.element.style.left = enemy.x + 'px'; + enemy.element.style.top = enemy.y + 'px'; + + this._checkPlayerEnemyCollision(enemy); + }); + } + + _applyMovementPattern(enemy, mapWidth, mapHeight) { + enemy.changeDirectionTimer++; + + switch (enemy.pattern) { + case 'patrol': + const distanceFromStart = Math.sqrt( + Math.pow(enemy.x - enemy.patrolStartX, 2) + Math.pow(enemy.y - enemy.patrolStartY, 2) + ); + + if (distanceFromStart > enemy.patrolDistance) { + const angleToStart = Math.atan2( + enemy.patrolStartY - enemy.y, + enemy.patrolStartX - enemy.x + ); + enemy.moveDirection = angleToStart; + } + + if (enemy.changeDirectionTimer > 120) { + enemy.moveDirection += (Math.random() - 0.5) * Math.PI * 0.5; + enemy.changeDirectionTimer = 0; + } + + enemy.x += Math.cos(enemy.moveDirection) * enemy.speed; + enemy.y += Math.sin(enemy.moveDirection) * enemy.speed; + break; + + case 'chase': + const angleToPlayer = Math.atan2( + this._player.y - enemy.y, + this._player.x - enemy.x + ); + 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': + if (enemy.changeDirectionTimer > 60 + Math.random() * 60) { + enemy.moveDirection += (Math.random() - 0.5) * Math.PI; + enemy.changeDirectionTimer = 0; + } + + enemy.x += Math.cos(enemy.moveDirection) * enemy.speed; + enemy.y += Math.sin(enemy.moveDirection) * enemy.speed; + break; + + case 'circle': + enemy.circleAngle += 0.03 + (enemy.speed * 0.01); + + enemy.x = enemy.circleCenter.x + Math.cos(enemy.circleAngle) * enemy.circleRadius; + enemy.y = enemy.circleCenter.y + Math.sin(enemy.circleAngle) * enemy.circleRadius; + break; + } + } + + _handleMapClick(e) { + if (this._isGamePaused || this._isPlayerMoving) return; + + const rect = e.currentTarget.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const clickY = e.clientY - rect.top; + + // Check pot clicks + let targetFound = false; + this._pots.forEach(pot => { + if (!pot.destroyed && this._isNearPosition(clickX, clickY, pot)) { + this._movePlayerToTarget(pot, 'pot'); + targetFound = true; + } + }); + + // Check enemy clicks + if (!targetFound) { + this._enemies.forEach(enemy => { + if (!enemy.defeated && this._isNearPosition(clickX, clickY, enemy)) { + this._movePlayerToTarget(enemy, 'enemy'); + targetFound = true; + } + }); + } + + // Move to empty area + if (!targetFound) { + this._movePlayerToPosition(clickX, clickY); + } + } + + _isNearPosition(clickX, clickY, object) { + const distance = Math.sqrt( + Math.pow(clickX - (object.x + 20), 2) + Math.pow(clickY - (object.y + 20), 2) + ); + return distance < 60; + } + + _movePlayerToTarget(target, type) { + this._isPlayerMoving = true; + const playerElement = document.getElementById('player'); + + if (type === 'enemy') { + this._grantAttackInvulnerability(); + } + + const targetX = target.x; + const targetY = target.y; + + this._player.x = targetX; + this._player.y = targetY; + + playerElement.style.left = targetX + 'px'; + playerElement.style.top = targetY + 'px'; + playerElement.style.transform = 'scale(1.1)'; + + setTimeout(() => { + playerElement.style.transform = 'scale(1)'; + this._isPlayerMoving = false; + + if (type === 'pot') { + this._destroyPot(target); + } else if (type === 'enemy') { + this._defeatEnemy(target); + } + }, 800); + } + + _movePlayerToPosition(targetX, targetY) { + this._isPlayerMoving = true; + const playerElement = document.getElementById('player'); + + this._player.x = targetX - 20; + this._player.y = targetY - 20; + + const gameMap = document.getElementById('game-map'); + const mapRect = gameMap.getBoundingClientRect(); + const margin = 20; + + this._player.x = Math.max(margin, Math.min(mapRect.width - 60, this._player.x)); + this._player.y = Math.max(margin, Math.min(mapRect.height - 60, this._player.y)); + + playerElement.style.left = this._player.x + 'px'; + playerElement.style.top = this._player.y + 'px'; + playerElement.style.transform = 'scale(1.1)'; + + setTimeout(() => { + playerElement.style.transform = 'scale(1)'; + this._isPlayerMoving = false; + }, 800); + } + + _destroyPot(pot) { + pot.destroyed = true; + pot.element.classList.add('destroyed'); + + pot.element.innerHTML = '💥'; + setTimeout(() => { + pot.element.style.opacity = '0.3'; + pot.element.innerHTML = '💨'; + }, 200); + + this._potsDestroyed++; + this._score += 10; + + if (this._currentVocabIndex < this._vocabulary.length) { + this._showVocabPopup(this._vocabulary[this._currentVocabIndex]); + this._currentVocabIndex++; + } + + this._updateHUD(); + this._checkGameComplete(); + } + + _defeatEnemy(enemy) { + enemy.defeated = true; + enemy.element.classList.add('defeated'); + + enemy.element.innerHTML = '☠️'; + setTimeout(() => { + enemy.element.style.opacity = '0.3'; + }, 300); + + this._enemiesDefeated++; + this._score += 25; + + this._refreshAttackInvulnerability(); + + if (this._currentSentenceIndex < this._sentences.length) { + this._showReadingModal(this._sentences[this._currentSentenceIndex]); + this._currentSentenceIndex++; + } + + this._updateHUD(); + } + + _showVocabPopup(vocab) { + const popup = document.getElementById('vocab-popup'); + const wordEl = document.getElementById('vocab-word'); + const translationEl = document.getElementById('vocab-translation'); + const pronunciationEl = document.getElementById('vocab-pronunciation'); + + wordEl.textContent = vocab.original_language; + translationEl.textContent = vocab.user_language; + + if (vocab.pronunciation) { + pronunciationEl.textContent = `🗣️ ${vocab.pronunciation}`; + pronunciationEl.style.display = 'block'; + } else { + pronunciationEl.style.display = 'none'; + } + + popup.style.display = 'flex'; + popup.classList.add('show'); + + if (this._config.autoPlayTTS && this._config.ttsEnabled) { + setTimeout(() => { + this._speakText(vocab.original_language, { rate: 0.8 }); + }, 400); + } + + setTimeout(() => { + popup.classList.remove('show'); + setTimeout(() => { + popup.style.display = 'none'; + }, 300); + }, 2000); + } + + _showReadingModal(sentence) { + this._isGamePaused = true; + const modal = document.getElementById('reading-modal'); + const content = document.getElementById('reading-content'); + const modalTitle = document.getElementById('modal-title'); + + let modalTitleText = 'Adventure Text'; + if (sentence.speaker) { + modalTitleText = `💬 ${sentence.speaker} says...`; + } else if (sentence.title) { + modalTitleText = `📚 ${sentence.title}`; + } + + modalTitle.textContent = modalTitleText; + + const speakerInfo = sentence.speaker ? `
🎭 ${sentence.speaker}
` : ''; + const titleInfo = sentence.title && !sentence.speaker ? `
📖 ${sentence.title}
` : ''; + + content.innerHTML = ` +
+ ${titleInfo} + ${speakerInfo} +
+

${sentence.original_language}

+

${sentence.user_language}

+ ${sentence.pronunciation ? `

🗣️ ${sentence.pronunciation}

` : ''} +
+
+ `; + + modal.style.display = 'flex'; + modal.classList.add('show'); + + if (this._config.autoPlayTTS && this._config.ttsEnabled) { + setTimeout(() => { + this._speakText(sentence.original_language, { rate: 0.8 }); + }, 600); + } + } + + _closeModal() { + const modal = document.getElementById('reading-modal'); + modal.classList.remove('show'); + setTimeout(() => { + modal.style.display = 'none'; + this._isGamePaused = false; + }, 300); + + this._checkGameComplete(); + } + + _checkGameComplete() { + const allPotsDestroyed = this._pots.every(pot => pot.destroyed); + const allEnemiesDefeated = this._enemies.every(enemy => enemy.defeated); + + if (allPotsDestroyed && allEnemiesDefeated) { + setTimeout(() => { + this._gameComplete(); + }, 1000); + } + } + + _gameComplete() { + this._score += 100; + this._updateHUD(); + + document.getElementById('progress-text').textContent = '🏆 Adventure Complete!'; + + // Calculate duration + const duration = Math.round((Date.now() - this._gameStartTime) / 1000); + + // Handle localStorage best score + const currentScore = this._score; + const bestScore = parseInt(localStorage.getItem('adventure-reader-best-score') || '0'); + const isNewBest = currentScore > bestScore; + + if (isNewBest) { + localStorage.setItem('adventure-reader-best-score', currentScore.toString()); + } + + setTimeout(() => { + this._showVictoryPopup({ + gameTitle: 'Adventure Reader', + currentScore, + bestScore: isNewBest ? currentScore : bestScore, + isNewBest, + stats: { + 'Pots Destroyed': this._potsDestroyed, + 'Enemies Defeated': this._enemiesDefeated, + 'Duration': `${duration}s`, + 'Bonus Score': '100' + } + }); + }, 2000); + } + + _updateHUD() { + document.getElementById('score-display').textContent = this._score; + document.getElementById('pots-counter').textContent = this._potsDestroyed; + document.getElementById('enemies-counter').textContent = this._enemiesDefeated; + + const totalObjects = this._pots.length + this._enemies.length; + const destroyedObjects = this._potsDestroyed + this._enemiesDefeated; + + document.getElementById('progress-text').textContent = + `Progress: ${destroyedObjects}/${totalObjects} objects`; + } + + _checkPlayerEnemyCollision(enemy) { + if (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) + ); + + if (distance < 35) { + this._takeDamage(); + } + } + + _takeDamage() { + if (this._isPlayerInvulnerable) return; + + this._score = Math.max(0, this._score - 20); + this._updateHUD(); + + if (this._invulnerabilityTimeout) { + clearTimeout(this._invulnerabilityTimeout); + } + + this._isPlayerInvulnerable = true; + const playerElement = document.getElementById('player'); + + let blinkCount = 0; + const blinkInterval = setInterval(() => { + playerElement.style.opacity = playerElement.style.opacity === '0.3' ? '1' : '0.3'; + blinkCount++; + + if (blinkCount >= 8) { + clearInterval(blinkInterval); + playerElement.style.opacity = '1'; + playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))'; + this._isPlayerInvulnerable = false; + } + }, 250); + + this._showDamagePopup(); + } + + _grantAttackInvulnerability() { + this._isPlayerInvulnerable = true; + const playerElement = document.getElementById('player'); + + if (this._invulnerabilityTimeout) { + clearTimeout(this._invulnerabilityTimeout); + } + + playerElement.style.filter = 'drop-shadow(0 0 15px gold) brightness(1.4)'; + + this._invulnerabilityTimeout = setTimeout(() => { + playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))'; + this._isPlayerInvulnerable = false; + }, 2000); + + this._showInvulnerabilityPopup(); + } + + _refreshAttackInvulnerability() { + if (this._invulnerabilityTimeout) { + clearTimeout(this._invulnerabilityTimeout); + } + + const playerElement = document.getElementById('player'); + this._isPlayerInvulnerable = true; + + this._invulnerabilityTimeout = setTimeout(() => { + playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))'; + this._isPlayerInvulnerable = false; + }, 2000); + } + + _showInvulnerabilityPopup() { + const popup = document.createElement('div'); + popup.className = 'invulnerability-popup'; + popup.innerHTML = 'Protected!'; + popup.style.cssText = ` + position: fixed; + left: 50%; + top: 25%; + transform: translate(-50%, -50%); + color: #FFD700; + font-size: 1.5rem; + font-weight: bold; + z-index: 999; + pointer-events: none; + animation: protectionFloat 2s ease-out forwards; + `; + + document.body.appendChild(popup); + + setTimeout(() => { + popup.remove(); + }, 2000); + } + + _showDamagePopup() { + const damagePopup = document.createElement('div'); + damagePopup.className = 'damage-popup'; + damagePopup.innerHTML = '-20'; + damagePopup.style.cssText = ` + position: fixed; + left: 50%; + top: 30%; + transform: translate(-50%, -50%); + color: #EF4444; + font-size: 2rem; + font-weight: bold; + z-index: 999; + pointer-events: none; + animation: damageFloat 1.5s ease-out forwards; + `; + + document.body.appendChild(damagePopup); + + setTimeout(() => { + damagePopup.remove(); + }, 1500); + } + + _restart() { + this._score = 0; + this._currentSentenceIndex = 0; + this._currentVocabIndex = 0; + this._potsDestroyed = 0; + this._enemiesDefeated = 0; + this._isGamePaused = false; + this._isPlayerMoving = false; + this._isPlayerInvulnerable = false; + + if (this._invulnerabilityTimeout) { + clearTimeout(this._invulnerabilityTimeout); + this._invulnerabilityTimeout = null; + } + + this._generateGameObjects(); + this._initializePlayer(); + this._generateDecorations(); + + document.getElementById('progress-text').textContent = 'Click objects to begin your adventure!'; + } + + _isGameComplete() { + const allPotsDestroyed = this._pots.every(pot => pot.destroyed); + const allEnemiesDefeated = this._enemies.every(enemy => enemy.defeated); + return allPotsDestroyed && allEnemiesDefeated; + } + + _speakText(text, options = {}) { + if (!text || !this._config.ttsEnabled) return; + + if (typeof speechSynthesis !== 'undefined') { + speechSynthesis.cancel(); + + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = this._getContentLanguage(); + utterance.rate = options.rate || 0.8; + utterance.volume = 1.0; + + speechSynthesis.speak(utterance); + } + } + + _getContentLanguage() { + if (this._content.language) { + const langMap = { + 'chinese': 'zh-CN', + 'english': 'en-US', + 'french': 'fr-FR', + 'spanish': 'es-ES' + }; + return langMap[this._content.language] || this._content.language; + } + return 'en-US'; + } + + _showVictoryPopup({ gameTitle, currentScore, bestScore, isNewBest, stats }) { + const popup = document.createElement('div'); + popup.className = 'victory-popup'; + popup.innerHTML = ` +
+
+
🏰
+

${gameTitle} Complete!

+ ${isNewBest ? '
🎉 New Best Score!
' : ''} +
+ +
+
+
Your Score
+
${currentScore}
+
+
+
Best Score
+
${bestScore}
+
+
+ +
+ ${Object.entries(stats).map(([key, value]) => ` +
+ ${key} + ${value} +
+ `).join('')} +
+ +
+ + + +
+
+ `; + + document.body.appendChild(popup); + + // Emit completion event after showing popup + this._eventBus.emit('game:completed', { + gameId: 'adventure-reader', + instanceId: this.name, + score: currentScore, + potsDestroyed: stats['Pots Destroyed'], + enemiesDefeated: stats['Enemies Defeated'], + duration: parseInt(stats['Duration'].replace('s', '')) * 1000 + }, this.name); + } + + _showError(message) { + if (this._config.container) { + this._config.container.innerHTML = ` +
+

❌ Adventure Reader Error

+

${message}

+

This content module needs adventure-compatible content:

+
    +
  • 📚 stories: Adventure texts with original and translated content
  • +
  • 💬 dialogues: Character conversations
  • +
  • 📝 vocabulary: Words with translations for discovery
  • +
  • 📖 sentences: Individual phrases for reading practice
  • +
+ +
+ `; + } + } + + _handlePause() { + this._isGamePaused = true; + this._eventBus.emit('game:paused', { instanceId: this.name }, this.name); + } + + _handleResume() { + this._isGamePaused = false; + this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name); + } +} + +export default AdventureReader; \ No newline at end of file diff --git a/src/games/chinese-study.js b/src/games/ChineseStudy.js similarity index 59% rename from src/games/chinese-study.js rename to src/games/ChineseStudy.js index 5f13f83..2ad2ce7 100644 --- a/src/games/chinese-study.js +++ b/src/games/ChineseStudy.js @@ -1,109 +1,187 @@ -// === CHINESE STUDY MODE === +import Module from '../core/Module.js'; -class ChineseStudyGame { - constructor(options) { - this.container = options.container; - this.content = options.content; - this.onScoreUpdate = options.onScoreUpdate || (() => {}); - this.onGameEnd = options.onGameEnd || (() => {}); +class ChineseStudy extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); - // Game state - this.vocabulary = []; - this.currentMode = null; - this.currentIndex = 0; - this.score = 0; - this.correctAnswers = 0; - this.isRunning = false; - this.studyState = 'menu'; // 'menu', 'playing', 'review' - - // Extract vocabulary - this.vocabulary = this.extractVocabulary(this.content); - - this.init(); - } - - init() { - // Check if we have enough vocabulary - if (!this.vocabulary || this.vocabulary.length === 0) { - logSh('No Chinese vocabulary found for Chinese Study Game', 'ERROR'); - this.showInitError(); - return; + if (!dependencies.eventBus || !dependencies.content) { + throw new Error('ChineseStudy requires eventBus and content dependencies'); } - this.createGameInterface(); + this._eventBus = dependencies.eventBus; + this._content = dependencies.content; + this._config = { + difficulty: 'medium', + ...config + }; + + this._vocabulary = []; + this._currentMode = null; + this._currentIndex = 0; + this._score = 0; + this._correctAnswers = 0; + this._isRunning = false; + this._studyState = 'menu'; + this._gameContainer = null; + + Object.seal(this); } - showInitError() { - this.container.innerHTML = ` + static getMetadata() { + return { + id: 'chinese-study', + name: 'Chinese Study', + description: 'Comprehensive Chinese language learning with flashcards, recognition, and pinyin practice', + version: '2.0.0', + author: 'Class Generator', + category: 'language', + tags: ['chinese', 'vocabulary', 'pinyin', 'characters', 'study'], + difficulty: { + min: 1, + max: 4, + default: 2 + }, + estimatedDuration: 15, + requiredContent: ['vocabulary'] + }; + } + + static getCompatibilityScore(content) { + if (!content || !content.vocabulary) { + return 0; + } + + let score = 30; + + if (typeof content.vocabulary === 'object') { + const vocabCount = Object.keys(content.vocabulary).length; + if (vocabCount >= 5) score += 20; + if (vocabCount >= 15) score += 15; + if (vocabCount >= 30) score += 10; + + const hasPinyin = Object.values(content.vocabulary).some(word => + typeof word === 'object' && word.pronunciation + ); + const hasHSK = Object.values(content.vocabulary).some(word => + typeof word === 'object' && word.hskLevel + ); + const hasChineseChars = Object.keys(content.vocabulary).some(key => + /[\u4e00-\u9fff]/.test(key) + ); + + if (hasPinyin) score += 10; + if (hasHSK) score += 10; + if (hasChineseChars) score += 5; + } + + return Math.min(score, 100); + } + + async init() { + this._validateNotDestroyed(); + + this._eventBus.on('game:start', this._handleGameStart.bind(this), this.name); + this._eventBus.on('game:stop', this._handleGameStop.bind(this), this.name); + this._eventBus.on('navigation:change', this._handleNavigationChange.bind(this), this.name); + + this._injectCSS(); + this._setInitialized(); + } + + async destroy() { + this._validateNotDestroyed(); + + this._cleanup(); + this._removeCSS(); + this._eventBus.off('game:start', this.name); + this._eventBus.off('game:stop', this.name); + this._eventBus.off('navigation:change', this.name); + + this._setDestroyed(); + } + + _handleGameStart(event) { + this._validateInitialized(); + if (event.gameId === 'chinese-study') { + this._startGame(); + } + } + + _handleGameStop(event) { + this._validateInitialized(); + if (event.gameId === 'chinese-study') { + this._stopGame(); + } + } + + _handleNavigationChange(event) { + this._validateInitialized(); + if (event.from === '/games/chinese-study') { + this._cleanup(); + } + } + + async _startGame() { + try { + this._gameContainer = document.getElementById('game-content'); + if (!this._gameContainer) { + throw new Error('Game container not found'); + } + + const content = await this._content.getCurrentContent(); + if (!content) { + throw new Error('No content available'); + } + + this._vocabulary = this._extractVocabulary(content); + + if (this._vocabulary.length === 0) { + throw new Error('No Chinese vocabulary found for Chinese Study Game'); + } + + this._createGameInterface(); + + } catch (error) { + console.error('Error starting Chinese Study:', error); + this._showInitError(error.message); + } + } + + _stopGame() { + this._cleanup(); + } + + _cleanup() { + this._isRunning = false; + if (this._gameContainer) { + this._gameContainer.innerHTML = ''; + } + if (window.chineseStudyInstance === this) { + delete window.chineseStudyInstance; + } + } + + _showInitError(message) { + this._gameContainer.innerHTML = `
-

❌ Error loading

-

This content doesn't have Chinese vocabulary for the Chinese Study Game.

+

❌ Loading Error

+

${message}

The game needs vocabulary with Chinese characters, translations, and optional pinyin.

- +
`; - this.addStyles(); } - extractVocabulary(content) { + _extractVocabulary(content) { let vocabulary = []; - logSh('🔍 Extracting Chinese vocabulary from:', content?.name || 'content', 'INFO'); - - // Priority 1: Use raw module content (simple format) - if (content.rawContent) { - logSh('📦 Using raw module content', 'INFO'); - return this.extractVocabularyFromRaw(content.rawContent); - } - - // Priority 2: Ultra-modular format (vocabulary object) - ONLY format supported if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) { - logSh('✨ Ultra-modular format detected (vocabulary object)', 'INFO'); vocabulary = Object.entries(content.vocabulary).map(([word, data]) => { - // Support ultra-modular format ONLY - if (typeof data === 'object' && data.user_language) { - return { - chinese: word, // Clé = caractère chinois - translation: data.user_language.split(';')[0], // First translation - fullTranslation: data.user_language, // Complete translation - pronunciation: data.pronunciation || '', // Pinyin - type: data.type || 'general', - hskLevel: data.hskLevel || null, - examples: data.examples || [], - strokeOrder: data.strokeOrder || [] - }; - } - // Legacy fallback - simple string (temporary, will be removed) - else if (typeof data === 'string') { + if (typeof data === 'object' && data.translation) { return { chinese: word, - translation: data.split(';')[0], - fullTranslation: data, - pronunciation: '', - type: 'general', - hskLevel: null - }; - } - return null; - }).filter(Boolean); - } - // No other formats supported - ultra-modular only - - return this.finalizeVocabulary(vocabulary); - } - - extractVocabularyFromRaw(rawContent) { - logSh('🔧 Extracting from raw content:', rawContent.name || 'Module', 'INFO'); - let vocabulary = []; - - // Extract from vocabulary object in raw content - if (rawContent.vocabulary && typeof rawContent.vocabulary === 'object') { - vocabulary = Object.entries(rawContent.vocabulary).map(([word, data]) => { - if (typeof data === 'object' && data.user_language) { - return { - chinese: word, - translation: data.user_language.split(';')[0], - fullTranslation: data.user_language, + translation: data.translation.split(';')[0], + fullTranslation: data.translation, pronunciation: data.pronunciation || '', type: data.type || 'general', hskLevel: data.hskLevel || null, @@ -117,49 +195,39 @@ class ChineseStudyGame { fullTranslation: data, pronunciation: '', type: 'general', - hskLevel: null + hskLevel: null, + examples: [], + strokeOrder: [] }; } return null; }).filter(Boolean); } - return vocabulary; - } - - finalizeVocabulary(vocabulary) { - if (vocabulary.length === 0) { - logSh('⚠️ No valid vocabulary found', 'WARNING'); - return []; - } - - // Shuffle vocabulary vocabulary = vocabulary.sort(() => Math.random() - 0.5); - - logSh(`✅ Vocabulary extraction complete: ${vocabulary.length} items`, 'INFO'); + console.log(`Chinese Study: ${vocabulary.length} vocabulary items loaded`); return vocabulary; } - createGameInterface() { - if (this.studyState === 'menu') { - this.createModeSelection(); - } else if (this.studyState === 'playing') { - this.createStudyMode(); + _createGameInterface() { + if (this._studyState === 'menu') { + this._createModeSelection(); + } else if (this._studyState === 'playing') { + this._createStudyMode(); } - this.addStyles(); } - createModeSelection() { - const hasPinyin = this.vocabulary.some(item => item.pronunciation); - const hasHSK = this.vocabulary.some(item => item.hskLevel); + _createModeSelection() { + const hasPinyin = this._vocabulary.some(item => item.pronunciation); + const hasHSK = this._vocabulary.some(item => item.hskLevel); - this.container.innerHTML = ` + this._gameContainer.innerHTML = `

🇨🇳 Chinese Study Mode

-
Score: ${this.score}
-
${this.vocabulary.length} characters available
+
Score: ${this._score}
+
${this._vocabulary.length} characters available
${hasHSK ? '
📊 HSK levels included
' : ''} ${hasPinyin ? '
🗣️ Pinyin available
' : ''}
@@ -201,7 +269,7 @@ class ChineseStudyGame {

📖 Vocabulary Preview

- ${this.vocabulary.slice(0, 6).map(item => ` + ${this._vocabulary.slice(0, 6).map(item => `
${item.chinese} ${item.translation} @@ -209,52 +277,70 @@ class ChineseStudyGame { ${item.hskLevel ? `${item.hskLevel}` : ''}
`).join('')} - ${this.vocabulary.length > 6 ? `
... and ${this.vocabulary.length - 6} more
` : ''} + ${this._vocabulary.length > 6 ? `
... and ${this._vocabulary.length - 6} more
` : ''}
- +
`; - this.setupModeListeners(); + this._setupModeListeners(); } - createStudyMode() { - const currentItem = this.vocabulary[this.currentIndex]; - const progress = Math.round(((this.currentIndex + 1) / this.vocabulary.length) * 100); + _setupModeListeners() { + const modeCards = this._gameContainer.querySelectorAll('.mode-card:not([data-disabled])'); + modeCards.forEach(card => { + card.addEventListener('click', (e) => { + const mode = card.dataset.mode; + this._startMode(mode); + }); + }); + } + + _startMode(mode) { + this._currentMode = mode; + this._studyState = 'playing'; + this._currentIndex = 0; + this._correctAnswers = 0; + this._createGameInterface(); + } + + _createStudyMode() { + const currentItem = this._vocabulary[this._currentIndex]; + const progress = Math.round(((this._currentIndex + 1) / this._vocabulary.length) * 100); let modeContent = ''; - switch (this.currentMode) { + switch (this._currentMode) { case 'flashcards': - modeContent = this.createFlashcardMode(currentItem); + modeContent = this._createFlashcardMode(currentItem); break; case 'recognition': - modeContent = this.createRecognitionMode(currentItem); + modeContent = this._createRecognitionMode(currentItem); break; case 'pinyin': - modeContent = this.createPinyinMode(currentItem); + modeContent = this._createPinyinMode(currentItem); break; case 'hsk': - modeContent = this.createHSKMode(currentItem); + modeContent = this._createHSKMode(currentItem); break; } - this.container.innerHTML = ` + this._gameContainer.innerHTML = `
-

${this.getModeTitle()}

- +

${this._getModeTitle()}

+
-
${this.currentIndex + 1} / ${this.vocabulary.length}
-
Score: ${this.score}
+
${this._currentIndex + 1} / ${this._vocabulary.length}
+
Score: ${this._score}
@@ -263,92 +349,65 @@ class ChineseStudyGame {
- -
`; - this.setupStudyListeners(); + this._setupStudyListeners(); } - setupModeListeners() { - const modeCards = this.container.querySelectorAll('.mode-card:not([data-disabled])'); - modeCards.forEach(card => { - card.addEventListener('click', (e) => { - const mode = card.dataset.mode; - this.startMode(mode); - }); - }); - } - - setupStudyListeners() { - // Bind this context to methods for onclick handlers + _setupStudyListeners() { window.chineseStudyInstance = this; - // Override global onclick handlers - this.container.querySelector('.back-to-menu-btn').onclick = () => this.backToMenu(); - this.container.querySelector('.prev-btn').onclick = () => this.previousItem(); - this.container.querySelector('.next-btn').onclick = () => this.nextItem(); + this._gameContainer.querySelector('.back-to-menu-btn').onclick = () => this._backToMenu(); + this._gameContainer.querySelector('.prev-btn').onclick = () => this._previousItem(); + this._gameContainer.querySelector('.next-btn').onclick = () => this._nextItem(); - // Setup mode-specific listeners - this.setupModeSpecificListeners(); + this._setupModeSpecificListeners(); } - setupModeSpecificListeners() { - if (this.currentMode === 'flashcards') { - const flashcard = this.container.querySelector('.flashcard'); + _setupModeSpecificListeners() { + if (this._currentMode === 'flashcards') { + const flashcard = this._gameContainer.querySelector('.flashcard'); if (flashcard) { - flashcard.addEventListener('click', () => this.flipCard()); + flashcard.addEventListener('click', () => this._flipCard()); } - } else if (this.currentMode === 'recognition') { - const options = this.container.querySelectorAll('.option-btn'); + } else if (this._currentMode === 'recognition') { + const options = this._gameContainer.querySelectorAll('.option-btn'); options.forEach(option => { - option.addEventListener('click', (e) => this.selectOption(e.target.dataset.translation)); + option.addEventListener('click', (e) => this._selectOption(e.target.dataset.translation)); }); - } else if (this.currentMode === 'pinyin') { - const pinyinInput = this.container.querySelector('.pinyin-input'); + } else if (this._currentMode === 'pinyin') { + const pinyinInput = this._gameContainer.querySelector('.pinyin-input'); if (pinyinInput) { pinyinInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') this.checkPinyinAnswer(); + if (e.key === 'Enter') this._checkPinyinAnswer(); }); } - const checkBtn = this.container.querySelector('.check-pinyin-btn'); + const checkBtn = this._gameContainer.querySelector('.check-pinyin-btn'); if (checkBtn) { - checkBtn.onclick = () => this.checkPinyinAnswer(); + checkBtn.onclick = () => this._checkPinyinAnswer(); } } } - startMode(mode) { - this.currentMode = mode; - this.studyState = 'playing'; - this.currentIndex = 0; - this.correctAnswers = 0; - this.createGameInterface(); - } - - backToMenu() { - this.studyState = 'menu'; - this.currentMode = null; - this.createGameInterface(); - } - - getModeTitle() { + _getModeTitle() { const titles = { flashcards: '📚 Flashcards', recognition: '🧠 Character Recognition', pinyin: '🗣️ Pinyin Practice', hsk: '📊 HSK Review' }; - return titles[this.currentMode] || 'Chinese Study'; + return titles[this._currentMode] || 'Chinese Study'; } - createFlashcardMode(item) { + _createFlashcardMode(item) { return `
@@ -364,16 +423,15 @@ class ChineseStudyGame {
- - + +
`; } - createRecognitionMode(item) { - // Create wrong options - const wrongOptions = this.vocabulary + _createRecognitionMode(item) { + const wrongOptions = this._vocabulary .filter(v => v.chinese !== item.chinese) .sort(() => Math.random() - 0.5) .slice(0, 3) @@ -400,7 +458,7 @@ class ChineseStudyGame { `; } - createPinyinMode(item) { + _createPinyinMode(item) { return `
@@ -418,7 +476,7 @@ class ChineseStudyGame { `; } - createHSKMode(item) { + _createHSKMode(item) { const hskInfo = item.hskLevel || 'No HSK level'; return `
@@ -441,105 +499,116 @@ class ChineseStudyGame {
- - - + + +
`; } - // Navigation methods - nextItem() { - if (this.currentIndex < this.vocabulary.length - 1) { - this.currentIndex++; - this.createStudyMode(); + _nextItem() { + if (this._currentIndex < this._vocabulary.length - 1) { + this._currentIndex++; + this._createStudyMode(); } } - previousItem() { - if (this.currentIndex > 0) { - this.currentIndex--; - this.createStudyMode(); + _previousItem() { + if (this._currentIndex > 0) { + this._currentIndex--; + this._createStudyMode(); } } - // Flashcard methods - flipCard() { - const flashcard = this.container.querySelector('.flashcard'); + _backToMenu() { + this._studyState = 'menu'; + this._currentMode = null; + this._createGameInterface(); + } + + _flipCard() { + const flashcard = this._gameContainer.querySelector('.flashcard'); const isFlipped = flashcard.dataset.flipped === 'true'; flashcard.dataset.flipped = (!isFlipped).toString(); } - markAsKnown(known) { + _markAsKnown(known) { const points = known ? 10 : 5; - this.score += points; - this.correctAnswers += known ? 1 : 0; - this.onScoreUpdate(this.score); - this.updateScoreDisplay(); + this._score += points; + this._correctAnswers += known ? 1 : 0; + + this._eventBus.emit('game:score-update', { + gameId: 'chinese-study', + score: this._score, + module: this.name + }); + + this._updateScoreDisplay(); - // Auto-advance after a short delay setTimeout(() => { - if (this.currentIndex < this.vocabulary.length - 1) { - this.nextItem(); + if (this._currentIndex < this._vocabulary.length - 1) { + this._nextItem(); } else { - this.endStudySession(); + this._endStudySession(); } }, 1000); } - // Recognition mode methods - selectOption(selectedTranslation) { - const currentItem = this.vocabulary[this.currentIndex]; + _selectOption(selectedTranslation) { + const currentItem = this._vocabulary[this._currentIndex]; const isCorrect = selectedTranslation === currentItem.translation; - const feedback = this.container.querySelector('.result-feedback'); + const feedback = this._gameContainer.querySelector('.result-feedback'); if (isCorrect) { - this.score += 15; - this.correctAnswers++; + this._score += 15; + this._correctAnswers++; feedback.innerHTML = '✅ Correct! Well done!'; feedback.className = 'result-feedback correct'; } else { - this.score = Math.max(0, this.score - 5); + this._score = Math.max(0, this._score - 5); feedback.innerHTML = `❌ Incorrect. The correct answer is: ${currentItem.translation}`; feedback.className = 'result-feedback incorrect'; } feedback.style.display = 'block'; - this.onScoreUpdate(this.score); - this.updateScoreDisplay(); - // Disable all option buttons - const options = this.container.querySelectorAll('.option-btn'); + this._eventBus.emit('game:score-update', { + gameId: 'chinese-study', + score: this._score, + module: this.name + }); + + this._updateScoreDisplay(); + + const options = this._gameContainer.querySelectorAll('.option-btn'); options.forEach(btn => btn.disabled = true); - // Auto-advance after a delay setTimeout(() => { - if (this.currentIndex < this.vocabulary.length - 1) { - this.nextItem(); + if (this._currentIndex < this._vocabulary.length - 1) { + this._nextItem(); } else { - this.endStudySession(); + this._endStudySession(); } }, 2000); } - // Pinyin mode methods - checkPinyinAnswer() { - const input = this.container.querySelector('.pinyin-input'); + _checkPinyinAnswer() { + const input = this._gameContainer.querySelector('.pinyin-input'); const userAnswer = input.value.trim().toLowerCase(); - const currentItem = this.vocabulary[this.currentIndex]; + const currentItem = this._vocabulary[this._currentIndex]; const correctPinyin = currentItem.pronunciation ? currentItem.pronunciation.toLowerCase() : ''; - const feedback = this.container.querySelector('.pinyin-feedback'); - const correctAnswer = this.container.querySelector('.correct-answer'); + const feedback = this._gameContainer.querySelector('.pinyin-feedback'); + const correctAnswer = this._gameContainer.querySelector('.correct-answer'); - if (correctPinyin && this.normalizePinyin(userAnswer) === this.normalizePinyin(correctPinyin)) { - this.score += 20; - this.correctAnswers++; + if (correctPinyin && this._normalizePinyin(userAnswer) === this._normalizePinyin(correctPinyin)) { + this._score += 20; + this._correctAnswers++; feedback.innerHTML = '🎉 Excellent pronunciation!'; feedback.className = 'pinyin-feedback correct'; } else { - this.score = Math.max(0, this.score - 3); + this._score = Math.max(0, this._score - 3); feedback.innerHTML = '🤔 Not quite right. Try again or see the correct answer below.'; feedback.className = 'pinyin-feedback incorrect'; if (correctAnswer) correctAnswer.style.display = 'block'; @@ -547,128 +616,130 @@ class ChineseStudyGame { feedback.style.display = 'block'; input.disabled = true; - this.container.querySelector('.check-pinyin-btn').disabled = true; - this.onScoreUpdate(this.score); - this.updateScoreDisplay(); + this._gameContainer.querySelector('.check-pinyin-btn').disabled = true; + + this._eventBus.emit('game:score-update', { + gameId: 'chinese-study', + score: this._score, + module: this.name + }); + + this._updateScoreDisplay(); - // Auto-advance after a delay setTimeout(() => { - if (this.currentIndex < this.vocabulary.length - 1) { - this.nextItem(); + if (this._currentIndex < this._vocabulary.length - 1) { + this._nextItem(); } else { - this.endStudySession(); + this._endStudySession(); } }, 3000); } - normalizePinyin(pinyin) { - // Remove tone marks and accents for easier comparison - return pinyin.replace(/[āáǎàēéěèīíǐìōóǒòūúǔùüǖǘǚǜ]/g, (match) => { - const toneMap = { - 'ā': 'a', 'á': 'a', 'ǎ': 'a', 'à': 'a', - 'ē': 'e', 'é': 'e', 'ě': 'e', 'è': 'e', - 'ī': 'i', 'í': 'i', 'ǐ': 'i', 'ì': 'i', - 'ō': 'o', 'ó': 'o', 'ǒ': 'o', 'ò': 'o', - 'ū': 'u', 'ú': 'u', 'ǔ': 'u', 'ù': 'u', - 'ü': 'u', 'ǖ': 'u', 'ǘ': 'u', 'ǚ': 'u', 'ǜ': 'u' - }; - return toneMap[match] || match; - }).replace(/\s+/g, ''); - } - - // HSK mode methods - markDifficulty(difficulty) { + _markDifficulty(difficulty) { const points = { 'easy': 5, 'medium': 8, 'hard': 12 }; - this.score += points[difficulty]; - this.correctAnswers++; - this.onScoreUpdate(this.score); - this.updateScoreDisplay(); + this._score += points[difficulty]; + this._correctAnswers++; - // Visual feedback - const buttons = this.container.querySelectorAll('.difficulty-btn'); + this._eventBus.emit('game:score-update', { + gameId: 'chinese-study', + score: this._score, + module: this.name + }); + + this._updateScoreDisplay(); + + const buttons = this._gameContainer.querySelectorAll('.difficulty-btn'); buttons.forEach(btn => btn.disabled = true); - const selectedBtn = this.container.querySelector(`.difficulty-btn.${difficulty}`); + const selectedBtn = this._gameContainer.querySelector(`.difficulty-btn.${difficulty}`); selectedBtn.style.backgroundColor = '#10b981'; selectedBtn.style.color = 'white'; - // Auto-advance after a delay setTimeout(() => { - if (this.currentIndex < this.vocabulary.length - 1) { - this.nextItem(); + if (this._currentIndex < this._vocabulary.length - 1) { + this._nextItem(); } else { - this.endStudySession(); + this._endStudySession(); } }, 1500); } - updateScoreDisplay() { - const scoreElement = this.container.querySelector('#score'); + _updateScoreDisplay() { + const scoreElement = this._gameContainer.querySelector('#score'); if (scoreElement) { - scoreElement.textContent = this.score; + scoreElement.textContent = this._score; } } - endStudySession() { - const accuracy = Math.round((this.correctAnswers / this.vocabulary.length) * 100); + _normalizePinyin(pinyin) { + const toneMap = { + 'ā': 'a', 'á': 'a', 'ǎ': 'a', 'à': 'a', + 'ē': 'e', 'é': 'e', 'ě': 'e', 'è': 'e', + 'ī': 'i', 'í': 'i', 'ǐ': 'i', 'ì': 'i', + 'ō': 'o', 'ó': 'o', 'ǒ': 'o', 'ò': 'o', + 'ū': 'u', 'ú': 'u', 'ǔ': 'u', 'ù': 'u', + 'ü': 'u', 'ǖ': 'u', 'ǘ': 'u', 'ǚ': 'u', 'ǜ': 'u' + }; + return pinyin.replace(/[āáǎàēéěèīíǐìōóǒòūúǔùüǖǘǚǜ]/g, (match) => { + return toneMap[match] || match; + }).replace(/\s+/g, ''); + } - this.container.innerHTML = ` -
-
-

🎓 Study Session Complete!

-
-
-
${this.score}
-
Final Score
-
-
-
${this.correctAnswers}/${this.vocabulary.length}
-
Correct
-
-
-
${accuracy}%
-
Accuracy
-
-
-
- ${accuracy >= 80 ? '🌟 Excellent work! You\'ve mastered these characters!' : - accuracy >= 60 ? '👍 Good job! Keep practicing to improve further.' : - '💪 Nice effort! More practice will help you improve.'} -
-
- - - -
-
-
- `; + _endStudySession() { + const accuracy = Math.round((this._correctAnswers / this._vocabulary.length) * 100); - this.addStyles(); + // Handle localStorage best score + const currentScore = this._score; + const bestScore = parseInt(localStorage.getItem('chinese-study-best-score') || '0'); + const isNewBest = currentScore > bestScore; - // Trigger game end callback - this.onGameEnd({ - score: this.score, - accuracy: accuracy, - mode: this.currentMode, - totalItems: this.vocabulary.length, - correctAnswers: this.correctAnswers + if (isNewBest) { + localStorage.setItem('chinese-study-best-score', currentScore.toString()); + } + + this._showVictoryPopup({ + gameTitle: 'Chinese Study', + currentScore, + bestScore: isNewBest ? currentScore : bestScore, + isNewBest, + stats: { + 'Study Mode': this._currentMode || 'General', + 'Accuracy': `${accuracy}%`, + 'Correct Answers': `${this._correctAnswers}/${this._vocabulary.length}`, + 'Characters Studied': this._vocabulary.length + } }); } - addStyles() { + _restart() { + this._score = 0; + this._correctAnswers = 0; + this._currentIndex = 0; + this._studyState = 'menu'; + this._currentMode = null; + + this._vocabulary = this._vocabulary.sort(() => Math.random() - 0.5); + this._createGameInterface(); + console.log('Chinese Study restarted'); + } + + _injectCSS() { + const cssId = 'chinese-study-styles'; + if (document.getElementById(cssId)) return; + const style = document.createElement('style'); + style.id = cssId; style.textContent = ` .chinese-study-container { max-width: 1000px; margin: 0 auto; padding: 20px; - font-family: 'Arial', sans-serif; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } .game-header { @@ -858,7 +929,6 @@ class ChineseStudyGame { padding: 10px; } - /* Study Mode Styles */ .study-mode-active { max-width: 800px; } @@ -936,7 +1006,6 @@ class ChineseStudyGame { justify-content: center; } - /* Flashcard Styles */ .flashcard-container { width: 100%; max-width: 400px; @@ -1048,20 +1117,19 @@ class ChineseStudyGame { background: #d97706; } - /* Recognition Mode Styles */ - .recognition-container { + .recognition-container, .pinyin-container, .hsk-container { width: 100%; text-align: center; } - .question-section { + .question-section, .character-section, .hsk-header { margin-bottom: 30px; } - .pronunciation-hint { + .pronunciation-hint, .translation-hint { color: #6b7280; font-style: italic; - margin-bottom: 15px; + margin: 10px 0; } .question-text { @@ -1097,41 +1165,25 @@ class ChineseStudyGame { opacity: 0.6; } - .result-feedback { + .result-feedback, .pinyin-feedback { padding: 15px; border-radius: 8px; font-weight: bold; margin-bottom: 15px; } - .result-feedback.correct { + .result-feedback.correct, .pinyin-feedback.correct { background: #d1fae5; color: #065f46; border: 1px solid #10b981; } - .result-feedback.incorrect { + .result-feedback.incorrect, .pinyin-feedback.incorrect { background: #fee2e2; color: #991b1b; border: 1px solid #ef4444; } - /* Pinyin Mode Styles */ - .pinyin-container { - width: 100%; - text-align: center; - } - - .character-section { - margin-bottom: 30px; - } - - .translation-hint { - font-size: 1.2em; - color: #6b7280; - margin-top: 10px; - } - .pinyin-exercise { margin-bottom: 20px; } @@ -1178,25 +1230,6 @@ class ChineseStudyGame { cursor: not-allowed; } - .pinyin-feedback { - padding: 15px; - border-radius: 8px; - font-weight: bold; - margin-bottom: 15px; - } - - .pinyin-feedback.correct { - background: #d1fae5; - color: #065f46; - border: 1px solid #10b981; - } - - .pinyin-feedback.incorrect { - background: #fee2e2; - color: #991b1b; - border: 1px solid #ef4444; - } - .correct-answer { background: #f0f9ff; color: #0c4a6e; @@ -1206,16 +1239,6 @@ class ChineseStudyGame { font-weight: bold; } - /* HSK Mode Styles */ - .hsk-container { - width: 100%; - text-align: center; - } - - .hsk-header { - margin-bottom: 30px; - } - .hsk-level-badge { background: #f59e0b; color: white; @@ -1330,7 +1353,6 @@ class ChineseStudyGame { opacity: 0.7; } - /* Study Controls */ .study-controls { display: flex; justify-content: space-between; @@ -1359,7 +1381,6 @@ class ChineseStudyGame { cursor: not-allowed; } - /* Study Complete Styles */ .study-complete { text-align: center; padding: 40px 20px; @@ -1546,40 +1567,310 @@ class ChineseStudyGame { gap: 10px; } } + + /* Victory Popup Styles */ + .victory-popup { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: fadeIn 0.3s ease-out; + } + + .victory-content { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 20px; + padding: 40px; + text-align: center; + color: white; + max-width: 500px; + width: 90%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: slideUp 0.4s ease-out; + } + + .victory-header { + margin-bottom: 30px; + } + + .victory-icon { + font-size: 4rem; + margin-bottom: 15px; + animation: bounce 0.6s ease-out; + } + + .victory-title { + font-size: 2rem; + font-weight: bold; + margin: 0 0 10px 0; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + + .new-best-badge { + background: linear-gradient(45deg, #f093fb 0%, #f5576c 100%); + color: white; + padding: 8px 20px; + border-radius: 25px; + font-size: 0.9rem; + font-weight: bold; + display: inline-block; + margin-top: 10px; + animation: glow 1s ease-in-out infinite alternate; + } + + .victory-scores { + display: flex; + justify-content: space-around; + margin: 30px 0; + gap: 20px; + } + + .score-display { + background: rgba(255, 255, 255, 0.1); + border-radius: 15px; + padding: 20px; + flex: 1; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + } + + .score-label { + font-size: 0.9rem; + opacity: 0.9; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 1px; + } + + .score-value { + font-size: 2rem; + font-weight: bold; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + + .victory-stats { + background: rgba(255, 255, 255, 0.1); + border-radius: 15px; + padding: 20px; + margin: 30px 0; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + } + + .stat-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + + .stat-row:last-child { + border-bottom: none; + } + + .stat-name { + font-size: 0.95rem; + opacity: 0.9; + } + + .stat-value { + font-weight: bold; + font-size: 1rem; + } + + .victory-buttons { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 30px; + } + + .victory-btn { + padding: 15px 30px; + border: none; + border-radius: 25px; + font-size: 1rem; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; + text-transform: uppercase; + letter-spacing: 1px; + } + + .victory-btn.primary { + background: linear-gradient(45deg, #4facfe 0%, #00f2fe 100%); + color: white; + box-shadow: 0 8px 25px rgba(79, 172, 254, 0.3); + } + + .victory-btn.primary:hover { + transform: translateY(-2px); + box-shadow: 0 12px 35px rgba(79, 172, 254, 0.4); + } + + .victory-btn.secondary { + background: linear-gradient(45deg, #a8edea 0%, #fed6e3 100%); + color: #333; + box-shadow: 0 8px 25px rgba(168, 237, 234, 0.3); + } + + .victory-btn.secondary:hover { + transform: translateY(-2px); + box-shadow: 0 12px 35px rgba(168, 237, 234, 0.4); + } + + .victory-btn.tertiary { + background: rgba(255, 255, 255, 0.2); + color: white; + border: 2px solid rgba(255, 255, 255, 0.3); + backdrop-filter: blur(10px); + } + + .victory-btn.tertiary:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); + } + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px) scale(0.9); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes bounce { + 0%, 20%, 50%, 80%, 100% { + transform: translateY(0); + } + 40% { + transform: translateY(-10px); + } + 60% { + transform: translateY(-5px); + } + } + + @keyframes glow { + from { + box-shadow: 0 0 20px rgba(245, 87, 108, 0.5); + } + to { + box-shadow: 0 0 30px rgba(245, 87, 108, 0.8); + } + } + + @media (max-width: 768px) { + .victory-content { + padding: 30px 20px; + width: 95%; + } + + .victory-scores { + flex-direction: column; + gap: 15px; + } + + .victory-icon { + font-size: 3rem; + } + + .victory-title { + font-size: 1.5rem; + } + + .victory-buttons { + gap: 10px; + } + + .victory-btn { + padding: 12px 25px; + font-size: 0.9rem; + } + } `; + document.head.appendChild(style); } - start() { - this.isRunning = true; - logSh('Chinese Study Mode initialized with ultra-modular format', 'INFO'); + _showVictoryPopup({ gameTitle, currentScore, bestScore, isNewBest, stats }) { + const popup = document.createElement('div'); + popup.className = 'victory-popup'; + popup.innerHTML = ` +
+
+
🎓
+

${gameTitle} Complete!

+ ${isNewBest ? '
🎉 New Best Score!
' : ''} +
+ +
+
+
Your Score
+
${currentScore}
+
+
+
Best Score
+
${bestScore}
+
+
+ +
+ ${Object.entries(stats).map(([key, value]) => ` +
+ ${key} + ${value} +
+ `).join('')} +
+ +
+ + + +
+
+ `; + + document.body.appendChild(popup); + + // Emit completion event after showing popup + this._eventBus.emit('game:end', { + gameId: 'chinese-study', + score: currentScore, + accuracy: parseInt(stats['Accuracy'].replace('%', '')), + mode: this._currentMode, + totalItems: this._vocabulary.length, + correctAnswers: this._correctAnswers, + module: this.name + }); } - destroy() { - this.isRunning = false; - // Clean up global references + _removeCSS() { + const cssElement = document.getElementById('chinese-study-styles'); + if (cssElement) { + cssElement.remove(); + } + if (window.chineseStudyInstance === this) { delete window.chineseStudyInstance; } - logSh('Chinese Study Mode destroyed', 'INFO'); - } - - restart() { - this.score = 0; - this.correctAnswers = 0; - this.currentIndex = 0; - this.studyState = 'menu'; - this.currentMode = null; - this.onScoreUpdate(this.score); - - // Re-shuffle vocabulary - this.vocabulary = this.vocabulary.sort(() => Math.random() - 0.5); - - this.createGameInterface(); - logSh('Chinese Study Mode restarted', 'INFO'); } } -// Export to global scope -window.GameModules = window.GameModules || {}; -window.GameModules.ChineseStudy = ChineseStudyGame; \ No newline at end of file +export default ChineseStudy; \ No newline at end of file diff --git a/src/games/FillTheBlank.js b/src/games/FillTheBlank.js new file mode 100644 index 0000000..7ec3e58 --- /dev/null +++ b/src/games/FillTheBlank.js @@ -0,0 +1,900 @@ +import Module from '../core/Module.js'; + +class FillTheBlank extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); + + if (!dependencies.eventBus || !dependencies.content) { + throw new Error('FillTheBlank requires eventBus and content dependencies'); + } + + this._eventBus = dependencies.eventBus; + this._content = dependencies.content; + this._config = { + container: null, + difficulty: 'medium', + maxSentences: 20, + ...config + }; + + this._score = 0; + this._errors = 0; + this._currentSentenceIndex = 0; + this._isRunning = false; + this._vocabulary = []; + this._sentences = []; + this._currentSentence = null; + this._blanks = []; + this._userAnswers = []; + this._gameContainer = null; + + Object.seal(this); + } + + static getMetadata() { + return { + id: 'fill-the-blank', + name: 'Fill the Blank', + description: 'Complete sentences by filling in missing words', + version: '2.0.0', + author: 'Class Generator', + category: 'vocabulary', + tags: ['vocabulary', 'sentences', 'completion', 'learning'], + difficulty: { + min: 1, + max: 4, + default: 2 + }, + estimatedDuration: 10, + requiredContent: ['vocabulary', 'sentences'] + }; + } + + static getCompatibilityScore(content) { + if (!content) { + return 0; + } + + let score = 0; + + const hasVocabulary = content.vocabulary && ( + typeof content.vocabulary === 'object' || + Array.isArray(content.vocabulary) + ); + const hasSentences = content.sentences || + content.story?.chapters || + content.fillInBlanks; + + if (hasVocabulary) score += 40; + if (hasSentences) score += 40; + + if (content.vocabulary && typeof content.vocabulary === 'object') { + const vocabCount = Object.keys(content.vocabulary).length; + if (vocabCount >= 10) score += 10; + if (vocabCount >= 20) score += 5; + } + + if (content.sentences && Array.isArray(content.sentences)) { + const sentenceCount = content.sentences.length; + if (sentenceCount >= 5) score += 5; + if (sentenceCount >= 10) score += 5; + } + + return Math.min(score, 100); + } + + async init() { + this._validateNotDestroyed(); + + // Validate container + if (!this._config.container) { + throw new Error('Game container is required'); + } + + this._eventBus.on('game:start', this._handleGameStart.bind(this), this.name); + this._eventBus.on('game:stop', this._handleGameStop.bind(this), this.name); + this._eventBus.on('navigation:change', this._handleNavigationChange.bind(this), this.name); + + this._injectCSS(); + + // Start game immediately + try { + this._gameContainer = this._config.container; + const content = this._content; + + if (!content) { + throw new Error('No content available'); + } + + this._extractVocabulary(content); + this._extractSentences(content); + + if (this._vocabulary.length === 0) { + throw new Error('No vocabulary found for Fill the Blank'); + } + + if (this._sentences.length === 0) { + throw new Error('No sentences found for Fill the Blank'); + } + + this._createGameBoard(); + this._setupEventListeners(); + this._loadNextSentence(); + + // Emit game ready event + this._eventBus.emit('game:ready', { + gameId: 'fill-the-blank', + instanceId: this.name, + vocabulary: this._vocabulary.length, + sentences: this._sentences.length + }, this.name); + + } catch (error) { + console.error('Error starting Fill the Blank:', error); + this._showInitError(error.message); + } + + this._setInitialized(); + } + + async destroy() { + this._validateNotDestroyed(); + + this._cleanup(); + this._removeCSS(); + this._eventBus.off('game:start', this.name); + this._eventBus.off('game:stop', this.name); + this._eventBus.off('navigation:change', this.name); + + this._setDestroyed(); + } + + _handleGameStart(event) { + this._validateInitialized(); + if (event.gameId === 'fill-the-blank') { + this._startGame(); + } + } + + _handleGameStop(event) { + this._validateInitialized(); + if (event.gameId === 'fill-the-blank') { + this._stopGame(); + } + } + + _handleNavigationChange(event) { + this._validateInitialized(); + if (event.from === '/games/fill-the-blank') { + this._cleanup(); + } + } + + async _startGame() { + try { + this._gameContainer = document.getElementById('game-content'); + if (!this._gameContainer) { + throw new Error('Game container not found'); + } + + const content = await this._content.getCurrentContent(); + if (!content) { + throw new Error('No content available'); + } + + this._extractVocabulary(content); + this._extractSentences(content); + + if (this._vocabulary.length === 0) { + throw new Error('No vocabulary found for Fill the Blank'); + } + + if (this._sentences.length === 0) { + throw new Error('No sentences found for Fill the Blank'); + } + + this._createGameBoard(); + this._setupEventListeners(); + this._loadNextSentence(); + + } catch (error) { + console.error('Error starting Fill the Blank:', error); + this._showInitError(error.message); + } + } + + _stopGame() { + this._cleanup(); + } + + _cleanup() { + this._isRunning = false; + if (this._gameContainer) { + this._gameContainer.innerHTML = ''; + } + } + + _showInitError(message) { + this._gameContainer.innerHTML = ` +
+

❌ Loading Error

+

${message}

+

The game requires vocabulary and sentences in compatible format.

+ +
+ `; + } + + _extractVocabulary(content) { + this._vocabulary = []; + + if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) { + this._vocabulary = Object.entries(content.vocabulary).map(([word, data]) => { + if (typeof data === 'object' && data.translation) { + return { + original: word, + translation: data.translation.split(';')[0], + fullTranslation: data.translation, + type: data.type || 'general', + audio: data.audio, + image: data.image, + examples: data.examples, + pronunciation: data.pronunciation, + category: data.type || 'general' + }; + } else if (typeof data === 'string') { + return { + original: word, + translation: data.split(';')[0], + fullTranslation: data, + type: 'general', + category: 'general' + }; + } + return null; + }).filter(Boolean); + } + + this._vocabulary = this._vocabulary.filter(word => + word && + typeof word.original === 'string' && + typeof word.translation === 'string' && + word.original.trim() !== '' && + word.translation.trim() !== '' + ); + + if (this._vocabulary.length === 0) { + this._vocabulary = [ + { original: 'hello', translation: 'bonjour', category: 'greetings' }, + { original: 'goodbye', translation: 'au revoir', category: 'greetings' }, + { original: 'thank you', translation: 'merci', category: 'greetings' }, + { original: 'cat', translation: 'chat', category: 'animals' }, + { original: 'dog', translation: 'chien', category: 'animals' }, + { original: 'house', translation: 'maison', category: 'objects' }, + { original: 'school', translation: 'école', category: 'places' }, + { original: 'book', translation: 'livre', category: 'objects' } + ]; + } + + console.log(`Fill the Blank: ${this._vocabulary.length} words loaded`); + } + + _extractSentences(content) { + this._sentences = []; + + if (content.story?.chapters) { + content.story.chapters.forEach(chapter => { + if (chapter.sentences) { + chapter.sentences.forEach(sentence => { + if (sentence.original && sentence.translation) { + this._sentences.push({ + original: sentence.original, + translation: sentence.translation, + source: 'story' + }); + } + }); + } + }); + } + + const directSentences = content.sentences; + if (directSentences && Array.isArray(directSentences)) { + directSentences.forEach(sentence => { + if (sentence.english && sentence.chinese) { + this._sentences.push({ + original: sentence.english, + translation: sentence.chinese, + source: 'sentences' + }); + } else if (sentence.original && sentence.translation) { + this._sentences.push({ + original: sentence.original, + translation: sentence.translation, + source: 'sentences' + }); + } + }); + } + + this._sentences = this._sentences.filter(sentence => + sentence.original && + sentence.original.split(' ').length >= 3 && + sentence.original.trim().length > 0 + ); + + this._sentences = this._shuffleArray(this._sentences); + + if (this._sentences.length === 0) { + this._sentences = this._createFallbackSentences(); + } + + this._sentences = this._sentences.slice(0, this._config.maxSentences); + console.log(`Fill the Blank: ${this._sentences.length} sentences loaded`); + } + + _createFallbackSentences() { + const fallback = []; + this._vocabulary.slice(0, 10).forEach(vocab => { + fallback.push({ + original: `This is a ${vocab.original}.`, + translation: `这是一个 ${vocab.translation}。`, + source: 'fallback' + }); + }); + return fallback; + } + + _createGameBoard() { + this._gameContainer.innerHTML = ` +
+
+
+
+ ${this._currentSentenceIndex + 1} + / ${this._sentences.length} +
+
+ ${this._errors} + Errors +
+
+ ${this._score} + Score +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + +
+ + +
+ `; + } + + _setupEventListeners() { + document.getElementById('check-btn').addEventListener('click', () => this._checkAnswer()); + document.getElementById('hint-btn').addEventListener('click', () => this._showHint()); + document.getElementById('skip-btn').addEventListener('click', () => this._skipSentence()); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && this._isRunning) { + this._checkAnswer(); + } + }); + } + + _loadNextSentence() { + if (this._currentSentenceIndex >= this._sentences.length) { + this._currentSentenceIndex = 0; + this._sentences = this._shuffleArray(this._sentences); + this._showFeedback(`🎉 All sentences completed! Starting over with a new order.`, 'success'); + setTimeout(() => { + this._loadNextSentence(); + }, 1500); + return; + } + + this._isRunning = true; + this._currentSentence = this._sentences[this._currentSentenceIndex]; + this._createBlanks(); + this._displaySentence(); + this._updateUI(); + } + + _createBlanks() { + const words = this._currentSentence.original.split(' '); + this._blanks = []; + + const numBlanks = Math.random() < 0.5 ? 1 : 2; + const blankIndices = new Set(); + + const vocabularyWords = []; + const otherWords = []; + + words.forEach((word, index) => { + const cleanWord = word.replace(/[.,!?;:"'()[\]{}\-–—]/g, '').toLowerCase(); + const isVocabularyWord = this._vocabulary.some(vocab => + vocab.original.toLowerCase() === cleanWord + ); + + if (isVocabularyWord) { + vocabularyWords.push({ word, index, priority: 'vocabulary' }); + } else { + otherWords.push({ word, index, priority: 'other', length: cleanWord.length }); + } + }); + + const selectedWords = []; + + const shuffledVocab = this._shuffleArray(vocabularyWords); + for (let i = 0; i < Math.min(numBlanks, shuffledVocab.length); i++) { + selectedWords.push(shuffledVocab[i]); + } + + if (selectedWords.length < numBlanks) { + const sortedOthers = otherWords.sort((a, b) => b.length - a.length); + const needed = numBlanks - selectedWords.length; + for (let i = 0; i < Math.min(needed, sortedOthers.length); i++) { + selectedWords.push(sortedOthers[i]); + } + } + + selectedWords.forEach(item => blankIndices.add(item.index)); + + words.forEach((word, index) => { + if (blankIndices.has(index)) { + this._blanks.push({ + index: index, + word: word.replace(/[.,!?;:]$/, ''), + punctuation: word.match(/[.,!?;:]$/) ? word.match(/[.,!?;:]$/)[0] : '', + userAnswer: '' + }); + } + }); + } + + _displaySentence() { + const words = this._currentSentence.original.split(' '); + let sentenceHTML = ''; + let blankCounter = 0; + + words.forEach((word, index) => { + const blank = this._blanks.find(b => b.index === index); + if (blank) { + sentenceHTML += ` + + ${blank.punctuation} + `; + blankCounter++; + } else { + sentenceHTML += `${word} `; + } + }); + + document.getElementById('sentence-container').innerHTML = sentenceHTML; + + const translation = this._currentSentence.translation || ''; + document.getElementById('translation-hint').innerHTML = translation ? + `💭 ${translation}` : ''; + + const firstInput = document.getElementById('blank-0'); + if (firstInput) { + setTimeout(() => firstInput.focus(), 100); + } + } + + _checkAnswer() { + if (!this._isRunning) return; + + let allCorrect = true; + let correctCount = 0; + + this._blanks.forEach((blank, index) => { + const input = document.getElementById(`blank-${index}`); + const userAnswer = input.value.trim().toLowerCase(); + const correctAnswer = blank.word.toLowerCase(); + + blank.userAnswer = input.value.trim(); + + if (userAnswer === correctAnswer) { + input.classList.remove('incorrect'); + input.classList.add('correct'); + correctCount++; + } else { + input.classList.remove('correct'); + input.classList.add('incorrect'); + allCorrect = false; + } + }); + + if (allCorrect) { + this._score += 10 * this._blanks.length; + this._showFeedback(`🎉 Perfect! +${10 * this._blanks.length} points`, 'success'); + setTimeout(() => { + this._currentSentenceIndex++; + this._loadNextSentence(); + }, 1500); + } else { + this._errors++; + if (correctCount > 0) { + this._score += 5 * correctCount; + this._showFeedback(`✨ ${correctCount}/${this._blanks.length} correct! +${5 * correctCount} points. Try again.`, 'partial'); + } else { + this._showFeedback(`❌ Try again! (${this._errors} errors)`, 'error'); + } + } + + this._updateUI(); + + this._eventBus.emit('game:score-update', { + gameId: 'fill-the-blank', + score: this._score, + module: this.name + }); + } + + _showHint() { + this._blanks.forEach((blank, index) => { + const input = document.getElementById(`blank-${index}`); + if (!input.value.trim()) { + input.value = blank.word[0]; + input.focus(); + } + }); + + this._showFeedback('💡 First letter added!', 'info'); + } + + _skipSentence() { + this._blanks.forEach((blank, index) => { + const input = document.getElementById(`blank-${index}`); + input.value = blank.word; + input.classList.add('revealed'); + }); + + this._showFeedback('📖 Answers revealed! Next sentence...', 'info'); + setTimeout(() => { + this._currentSentenceIndex++; + this._loadNextSentence(); + }, 2000); + } + + _showFeedback(message, type = 'info') { + const feedbackArea = document.getElementById('feedback-area'); + feedbackArea.innerHTML = `
${message}
`; + } + + _updateUI() { + document.getElementById('current-question').textContent = this._currentSentenceIndex + 1; + document.getElementById('errors-count').textContent = this._errors; + document.getElementById('score-display').textContent = 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; + } + + _injectCSS() { + const cssId = 'fill-the-blank-styles'; + if (document.getElementById(cssId)) return; + + const style = document.createElement('style'); + style.id = cssId; + style.textContent = ` + .fill-blank-wrapper { + max-width: 900px; + margin: 0 auto; + padding: 20px; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + color: white; + } + + .game-info { + background: rgba(255, 255, 255, 0.1); + border-radius: 15px; + padding: 20px; + margin-bottom: 25px; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + } + + .game-stats { + display: flex; + justify-content: space-around; + align-items: center; + } + + .stat-item { + text-align: center; + } + + .stat-value { + font-size: 2em; + font-weight: bold; + display: block; + color: #fff; + } + + .stat-label { + font-size: 0.9em; + opacity: 0.8; + color: #e0e0e0; + } + + .translation-hint { + background: rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 15px; + margin-bottom: 20px; + text-align: center; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + font-size: 1.1em; + color: #f0f0f0; + } + + .sentence-container { + background: rgba(255, 255, 255, 0.95); + border-radius: 15px; + padding: 30px; + margin-bottom: 25px; + font-size: 1.4em; + line-height: 1.8; + text-align: center; + color: #2c3e50; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + border: 1px solid rgba(255, 255, 255, 0.3); + } + + .word { + display: inline; + color: #2c3e50; + font-weight: 500; + } + + .blank-wrapper { + display: inline; + position: relative; + } + + .blank-input { + background: white; + border: 2px solid #ddd; + border-radius: 8px; + padding: 8px 12px; + font-size: 1em; + text-align: center; + min-width: 80px; + max-width: 150px; + transition: all 0.3s ease; + color: #2c3e50; + font-weight: bold; + } + + .blank-input:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 10px rgba(102, 126, 234, 0.3); + transform: scale(1.05); + } + + .blank-input.correct { + border-color: #27ae60; + background: linear-gradient(135deg, #d5f4e6, #a3e6c7); + color: #1e8e3e; + } + + .blank-input.incorrect { + border-color: #e74c3c; + background: linear-gradient(135deg, #ffeaea, #ffcdcd); + color: #c0392b; + animation: shake 0.5s ease-in-out; + } + + .blank-input.revealed { + border-color: #f39c12; + background: linear-gradient(135deg, #fff3cd, #ffeaa7); + color: #d35400; + } + + @keyframes shake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-5px); } + 75% { transform: translateX(5px); } + } + + .game-controls { + display: flex; + justify-content: center; + gap: 15px; + margin: 25px 0; + } + + .control-btn { + padding: 12px 25px; + border: none; + border-radius: 25px; + font-size: 1.1em; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; + min-width: 120px; + } + + .control-btn.primary { + background: linear-gradient(135deg, #27ae60, #2ecc71); + color: white; + box-shadow: 0 4px 15px rgba(39, 174, 96, 0.3); + } + + .control-btn.primary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(39, 174, 96, 0.4); + } + + .control-btn.secondary { + background: rgba(255, 255, 255, 0.9); + color: #667eea; + border: 2px solid rgba(255, 255, 255, 0.5); + } + + .control-btn.secondary:hover { + background: white; + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(255, 255, 255, 0.3); + } + + .feedback-area { + background: rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 20px; + text-align: center; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + min-height: 60px; + display: flex; + align-items: center; + justify-content: center; + } + + .instruction { + font-size: 1.1em; + font-weight: 500; + color: #f0f0f0; + } + + .instruction.success { + color: #2ecc71; + font-weight: bold; + animation: pulse 0.6s ease-in-out; + } + + .instruction.error { + color: #e74c3c; + font-weight: bold; + animation: pulse 0.6s ease-in-out; + } + + .instruction.partial { + color: #f39c12; + font-weight: bold; + } + + .instruction.info { + color: #3498db; + font-weight: bold; + } + + @keyframes pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } + } + + .game-error { + background: rgba(231, 76, 60, 0.1); + border: 2px solid #e74c3c; + border-radius: 15px; + padding: 30px; + text-align: center; + color: white; + backdrop-filter: blur(10px); + } + + .game-error h3 { + color: #e74c3c; + margin-bottom: 15px; + } + + .back-btn { + background: linear-gradient(135deg, #95a5a6, #7f8c8d); + color: white; + border: none; + padding: 12px 25px; + border-radius: 25px; + font-size: 1.1em; + font-weight: bold; + cursor: pointer; + margin-top: 20px; + transition: all 0.3s ease; + } + + .back-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(149, 165, 166, 0.4); + } + + @media (max-width: 768px) { + .fill-blank-wrapper { + padding: 15px; + } + + .game-stats { + flex-direction: column; + gap: 15px; + } + + .sentence-container { + font-size: 1.2em; + padding: 20px; + } + + .blank-input { + min-width: 60px; + font-size: 0.9em; + } + + .game-controls { + flex-direction: column; + align-items: center; + } + + .control-btn { + width: 100%; + max-width: 200px; + } + } + `; + + document.head.appendChild(style); + } + + _removeCSS() { + const cssElement = document.getElementById('fill-the-blank-styles'); + if (cssElement) { + cssElement.remove(); + } + } +} + +export default FillTheBlank; \ No newline at end of file diff --git a/src/games/FlashcardLearning.js b/src/games/FlashcardLearning.js new file mode 100644 index 0000000..d885ca8 --- /dev/null +++ b/src/games/FlashcardLearning.js @@ -0,0 +1,1978 @@ +/** + * FlashcardLearning - Advanced flashcard learning game with spaced repetition + * Features: Multiple modes, progress tracking, statistics, achievements + */ + +import Module from '../core/Module.js'; + +class FlashcardLearning extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); + + // Validate dependencies + if (!dependencies.eventBus) { + throw new Error('FlashcardLearning requires EventBus dependency'); + } + + this._eventBus = dependencies.eventBus; + this._content = dependencies.content || null; + this._config = { + container: null, + sessionLength: config.sessionLength || 20, + mode: config.mode || 'mixed', + difficulty: config.difficulty || 'all', + ...config + }; + + // Game state + this._container = null; + this._isActive = false; + this._currentCard = null; + this._currentCardIndex = 0; + this._sessionCards = []; + this._sessionStats = { + total: 0, + correct: 0, + incorrect: 0, + startTime: null, + endTime: null + }; + + // Learning modes + this._modes = { + recognition: 'Show English → Guess Translation', + production: 'Show Translation → Guess English', + mixed: 'Random Mix of Both', + pronunciation: 'Audio Practice Mode' + }; + + // Current study mode + this._currentMode = this._config.mode; + this._showingFront = true; + this._isRevealed = false; + + // Progress tracking system + this._progressKey = 'flashcard_progress'; + this._statsKey = 'flashcard_stats'; + this._achievementsKey = 'flashcard_achievements'; + + // Load saved progress + this._progress = this._loadProgress(); + this._globalStats = this._loadGlobalStats(); + this._achievements = this._loadAchievements(); + + // Card data processing + this._flashcards = []; + + Object.seal(this); + } + + static getMetadata() { + return { + name: 'Flashcard Learning', + description: 'Advanced flashcard learning with spaced repetition, multiple modes, and progress tracking', + difficulty: 'adaptive', + category: 'vocabulary', + minPlayers: 1, + maxPlayers: 1, + estimatedDuration: '10-30 minutes', + features: [ + 'Spaced Repetition Algorithm', + 'Multiple Learning Modes', + 'Progress Tracking', + 'Statistics Dashboard', + 'Achievement System', + 'Audio Pronunciation', + 'Customizable Sessions' + ] + }; + } + + static getCompatibilityScore(content) { + if (!content) { + return { score: 0, reason: 'No content available' }; + } + + let wordCount = 0; + let sentenceCount = 0; + + // Count vocabulary words + if (content.vocabulary && typeof content.vocabulary === 'object') { + wordCount = Object.keys(content.vocabulary).length; + } + + // Count sentences + if (content.sentences && Array.isArray(content.sentences)) { + sentenceCount = content.sentences.length; + } + + // Check story content for extractable vocabulary + if (content.story && typeof content.story === 'string') { + const words = content.story.match(/\b[a-zA-Z]+\b/g) || []; + wordCount += Math.min(words.length / 10, 20); // Estimate extractable vocab + } + + // Compatibility scoring + const totalContent = wordCount + sentenceCount * 2; + + if (totalContent >= 15) { + return { + score: 0.95, + reason: `Excellent content: ${wordCount} words, ${sentenceCount} sentences`, + requirements: ['vocabulary', 'sentences'], + wordCount, + sentenceCount + }; + } else if (totalContent >= 8) { + return { + score: 0.75, + reason: `Good content: ${wordCount} words, ${sentenceCount} sentences`, + requirements: ['vocabulary'], + wordCount, + sentenceCount + }; + } else if (totalContent >= 3) { + return { + score: 0.50, + reason: `Minimal content: ${wordCount} words, ${sentenceCount} sentences`, + requirements: ['basic vocabulary'], + wordCount, + sentenceCount + }; + } + + return { + score: 0.1, + reason: 'Insufficient vocabulary for flashcard learning', + wordCount, + sentenceCount + }; + } + + async init() { + this._validateNotDestroyed(); + + // Validate container + if (!this._config.container) { + throw new Error('Game container is required'); + } + + console.log('🎯 Initializing FlashcardLearning game...'); + + // Set up event listeners + this._eventBus.on('game:stop', this._handleStop.bind(this), this.name); + this._eventBus.on('flashcard:next', this._handleNext.bind(this), this.name); + this._eventBus.on('flashcard:reveal', this._handleReveal.bind(this), this.name); + this._eventBus.on('flashcard:rate', this._handleRate.bind(this), this.name); + this._eventBus.on('flashcard:mode-change', this._handleModeChange.bind(this), this.name); + + // Start game immediately + try { + this._isActive = true; // Set active before starting + this._container = this._config.container; + const content = window.contentLoader ? window.contentLoader.getContent(window.currentChapterId) : this._content; + + if (!content || (!content.vocabulary && !content.sentences)) { + throw new Error('No suitable content available for flashcards'); + } + + // Process content into flashcards + this._processContent(content); + + // Create game interface + this._createGameUI(); + + // Start first session + this._startSession(); + + // Emit game ready event + this._eventBus.emit('game:ready', { + gameId: 'flashcard-learning', + instanceId: this.name, + cards: this._flashcards.length + }, this.name); + + } catch (error) { + console.error('Error starting FlashcardLearning:', error); + this._showError(error.message); + } + + this._setInitialized(); + console.log('✅ FlashcardLearning initialized successfully'); + } + + _showError(message) { + if (this._container) { + this._container.innerHTML = ` +
+
🎴
+

Flashcard Learning Error

+

${message}

+ +
+ `; + } + } + + async destroy() { + this._validateNotDestroyed(); + + // Save progress before destroying + this._saveProgress(); + this._saveGlobalStats(); + this._saveAchievements(); + + // Clean up UI + if (this._container) { + this._container.innerHTML = ''; + } + + this._isActive = false; + this._setDestroyed(); + } + + // Game Control Methods + async start() { + this._validateInitialized(); + + if (this._isActive) { + console.warn('FlashcardLearning is already active'); + return; + } + + console.log('🚀 Starting FlashcardLearning game...'); + + // Get container + this._container = document.getElementById(this._config.container); + if (!this._container) { + throw new Error(`Container element '${this._config.container}' not found`); + } + + // Check if we have flashcards + if (this._flashcards.length === 0) { + this._showNoContentMessage(); + return; + } + + this._isActive = true; + this._setupSession(); + this._createGameUI(); + this._startSession(); + + // Emit start event + this._eventBus.emit('game:flashcard-started', { + mode: this._currentMode, + cardsCount: this._flashcards.length, + sessionLength: this._config.sessionLength + }, this.name); + } + + async stop() { + if (!this._isActive) return; + + console.log('⏹️ Stopping FlashcardLearning game...'); + + // Save current session progress + this._endSession(); + this._saveProgress(); + this._saveGlobalStats(); + + // Clean up UI + if (this._container) { + this._container.innerHTML = ''; + } + + this._isActive = false; + + // Emit stop event + this._eventBus.emit('game:flashcard-stopped', { + sessionStats: this._sessionStats + }, this.name); + } + + // Content Processing + _processContent() { + console.log('🔄 Processing content into flashcards...'); + + this._flashcards = []; + + if (!this._content) { + console.warn('No content available for flashcard generation'); + return; + } + + // Process vocabulary + if (this._content.vocabulary && typeof this._content.vocabulary === 'object') { + for (const [english, data] of Object.entries(this._content.vocabulary)) { + if (english && data) { + let translation; + if (typeof data === 'string') { + translation = data; + } else if (data.user_language) { + translation = data.user_language.split(';')[0]; // Take first translation + } else if (data.translation) { + translation = data.translation; + } else { + continue; // Skip if no translation found + } + + this._flashcards.push({ + id: `vocab_${english.toLowerCase().replace(/\s+/g, '_')}`, + type: 'vocabulary', + front: english, + back: translation, + category: 'vocabulary', + difficulty: this._estimateDifficulty(english), + wordType: data.type || 'unknown', + pronunciation: data.pronunciation || null + }); + } + } + } + + // Process sentences + if (this._content.sentences && Array.isArray(this._content.sentences)) { + this._content.sentences.forEach((sentence, index) => { + if (sentence.english && sentence.translation) { + this._flashcards.push({ + id: `sentence_${index}`, + type: 'sentence', + front: sentence.english, + back: sentence.translation, + category: 'sentences', + difficulty: this._estimateDifficulty(sentence.english) + }); + } + }); + } + + console.log(`✅ Generated ${this._flashcards.length} flashcards`); + + // Initialize progress for new cards + this._initializeCardProgress(); + } + + _estimateDifficulty(text) { + const wordCount = text.split(/\s+/).length; + const charCount = text.length; + const complexity = text.match(/[^\w\s]/g)?.length || 0; + + if (wordCount <= 2 && charCount <= 10) return 'beginner'; + if (wordCount <= 5 && charCount <= 25) return 'intermediate'; + return 'advanced'; + } + + _initializeCardProgress() { + let updated = false; + + for (const card of this._flashcards) { + if (!this._progress[card.id]) { + this._progress[card.id] = { + cardId: card.id, + word: card.front, + translation: card.back, + timesStudied: 0, + timesCorrect: 0, + lastStudied: null, + nextReview: Date.now(), + difficultyLevel: 1, + masteryLevel: 'new', + averageResponseTime: 0, + confidenceHistory: [] + }; + updated = true; + } + } + + if (updated) { + this._saveProgress(); + } + } + + // Session Management + _setupSession() { + console.log('📚 Setting up study session...'); + + // Get cards due for review using spaced repetition + const dueCards = this._getDueCards(); + const newCards = this._getNewCards(); + + // Mix due cards with new cards (70% due, 30% new) + const maxDue = Math.min(dueCards.length, Math.floor(this._config.sessionLength * 0.7)); + const maxNew = Math.min(newCards.length, this._config.sessionLength - maxDue); + + this._sessionCards = [ + ...dueCards.slice(0, maxDue), + ...newCards.slice(0, maxNew) + ]; + + // If no cards available, use all cards for practice + if (this._sessionCards.length === 0) { + console.log('📚 No due cards found, using all flashcards for practice'); + this._sessionCards = [...this._flashcards].slice(0, this._config.sessionLength); + } + + // Shuffle cards + this._shuffleArray(this._sessionCards); + + // Limit to session length + this._sessionCards = this._sessionCards.slice(0, this._config.sessionLength); + + console.log(`📋 Session setup: ${this._sessionCards.length} cards (${maxDue} due, ${maxNew} new)`); + + // Reset session stats + this._sessionStats = { + total: this._sessionCards.length, + correct: 0, + incorrect: 0, + startTime: Date.now(), + endTime: null, + cardsStudied: 0 + }; + } + + _getDueCards() { + const now = Date.now(); + return this._flashcards.filter(card => { + const progress = this._progress[card.id]; + return progress && progress.nextReview <= now && progress.masteryLevel !== 'mastered'; + }).sort((a, b) => { + const aProgress = this._progress[a.id]; + const bProgress = this._progress[b.id]; + return aProgress.nextReview - bProgress.nextReview; + }); + } + + _getNewCards() { + return this._flashcards.filter(card => { + const progress = this._progress[card.id]; + // If no progress exists, treat as new card + return !progress || progress.masteryLevel === 'new'; + }).sort(() => Math.random() - 0.5); + } + + _startSession() { + if (this._sessionCards.length === 0) { + // Fallback: If still no cards after setup, force use all flashcards + if (this._flashcards.length > 0) { + console.log('🚨 Forcing session with all available flashcards'); + this._sessionCards = [...this._flashcards].slice(0, this._config.sessionLength); + this._shuffleArray(this._sessionCards); + } else { + this._showNoCardsMessage(); + return; + } + } + + this._currentCardIndex = 0; + this._loadCard(0); + } + + _endSession() { + this._sessionStats.endTime = Date.now(); + + // Update global stats + this._updateGlobalStats(); + + // Check for achievements + this._checkAchievements(); + + console.log('📊 Session completed:', this._sessionStats); + } + + // Card Management + _loadCard(index) { + if (index >= this._sessionCards.length) { + this._showSessionComplete(); + return; + } + + this._currentCardIndex = index; + this._currentCard = this._sessionCards[index]; + this._showingFront = true; + this._isRevealed = false; + + // Determine which side to show based on mode + const shouldShowEnglishFirst = this._shouldShowEnglishFirst(); + + if (shouldShowEnglishFirst) { + this._currentCard.displayFront = this._currentCard.back; // English first + this._currentCard.displayBack = this._currentCard.front; // Translation revealed + } else { + this._currentCard.displayFront = this._currentCard.front; // Translation first + this._currentCard.displayBack = this._currentCard.back; // English revealed + } + + this._updateCardDisplay(); + this._updateProgressIndicator(); + } + + _shouldShowEnglishFirst() { + switch (this._currentMode) { + case 'recognition': + return true; // English → Translation + case 'production': + return false; // Translation → English + case 'mixed': + return Math.random() < 0.5; // Random + case 'pronunciation': + return true; // Show English, focus on pronunciation + default: + return true; + } + } + + _nextCard() { + console.log(`🎯 _nextCard: current index ${this._currentCardIndex}, moving to ${this._currentCardIndex + 1}`); + if (this._currentCardIndex < this._sessionCards.length - 1) { + this._loadCard(this._currentCardIndex + 1); + } else { + this._showSessionComplete(); + } + } + + // UI Creation and Management + _createGameUI() { + const gameHTML = ` +
+
+
+ + +
+
+ Card 0 of 0 +
+
+
+
+ +
+ +
+
+
+
+
+
Click to reveal
+
+
+
+
+
+
+
+ + + + +
+
+
+
+
+ +
+ +
+ +
+
+ Correct: + 0 +
+
+ Incorrect: + 0 +
+
+ Accuracy: + 0% +
+
+ + +
+ `; + + this._container.innerHTML = gameHTML; + this._injectCSS(); + this._setupUIEvents(); + this._updateStatisticsPanel(); + } + + _injectCSS() { + if (document.getElementById('flashcard-styles')) return; + + const style = document.createElement('style'); + style.id = 'flashcard-styles'; + style.textContent = ` + .flashcard-game { + max-width: 800px; + margin: 0 auto; + padding: 20px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + } + + .game-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + flex-wrap: wrap; + gap: 15px; + } + + .mode-selector label { + margin-right: 10px; + font-weight: 600; + } + + .mode-selector select { + padding: 8px 12px; + border: 2px solid #e0e0e0; + border-radius: 8px; + background: white; + font-size: 14px; + } + + .session-info { + text-align: center; + flex: 1; + min-width: 200px; + } + + #progress-indicator { + display: block; + font-weight: 600; + margin-bottom: 8px; + color: #555; + } + + .progress-bar { + width: 100%; + height: 8px; + background: #e0e0e0; + border-radius: 4px; + overflow: hidden; + } + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, #4CAF50, #45a049); + width: 0%; + transition: width 0.3s ease; + } + + .flashcard-container { + perspective: 1000px; + margin: 30px 0; + display: flex; + justify-content: center; + } + + .flashcard { + width: 400px; + height: 300px; + position: relative; + transform-style: preserve-3d; + transition: transform 0.6s; + cursor: pointer; + margin: 0 auto; + } + + .flashcard.flipped { + transform: rotateY(180deg); + box-shadow: + 0 8px 32px rgba(25, 74, 156, 0.4), + 0 4px 16px rgba(25, 74, 156, 0.3), + 0 2px 8px rgba(0, 0, 0, 0.1); + filter: drop-shadow(0 4px 20px rgba(25, 74, 156, 0.25)); + } + + .card-front, .card-back { + position: absolute; + width: 100%; + height: 100%; + backface-visibility: hidden; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + box-sizing: border-box; + } + + .card-front { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + } + + .card-back { + background: linear-gradient(135deg, #1a4a9c 0%, #0d2b5f 100%); + color: white; + transform: rotateY(180deg) scaleX(-1); + border: 2px solid rgba(255, 255, 255, 0.1); + } + + .card-content { + text-align: center; + width: 100%; + } + + .card-text { + font-size: 24px; + font-weight: 600; + line-height: 1.4; + margin-bottom: 15px; + min-height: 60px; + display: flex; + align-items: center; + justify-content: center; + } + + .card-hint { + font-size: 14px; + opacity: 0.8; + font-style: italic; + } + + .pronunciation-text { + font-size: 16px; + font-style: italic; + opacity: 0.8; + margin-top: 10px; + margin-bottom: 15px; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.1); + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.2); + } + + .confidence-buttons { + display: flex; + gap: 10px; + justify-content: center; + flex-wrap: wrap; + } + + .confidence-btn { + padding: 8px 16px; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + font-size: 12px; + transition: all 0.3s ease; + text-transform: uppercase; + } + + .confidence-btn.again { background: #ff4757; color: white; } + .confidence-btn.hard { background: #ff6b7a; color: white; } + .confidence-btn.good { background: #26d0ce; color: white; } + .confidence-btn.easy { background: #1dd1a1; color: white; } + + .confidence-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + } + + .game-controls { + display: flex; + gap: 15px; + justify-content: center; + margin: 20px 0; + } + + .btn-primary, .btn-secondary { + padding: 12px 24px; + border: none; + border-radius: 8px; + cursor: pointer; + font-weight: 600; + font-size: 14px; + transition: all 0.3s ease; + } + + .btn-primary { + background: #4CAF50; + color: white; + } + + .btn-secondary { + background: #e0e0e0; + color: #555; + } + + .btn-primary:hover, .btn-secondary:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + } + + .session-stats { + display: flex; + gap: 30px; + justify-content: center; + margin: 20px 0; + padding: 20px; + background: #f8f9fa; + border-radius: 12px; + } + + .stat { + text-align: center; + } + + .stat-label { + display: block; + font-size: 12px; + color: #666; + text-transform: uppercase; + font-weight: 600; + margin-bottom: 5px; + } + + .stat-value { + display: block; + font-size: 18px; + font-weight: 700; + color: #333; + } + + .statistics-panel { + background: white; + border-radius: 12px; + padding: 20px; + margin-top: 20px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + + .stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 20px; + margin: 20px 0; + } + + .stat-item { + text-align: center; + padding: 15px; + background: #f8f9fa; + border-radius: 8px; + } + + .stat-number { + display: block; + font-size: 24px; + font-weight: 700; + color: #4CAF50; + margin-bottom: 5px; + } + + .achievements-list { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; + } + + .achievement { + padding: 6px 12px; + background: #4CAF50; + color: white; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + } + + .achievement.locked { + background: #ccc; + color: #666; + } + + @media (max-width: 600px) { + .flashcard { + width: 320px; + height: 240px; + } + + .card-text { + font-size: 20px; + } + + .game-header { + flex-direction: column; + align-items: stretch; + } + + .session-stats { + flex-direction: column; + gap: 15px; + } + + .confidence-buttons { + gap: 5px; + } + + .confidence-btn { + padding: 6px 12px; + font-size: 11px; + } + } + `; + + document.head.appendChild(style); + } + + _setupUIEvents() { + // Mode selector + const modeSelect = document.getElementById('mode-select'); + if (modeSelect) { + modeSelect.addEventListener('change', (e) => { + this._handleModeChange({ data: { mode: e.target.value } }); + }); + } + + // Settings button + const settingsBtn = document.getElementById('settings-btn'); + if (settingsBtn) { + settingsBtn.addEventListener('click', () => { + this._toggleStatisticsPanel(); + }); + } + + // Previous button removed - replaced by Don't know button after reveal + + // Reveal button + const revealBtn = document.getElementById('reveal-btn'); + if (revealBtn) { + revealBtn.addEventListener('click', () => { + this._handleReveal(); + }); + } + + // Flashcard click + const flashcard = document.getElementById('flashcard'); + if (flashcard) { + flashcard.addEventListener('click', () => { + this._handleReveal(); + }); + } + + // Next button removed - cards advance automatically after rating + + // Confidence rating buttons + const confidenceBtns = document.querySelectorAll('.confidence-btn'); + confidenceBtns.forEach(btn => { + btn.addEventListener('click', (e) => { + const rating = e.target.getAttribute('data-rating'); + if (rating) { + this._handleRate(rating); + } + }); + }); + + // Event delegation for dynamic buttons + if (this._container) { + this._container.addEventListener('click', (e) => { + const target = e.target; + + // Handle dynamic buttons that appear in completion messages + if (target.id === 'session-complete-stop' || target.id === 'no-cards-stop') { + this.stop(); + } else if (target.id === 'start-new-session') { + this._startNewSession(); + } + }); + } + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (!this._isActive) return; + + switch(e.code) { + case 'Space': + e.preventDefault(); + this._handleReveal(); + break; + case 'ArrowLeft': + e.preventDefault(); + this._previousCard(); + break; + case 'ArrowRight': + e.preventDefault(); + this._handleNext(); + break; + case 'Digit1': + e.preventDefault(); + if (this._isRevealed) this._handleRate('again'); + break; + case 'Digit2': + e.preventDefault(); + if (this._isRevealed) this._handleRate('hard'); + break; + case 'Digit3': + e.preventDefault(); + if (this._isRevealed) this._handleRate('good'); + break; + case 'Digit4': + e.preventDefault(); + if (this._isRevealed) this._handleRate('easy'); + break; + } + }); + } + + _updateCardDisplay() { + if (!this._currentCard) return; + + const flashcard = document.getElementById('flashcard'); + if (!flashcard) return; + + // Add fade out animation + flashcard.style.opacity = '0'; + flashcard.style.transform = 'scale(0.9)'; + flashcard.style.transition = 'all 0.3s ease'; + + setTimeout(() => { + // Recreate the front side of the card + flashcard.innerHTML = ` +
+
+ ${this._currentCard.displayFront} +
+ +
+ `; + + // Only setup event listeners for flashcard elements, not all UI + const revealBtn = document.getElementById('reveal-btn'); + if (revealBtn) { + revealBtn.addEventListener('click', () => { + this._handleReveal(); + }); + } + + const flashcardElement = document.getElementById('flashcard'); + if (flashcardElement) { + flashcardElement.addEventListener('click', () => { + this._handleReveal(); + }); + } + + // Fade in animation + flashcard.style.opacity = '1'; + flashcard.style.transform = 'scale(1)'; + + // Hide difficulty buttons when showing front of new card + const gameControls = document.querySelector('.game-controls'); + if (gameControls) { + gameControls.innerHTML = ''; + } + + // Update button states + this._updateButtonStates(); + + // Play audio if in pronunciation mode + if (this._currentMode === 'pronunciation') { + this._playAudio(this._currentCard.displayFront); + } + }, 150); + } + + _updateButtonStates() { + // Button states are now managed by the reveal/rating system + // No need to manage prev/next buttons as they have been removed + } + + _updateProgressIndicator() { + console.log(`📊 Updating progress indicator: ${this._currentCardIndex + 1} of ${this._sessionCards.length}`); + + const indicator = document.getElementById('progress-indicator'); + const progressFill = document.getElementById('progress-fill'); + + if (indicator) { + indicator.textContent = `Card ${this._currentCardIndex + 1} of ${this._sessionCards.length}`; + } + + if (progressFill) { + const progress = ((this._currentCardIndex + 1) / this._sessionCards.length) * 100; + progressFill.style.width = `${progress}%`; + } + } + + _updateSessionStats() { + const correctCount = document.getElementById('correct-count'); + const incorrectCount = document.getElementById('incorrect-count'); + const accuracy = document.getElementById('accuracy'); + + if (correctCount) correctCount.textContent = this._sessionStats.correct; + if (incorrectCount) incorrectCount.textContent = this._sessionStats.incorrect; + + if (accuracy) { + const total = this._sessionStats.correct + this._sessionStats.incorrect; + const accuracyRate = total > 0 ? Math.round((this._sessionStats.correct / total) * 100) : 0; + accuracy.textContent = `${accuracyRate}%`; + } + } + + _updateStatisticsPanel() { + const totalCards = document.getElementById('total-cards-studied'); + const masteryRate = document.getElementById('mastery-rate'); + const studyStreak = document.getElementById('study-streak'); + const timeStudied = document.getElementById('time-studied'); + const achievementsList = document.getElementById('achievements-list'); + + if (totalCards) { + const total = Object.values(this._progress).reduce((sum, p) => sum + p.timesStudied, 0); + totalCards.textContent = total; + } + + if (masteryRate) { + const masteredCount = Object.values(this._progress).filter(p => p.masteryLevel === 'mastered').length; + const totalCount = this._flashcards.length; + const rate = totalCount > 0 ? Math.round((masteredCount / totalCount) * 100) : 0; + masteryRate.textContent = `${rate}%`; + } + + if (studyStreak) { + studyStreak.textContent = this._globalStats.currentStreak || 0; + } + + if (timeStudied) { + const totalMinutes = Math.round((this._globalStats.totalTimeStudied || 0) / (1000 * 60)); + timeStudied.textContent = `${totalMinutes}m`; + } + + if (achievementsList) { + achievementsList.innerHTML = this._generateAchievementsHTML(); + } + } + + _generateAchievementsHTML() { + const availableAchievements = [ + { id: 'first_study', name: 'First Steps', description: 'Complete your first study session' }, + { id: 'streak_3', name: '3-Day Streak', description: 'Study for 3 consecutive days' }, + { id: 'streak_7', name: 'Week Warrior', description: 'Study for 7 consecutive days' }, + { id: 'master_10', name: 'Mastery Beginner', description: 'Master 10 cards' }, + { id: 'master_50', name: 'Vocabulary Expert', description: 'Master 50 cards' }, + { id: 'speed_demon', name: 'Speed Demon', description: 'Answer 100 cards in a single session' }, + { id: 'perfectionist', name: 'Perfectionist', description: 'Get 100% accuracy in a 20+ card session' } + ]; + + return availableAchievements.map(achievement => { + const unlocked = this._achievements.includes(achievement.id); + return `
+ ${achievement.name} +
`; + }).join(''); + } + + _toggleStatisticsPanel() { + const panel = document.getElementById('statistics-panel'); + if (panel) { + const isVisible = panel.style.display !== 'none'; + panel.style.display = isVisible ? 'none' : 'block'; + if (!isVisible) { + this._updateStatisticsPanel(); + } + } + } + + // Event Handlers + _handleStart(event) { + this.start(); + } + + _handleStop(event) { + this.stop(); + } + + _handleNext(event) { + if (this._isRevealed) { + // If revealed, rate as "good" by default and move to next + this._handleRate('good'); + } else { + // If not revealed, just move to next card + this._nextCard(); + } + } + + _handleReveal(event) { + if (!this._isActive || !this._currentCard) return; + + const flashcard = document.getElementById('flashcard'); + + if (!this._isRevealed) { + // Add scale animation + flashcard.style.transform = 'scale(0.95)'; + flashcard.style.transition = 'all 0.3s ease'; + + setTimeout(() => { + // Replace with reveal content + flashcard.innerHTML = ` +
+
+ ${this._currentCard.displayFront} +
+
+ ${this._currentCard.displayBack} +
+
+ ${this._currentCard.pronunciation || ''} +
+
+ `; + + // Scale back up + flashcard.style.transform = 'scale(1)'; + + this._isRevealed = true; + + // Add difficulty buttons in game-controls section + const gameControls = document.querySelector('.game-controls'); + if (gameControls) { + // Calculate next review times for each confidence level + const againTime = this._formatTimeUntilReview(this._calculateNextReviewTime('again')); + const hardTime = this._formatTimeUntilReview(this._calculateNextReviewTime('hard')); + const goodTime = this._formatTimeUntilReview(this._calculateNextReviewTime('good')); + const easyTime = this._formatTimeUntilReview(this._calculateNextReviewTime('easy')); + + gameControls.innerHTML = ` +
+ + + + +
+ `; + + // Add event listeners for difficulty buttons + setTimeout(() => { + const dontKnowBtn = document.getElementById('dont-know-btn'); + const difficultBtn = document.getElementById('difficult-btn'); + const normalBtn = document.getElementById('normal-btn'); + const easyBtn = document.getElementById('easy-btn'); + + if (dontKnowBtn) { + dontKnowBtn.onclick = () => { + console.log('🔴 Clicked Don\'t know'); + this._handleRate('again'); + }; + } + if (difficultBtn) { + difficultBtn.onclick = () => { + console.log('🟠 Clicked Difficult'); + this._handleRate('hard'); + }; + } + if (normalBtn) { + normalBtn.onclick = () => { + console.log('🔵 Clicked Normal'); + this._handleRate('good'); + }; + } + if (easyBtn) { + easyBtn.onclick = () => { + console.log('🟢 Clicked Easy'); + this._handleRate('easy'); + }; + } + }, 50); + } + + // Always play audio for pronunciation, regardless of mode + setTimeout(() => { + this._playAudio(this._currentCard.front); + }, 200); + }, 150); + } + } + + _handleRate(confidence) { + console.log(`⭐ _handleRate called with: ${confidence}`); + if (!this._currentCard || !this._isRevealed) return; + + const cardProgress = this._progress[this._currentCard.id]; + + // Update card statistics + cardProgress.timesStudied++; + cardProgress.lastStudied = Date.now(); + cardProgress.confidenceHistory.push(confidence); + + // Keep only last 10 confidence ratings + if (cardProgress.confidenceHistory.length > 10) { + cardProgress.confidenceHistory = cardProgress.confidenceHistory.slice(-10); + } + + // Update session stats + this._sessionStats.cardsStudied++; + + if (confidence === 'easy' || confidence === 'good') { + cardProgress.timesCorrect++; + this._sessionStats.correct++; + } else { + this._sessionStats.incorrect++; + } + + // Apply spaced repetition algorithm + this._updateSpacedRepetition(cardProgress, confidence); + + // Update mastery level + this._updateMasteryLevel(cardProgress); + + // Save progress + this._saveProgress(); + + // Update UI + this._updateSessionStats(); + + // Move to next card + setTimeout(() => { + console.log(`➡️ Moving to next card from index ${this._currentCardIndex}`); + this._nextCard(); + }, 500); + } + + _handleModeChange(event) { + const newMode = event.data.mode; + if (this._modes[newMode]) { + this._currentMode = newMode; + console.log(`📚 Mode changed to: ${this._modes[newMode]}`); + + // Reload current card with new mode + if (this._currentCard) { + this._isRevealed = false; + this._loadCard(this._currentCardIndex); + } + } + } + + // Spaced Repetition Algorithm + _updateSpacedRepetition(cardProgress, confidence) { + const now = Date.now(); + const baseUnitMs = 20 * 60 * 1000; // 20 minutes as base unit + + let multiplier; + switch (confidence) { + case 'again': + multiplier = 0.1; // Review very soon (2 minutes) + cardProgress.difficultyLevel = Math.max(1, cardProgress.difficultyLevel - 0.5); + break; + case 'hard': + multiplier = 0.5; // Review sooner (10 minutes) + cardProgress.difficultyLevel = Math.max(1, cardProgress.difficultyLevel - 0.2); + break; + case 'good': + multiplier = 1.0; // Standard interval (20 minutes, 40m, 1h20, etc.) + cardProgress.difficultyLevel = Math.min(5, cardProgress.difficultyLevel + 0.1); + break; + case 'easy': + multiplier = 2.0; // Extended interval (40 minutes, 1h20, 2h40, etc.) + cardProgress.difficultyLevel = Math.min(5, cardProgress.difficultyLevel + 0.3); + break; + } + + // Calculate next review time based on difficulty level and confidence + const baseInterval = Math.pow(2, cardProgress.difficultyLevel - 1) * baseUnitMs; + const interval = baseInterval * multiplier; + + cardProgress.nextReview = now + Math.max(interval, 60000); // Minimum 1 minute + } + + _calculateNextReviewTime(confidence) { + if (!this._currentCard) return null; + + const cardProgress = this._progress[this._currentCard.id]; + const now = Date.now(); + const baseUnitMs = 20 * 60 * 1000; // 20 minutes as base unit + let multiplier; + + switch (confidence) { + case 'again': + multiplier = 0.1; // Review very soon (2 minutes) + break; + case 'hard': + multiplier = 0.5; // Review sooner (10 minutes) + break; + case 'good': + multiplier = 1.0; // Standard interval (20 minutes, 40m, 1h20, etc.) + break; + case 'easy': + multiplier = 2.0; // Extended interval (40 minutes, 1h20, 2h40, etc.) + break; + } + + const baseInterval = Math.pow(2, cardProgress.difficultyLevel - 1) * baseUnitMs; + const interval = baseInterval * multiplier; + const nextReview = now + Math.max(interval, 60000); // Minimum 1 minute + + return nextReview; + } + + _formatTimeUntilReview(timestamp) { + if (!timestamp) return ''; + + const now = Date.now(); + const diff = timestamp - now; + const minutes = Math.floor(diff / (60 * 1000)); + const hours = Math.floor(diff / (60 * 60 * 1000)); + const days = Math.floor(diff / (24 * 60 * 60 * 1000)); + + if (days > 0) { + return `${days}j`; + } else if (hours > 0) { + return `${hours}h`; + } else if (minutes > 0) { + return `${minutes}m`; + } else { + return '1m'; + } + } + + _updateMasteryLevel(cardProgress) { + const correctRate = cardProgress.timesStudied > 0 ? cardProgress.timesCorrect / cardProgress.timesStudied : 0; + const recentConfidence = cardProgress.confidenceHistory.slice(-3); + + if (cardProgress.timesStudied >= 8 && correctRate >= 0.8 && + recentConfidence.length >= 3 && recentConfidence.every(c => c === 'easy' || c === 'good')) { + cardProgress.masteryLevel = 'mastered'; + } else if (cardProgress.timesStudied >= 3 && correctRate >= 0.6) { + cardProgress.masteryLevel = 'review'; + } else if (cardProgress.timesStudied > 0) { + cardProgress.masteryLevel = 'learning'; + } else { + cardProgress.masteryLevel = 'new'; + } + } + + // Audio System + _playAudio(text) { + if ('speechSynthesis' in window) { + // Cancel any ongoing speech + speechSynthesis.cancel(); + + const utterance = new SpeechSynthesisUtterance(text); + utterance.rate = 0.8; + 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('Neural') + ) || voices.find(voice => voice.lang.startsWith('en')); + + if (englishVoice) { + utterance.voice = englishVoice; + } + + speechSynthesis.speak(utterance); + } + } + + _generatePronunciation(word) { + if (!word || typeof word !== 'string') return ''; + + // Simple phonetic approximation for common English patterns + let pronunciation = word.toLowerCase().trim(); + + // Basic pronunciation rules (simplified IPA-style) + const rules = [ + // Vowel combinations + ['ough', 'ʌf'], ['augh', 'ɔːf'], ['eigh', 'eɪ'], + ['tion', 'ʃən'], ['sion', 'ʒən'], ['cian', 'ʃən'], + + // Consonant combinations + ['ch', 'tʃ'], ['sh', 'ʃ'], ['th', 'θ'], ['ph', 'f'], + ['ck', 'k'], ['ng', 'ŋ'], ['qu', 'kw'], + + // Common vowel patterns + ['ee', 'iː'], ['ea', 'iː'], ['oo', 'uː'], ['ou', 'aʊ'], + ['ow', 'aʊ'], ['oi', 'ɔɪ'], ['oy', 'ɔɪ'], ['au', 'ɔː'], + ['aw', 'ɔː'], ['ai', 'eɪ'], ['ay', 'eɪ'], ['ie', 'aɪ'], + ['ue', 'uː'], ['ui', 'uː'], + + // Single vowels (simplified approximation) + ['a', 'æ'], ['e', 'ɛ'], ['i', 'ɪ'], ['o', 'ɒ'], ['u', 'ʌ'] + ]; + + // Apply pronunciation rules + rules.forEach(([pattern, replacement]) => { + pronunciation = pronunciation.replace(new RegExp(pattern, 'g'), replacement); + }); + + return pronunciation; + } + + // Utility Methods + _shuffleArray(array) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + } + + _previousCard() { + if (this._currentCardIndex > 0) { + this._loadCard(this._currentCardIndex - 1); + } + } + + // Progress & Statistics Management + _loadProgress() { + try { + const saved = localStorage.getItem(this._progressKey); + return saved ? JSON.parse(saved) : {}; + } catch (error) { + console.warn('Failed to load progress:', error); + return {}; + } + } + + _saveProgress() { + try { + localStorage.setItem(this._progressKey, JSON.stringify(this._progress)); + } catch (error) { + console.warn('Failed to save progress:', error); + } + } + + _loadGlobalStats() { + try { + const saved = localStorage.getItem(this._statsKey); + return saved ? JSON.parse(saved) : { + totalSessionsCompleted: 0, + totalCardsStudied: 0, + totalTimeStudied: 0, + currentStreak: 0, + longestStreak: 0, + lastStudyDate: null + }; + } catch (error) { + console.warn('Failed to load global stats:', error); + return {}; + } + } + + _saveGlobalStats() { + try { + localStorage.setItem(this._statsKey, JSON.stringify(this._globalStats)); + } catch (error) { + console.warn('Failed to save global stats:', error); + } + } + + _loadAchievements() { + try { + const saved = localStorage.getItem(this._achievementsKey); + return saved ? JSON.parse(saved) : []; + } catch (error) { + console.warn('Failed to load achievements:', error); + return []; + } + } + + _saveAchievements() { + try { + localStorage.setItem(this._achievementsKey, JSON.stringify(this._achievements)); + } catch (error) { + console.warn('Failed to save achievements:', error); + } + } + + _updateGlobalStats() { + const sessionDuration = Date.now() - this._sessionStats.startTime; + + this._globalStats.totalSessionsCompleted++; + this._globalStats.totalCardsStudied += this._sessionStats.cardsStudied; + this._globalStats.totalTimeStudied += sessionDuration; + + // Update study streak + const today = new Date().toDateString(); + const lastStudy = this._globalStats.lastStudyDate; + + if (lastStudy === today) { + // Already studied today, no change to streak + } else if (lastStudy === new Date(Date.now() - 86400000).toDateString()) { + // Studied yesterday, increment streak + this._globalStats.currentStreak++; + } else { + // Gap in studying, reset streak + this._globalStats.currentStreak = 1; + } + + this._globalStats.longestStreak = Math.max( + this._globalStats.longestStreak, + this._globalStats.currentStreak + ); + this._globalStats.lastStudyDate = today; + } + + _checkAchievements() { + const newAchievements = []; + + // First study session + if (this._globalStats.totalSessionsCompleted === 1 && + !this._achievements.includes('first_study')) { + newAchievements.push('first_study'); + } + + // Study streaks + if (this._globalStats.currentStreak >= 3 && + !this._achievements.includes('streak_3')) { + newAchievements.push('streak_3'); + } + + if (this._globalStats.currentStreak >= 7 && + !this._achievements.includes('streak_7')) { + newAchievements.push('streak_7'); + } + + // Mastery achievements + const masteredCount = Object.values(this._progress).filter(p => p.masteryLevel === 'mastered').length; + + if (masteredCount >= 10 && !this._achievements.includes('master_10')) { + newAchievements.push('master_10'); + } + + if (masteredCount >= 50 && !this._achievements.includes('master_50')) { + newAchievements.push('master_50'); + } + + // Session achievements + if (this._sessionStats.cardsStudied >= 100 && + !this._achievements.includes('speed_demon')) { + newAchievements.push('speed_demon'); + } + + if (this._sessionStats.cardsStudied >= 20 && + this._sessionStats.incorrect === 0 && + this._sessionStats.correct === this._sessionStats.cardsStudied && + !this._achievements.includes('perfectionist')) { + newAchievements.push('perfectionist'); + } + + // Add new achievements + this._achievements.push(...newAchievements); + + // Show achievement notifications + newAchievements.forEach(achievementId => { + this._showAchievementNotification(achievementId); + }); + } + + _showAchievementNotification(achievementId) { + const achievementNames = { + 'first_study': 'First Steps - Complete your first study session!', + 'streak_3': '3-Day Streak - Keep it up!', + 'streak_7': 'Week Warrior - Amazing dedication!', + 'master_10': 'Mastery Beginner - 10 cards mastered!', + 'master_50': 'Vocabulary Expert - 50 cards mastered!', + 'speed_demon': 'Speed Demon - 100 cards in one session!', + 'perfectionist': 'Perfectionist - Perfect score on 20+ cards!' + }; + + const message = achievementNames[achievementId] || 'New Achievement Unlocked!'; + console.log(`🏆 Achievement Unlocked: ${message}`); + + // Show visual notification (you could enhance this with a toast notification) + if (window.alert) { + setTimeout(() => { + alert(`🏆 Achievement Unlocked!\n${message}`); + }, 1000); + } + } + + // Show Messages + _showNoContentMessage() { + this._container.innerHTML = ` +
+

🎴 No Content Available

+

There's no vocabulary or sentence content available for flashcard learning in this chapter.

+

Please select a chapter with vocabulary words or sentences to study.

+ +
+ `; + } + + _showNoCardsMessage() { + this._container.innerHTML = ` +
+

📚 All Caught Up!

+

You've reviewed all available cards for today.

+

Great job! Come back tomorrow for more practice.

+
+

Cards Mastered: ${Object.values(this._progress).filter(p => p.masteryLevel === 'mastered').length}

+

Study Streak: ${this._globalStats.currentStreak} days

+
+ +
+ `; + } + + _showSessionComplete() { + const accuracy = this._sessionStats.total > 0 ? + Math.round((this._sessionStats.correct / this._sessionStats.total) * 100) : 0; + + const sessionTime = Math.round((Date.now() - this._sessionStats.startTime) / 1000 / 60); + + this._container.innerHTML = ` +
+

🎉 Session Complete!

+
+
+
${this._sessionStats.total}
+
Cards Studied
+
+
+
${accuracy}%
+
Accuracy
+
+
+
${sessionTime}m
+
Study Time
+
+
+ +
+

Mastery Progress

+ ${this._generateMasteryProgressHTML()} +
+ +
+ + +
+
+ `; + + this._endSession(); + } + + _generateMasteryProgressHTML() { + const levels = { new: 0, learning: 0, review: 0, mastered: 0 }; + + Object.values(this._progress).forEach(progress => { + levels[progress.masteryLevel]++; + }); + + const total = Object.values(levels).reduce((a, b) => a + b, 0); + + return Object.entries(levels).map(([level, count]) => { + const percentage = total > 0 ? Math.round((count / total) * 100) : 0; + const levelNames = { + new: 'New', + learning: 'Learning', + review: 'Review', + mastered: 'Mastered' + }; + + return ` +
+ ${levelNames[level]}: + ${count} (${percentage}%) +
+ `; + }).join(''); + } + + _startNewSession() { + this._setupSession(); + this._createGameUI(); + this._startSession(); + } +} + +export default FlashcardLearning; \ No newline at end of file diff --git a/src/games/GrammarDiscovery.js b/src/games/GrammarDiscovery.js new file mode 100644 index 0000000..483cebe --- /dev/null +++ b/src/games/GrammarDiscovery.js @@ -0,0 +1,1864 @@ +import Module from '../core/Module.js'; + +class GrammarDiscovery extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); + + if (!dependencies.eventBus || !dependencies.content) { + throw new Error('GrammarDiscovery requires eventBus and content dependencies'); + } + + this._eventBus = dependencies.eventBus; + this._content = dependencies.content; + this._config = { + container: null, + difficulty: 'medium', + ...config + }; + + this._score = 0; + this._rotationSteps = [ + 'explanation-basic', + 'examples-simple', + 'exercise-basic', + 'explanation-detailed', + 'examples-complex', + 'exercise-intermediate', + 'summary', + 'exercise-global' + ]; + + this._currentStep = 0; + this._grammarConcept = null; + this._conceptData = {}; + this._stepProgress = {}; + this._availableConcepts = []; + this._conceptSelected = false; + this._gameContainer = null; + + this._simpleExamples = []; + this._complexExamples = []; + this._basicExercises = []; + this._intermediateExercises = []; + this._globalExercises = []; + this._currentExerciseSet = []; + this._currentExerciseIndex = 0; + + Object.seal(this); + } + + static getMetadata() { + return { + id: 'grammar-discovery', + name: 'Grammar Discovery', + description: 'Interactive grammar learning with 8-step rotation system', + version: '2.0.0', + author: 'Class Generator', + category: 'grammar', + tags: ['grammar', 'learning', 'discovery', 'practice'], + difficulty: { + min: 1, + max: 4, + default: 2 + }, + estimatedDuration: 20, + requiredContent: ['grammar'] + }; + } + + static getCompatibilityScore(content) { + if (!content) { + return 0; + } + + // Handle both array and object vocabulary formats + let vocabCount = 0; + let hasVocabulary = false; + + if (Array.isArray(content.vocabulary)) { + vocabCount = content.vocabulary.length; + hasVocabulary = vocabCount > 0; + } else if (content.vocabulary && typeof content.vocabulary === 'object') { + vocabCount = Object.keys(content.vocabulary).length; + hasVocabulary = vocabCount > 0; + } + + // Check for dedicated grammar content + if (content.grammar) { + let score = 60; + const grammarConcepts = Object.keys(content.grammar); + if (grammarConcepts.length >= 1) score += 15; + if (grammarConcepts.length >= 3) score += 10; + if (grammarConcepts.length >= 5) score += 10; + const hasExercises = content.fillInBlanks || content.corrections; + if (hasExercises) score += 5; + return Math.min(score, 100); + } + + // Fallback: Use vocabulary to create basic grammar exercises + if (hasVocabulary && vocabCount >= 5) { + let score = 30; // Base score for vocabulary-based grammar + + if (vocabCount >= 10) score += 10; + if (vocabCount >= 20) score += 10; + if (vocabCount >= 30) score += 5; + + // Bonus if we have other useful content + if (content.fillInBlanks) score += 10; + if (content.corrections) score += 10; + if (content.sentences) score += 5; + + return Math.min(score, 75); // Cap at 75 for vocabulary-only content + } + + return 0; + } + + async init() { + this._validateNotDestroyed(); + + // Validate container + if (!this._config.container) { + throw new Error('Game container is required'); + } + + this._eventBus.on('game:start', this._handleGameStart.bind(this), this.name); + this._eventBus.on('game:stop', this._handleGameStop.bind(this), this.name); + this._eventBus.on('navigation:change', this._handleNavigationChange.bind(this), this.name); + + this._injectCSS(); + + // Start game immediately + try { + this._gameContainer = this._config.container; + const content = this._content; + + if (!content) { + throw new Error('No content available'); + } + + // Check if we have grammar content or vocabulary to work with + const hasGrammar = content.grammar && Object.keys(content.grammar).length > 0; + let hasVocabulary = false; + let vocabCount = 0; + + if (Array.isArray(content.vocabulary)) { + vocabCount = content.vocabulary.length; + hasVocabulary = vocabCount > 0; + } else if (content.vocabulary && typeof content.vocabulary === 'object') { + vocabCount = Object.keys(content.vocabulary).length; + hasVocabulary = vocabCount > 0; + } + + if (!hasGrammar && (!hasVocabulary || vocabCount < 5)) { + throw new Error('No suitable content available for grammar learning'); + } + + this._extractAvailableConcepts(content); + this._renderGameInterface(); + + // Emit game ready event + this._eventBus.emit('game:ready', { + gameId: 'grammar-discovery', + instanceId: this.name, + concepts: this._availableConcepts.length, + vocabulary: vocabCount + }, this.name); + + } catch (error) { + console.error('Error starting Grammar Discovery:', error); + this._eventBus.emit('game:error', { + gameId: 'grammar-discovery', + error: error.message, + module: this.name + }); + } + + this._setInitialized(); + } + + async destroy() { + this._validateNotDestroyed(); + + this._cleanup(); + this._removeCSS(); + this._eventBus.off('game:start', this.name); + this._eventBus.off('game:stop', this.name); + this._eventBus.off('navigation:change', this.name); + + this._setDestroyed(); + } + + _handleGameStart(event) { + this._validateInitialized(); + if (event.gameId === 'grammar-discovery') { + this._startGame(); + } + } + + _handleGameStop(event) { + this._validateInitialized(); + if (event.gameId === 'grammar-discovery') { + this._stopGame(); + } + } + + _handleNavigationChange(event) { + this._validateInitialized(); + if (event.from === '/games/grammar-discovery') { + this._cleanup(); + } + } + + async _startGame() { + try { + this._gameContainer = document.getElementById('game-content'); + if (!this._gameContainer) { + throw new Error('Game container not found'); + } + + const content = await this._content.getCurrentContent(); + if (!content) { + throw new Error('No content available'); + } + + // Check if we have grammar content or vocabulary to work with + const hasGrammar = content.grammar && Object.keys(content.grammar).length > 0; + let hasVocabulary = false; + let vocabCount = 0; + + if (Array.isArray(content.vocabulary)) { + vocabCount = content.vocabulary.length; + hasVocabulary = vocabCount > 0; + } else if (content.vocabulary && typeof content.vocabulary === 'object') { + vocabCount = Object.keys(content.vocabulary).length; + hasVocabulary = vocabCount > 0; + } + + if (!hasGrammar && (!hasVocabulary || vocabCount < 5)) { + throw new Error('No suitable content available for grammar learning'); + } + + this._extractAvailableConcepts(content); + this._renderGameInterface(); + + } catch (error) { + console.error('Error starting Grammar Discovery:', error); + this._eventBus.emit('game:error', { + gameId: 'grammar-discovery', + error: error.message, + module: this.name + }); + } + } + + _stopGame() { + this._cleanup(); + } + + _cleanup() { + if (this._gameContainer) { + this._gameContainer.innerHTML = ''; + } + + if (window.currentGrammarGame === this) { + delete window.currentGrammarGame; + } + } + + _extractAvailableConcepts(content) { + this._availableConcepts = []; + + // First, try to use dedicated grammar content + if (content.grammar && Object.keys(content.grammar).length > 0) { + this._availableConcepts = Object.entries(content.grammar).map(([key, conceptData]) => ({ + id: key, + title: conceptData.title, + explanation: conceptData.explanation, + data: conceptData + })); + } else { + // Fallback: Create basic grammar concepts from vocabulary + this._createVocabularyBasedConcepts(content); + } + + console.log(`Found ${this._availableConcepts.length} grammar concepts available`); + } + + _createVocabularyBasedConcepts(content) { + // Create basic grammar concepts that can work with vocabulary + const basicConcepts = [ + { + id: 'vocabulary-practice', + title: 'Vocabulary Practice', + explanation: 'Practice using new vocabulary words in context and build familiarity with their meanings.', + data: { + title: 'Vocabulary Practice', + explanation: 'Practice using new vocabulary words in context and build familiarity with their meanings.', + examples: this._createVocabularyExamples(content), + mainRules: [ + 'Learn the meaning of each word', + 'Practice pronunciation', + 'Use words in different contexts', + 'Remember common collocations' + ] + } + }, + { + id: 'word-formation', + title: 'Word Formation', + explanation: 'Understanding how words are formed and how to recognize patterns in vocabulary.', + data: { + title: 'Word Formation', + explanation: 'Understanding how words are formed and how to recognize patterns in vocabulary.', + examples: this._createWordFormationExamples(content), + mainRules: [ + 'Identify word roots and patterns', + 'Recognize common prefixes and suffixes', + 'Understand compound words', + 'Learn word families' + ] + } + } + ]; + + this._availableConcepts = basicConcepts; + } + + _createVocabularyExamples(content) { + const examples = []; + let vocabulary = []; + + // Extract vocabulary based on format + 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.forEach(item => { + if (typeof item === 'object') { + examples.push({ + chinese: item.chinese || item.text || item.word, + english: item.english || item.translation, + pronunciation: item.pronunciation || item.prononciation || '', + explanation: `Practice word: ${item.chinese || item.text || item.word}` + }); + } else if (typeof item === 'string') { + examples.push({ + chinese: item, + english: `Translation for: ${item}`, + pronunciation: '', + explanation: `Practice word: ${item}` + }); + } + }); + + return examples; + } + + _createWordFormationExamples(content) { + const examples = []; + let vocabulary = []; + + // Extract vocabulary based on format + 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.forEach(item => { + if (typeof item === 'object') { + const word = item.chinese || item.text || item.word; + examples.push({ + chinese: word, + english: item.english || item.translation || `Formation example: ${word}`, + pronunciation: item.pronunciation || item.prononciation || '', + explanation: `Analyze the structure and formation of: ${word}` + }); + } else if (typeof item === 'string') { + examples.push({ + chinese: item, + english: `Formation example: ${item}`, + pronunciation: '', + explanation: `Analyze the structure and formation of: ${item}` + }); + } + }); + + return examples; + } + + _renderGameInterface() { + this._gameContainer.innerHTML = ` +
+
+
+ 🎯 + Select Grammar Concept +
+ +
Score: 0
+
+
+ +
+
+ `; + + window.currentGrammarGame = this; + this._showConceptSelector(); + } + + _showConceptSelector() { + if (this._availableConcepts.length === 0) { + console.error('No concepts available for selection'); + return; + } + + const contentDiv = document.getElementById('grammar-content'); + + contentDiv.innerHTML = ` +
+
+ 🎯 Choose Grammar Concept +
+
+ Select which grammar concept you want to focus on for intensive study with our 8-step rotation system. +
+ + + +
+ +
+ + +
+ `; + } + + _previewConcept(conceptId) { + const previewDiv = document.getElementById('concept-preview'); + const startBtn = document.getElementById('start-btn'); + + if (!conceptId) { + previewDiv.classList.remove('show'); + startBtn.disabled = true; + return; + } + + const concept = this._availableConcepts.find(c => c.id === conceptId); + if (!concept) return; + + this._grammarConcept = conceptId; + this._conceptData = concept.data; + this._organizeConceptContent(); + + previewDiv.innerHTML = ` +
${concept.title}
+
${concept.explanation}
+
+
+ ${this._simpleExamples.length}
+ Simple Examples +
+
+ ${this._complexExamples.length}
+ Complex Examples +
+
+ ${this._basicExercises.length}
+ Basic Exercises +
+
+ ${this._intermediateExercises.length}
+ Intermediate Exercises +
+
+ ${this._globalExercises.length}
+ Final Test Questions +
+
+ `; + + previewDiv.classList.add('show'); + startBtn.disabled = false; + } + + _startSelectedConcept() { + const dropdown = document.getElementById('concept-dropdown'); + const selectedConceptId = dropdown.value; + + if (!selectedConceptId) { + alert('Please select a grammar concept first!'); + return; + } + + document.getElementById('step-progress').style.display = 'block'; + document.getElementById('phase-icon').textContent = '📚'; + document.getElementById('phase-text').textContent = 'Basic Explanation'; + + this._selectConcept(selectedConceptId); + } + + _selectConcept(conceptId) { + const selectedConcept = this._availableConcepts.find(c => c.id === conceptId); + if (!selectedConcept) { + console.error('Concept not found:', conceptId); + return; + } + + this._grammarConcept = conceptId; + this._conceptData = selectedConcept.data; + this._conceptSelected = true; + + this._organizeConceptContent(); + this._startRotationCycle(); + } + + async _organizeConceptContent() { + const concept = this._conceptData; + + this._simpleExamples = []; + this._complexExamples = []; + + if (concept.examples) { + this._simpleExamples = concept.examples.slice(0, 3); + this._complexExamples = concept.examples.slice(3); + } + + if (concept.detailedExplanation) { + Object.values(concept.detailedExplanation).forEach(section => { + if (section.examples) { + const sectionSimple = section.examples.slice(0, 2); + const sectionComplex = section.examples.slice(2); + + this._simpleExamples.push(...sectionSimple); + this._complexExamples.push(...sectionComplex); + } + }); + } + + this._basicExercises = []; + this._intermediateExercises = []; + this._globalExercises = []; + + const content = await this._content.getCurrentContent(); + + if (content.fillInBlanks) { + content.fillInBlanks.forEach(exercise => { + const isGrammarLesson = content.type === 'grammar_course'; + const isRelevant = isGrammarLesson || + exercise.grammarFocus === this._grammarConcept || + exercise.grammarFocus === 'completion' || + exercise.grammarFocus === 'change-of-state'; + + if (isRelevant) { + if (exercise.sentence.length < 15) { + this._basicExercises.push(exercise); + } else { + this._intermediateExercises.push(exercise); + } + } + }); + } + + if (content.corrections) { + content.corrections.forEach(correction => { + const isGrammarLesson = content.type === 'grammar_course'; + const isRelevant = isGrammarLesson || + correction.grammarFocus === this._grammarConcept; + + if (isRelevant) { + this._intermediateExercises.push({ + type: 'correction', + question: 'Which sentence is correct?', + options: [correction.correct, correction.incorrect], + correctAnswer: correction.correct, + explanation: correction.explanation + }); + } + }); + } + + this._globalExercises = [...this._basicExercises, ...this._intermediateExercises]; + this._globalExercises = this._shuffleArray(this._globalExercises).slice(0, 5); + } + + _startRotationCycle() { + this._currentStep = 0; + this._showCurrentStep(); + } + + _showCurrentStep() { + const stepType = this._rotationSteps[this._currentStep]; + const stepNumber = this._currentStep + 1; + + document.getElementById('current-step').textContent = stepNumber; + + const phaseInfo = this._getPhaseInfo(stepType); + document.getElementById('phase-icon').textContent = phaseInfo.icon; + document.getElementById('phase-text').textContent = phaseInfo.text; + + switch (stepType) { + case 'explanation-basic': + this._showBasicExplanation(); + break; + case 'examples-simple': + this._showSimpleExamples(); + break; + case 'exercise-basic': + this._showBasicExercises(); + break; + case 'explanation-detailed': + this._showDetailedExplanation(); + break; + case 'examples-complex': + this._showComplexExamples(); + break; + case 'exercise-intermediate': + this._showIntermediateExercises(); + break; + case 'summary': + this._showSummary(); + break; + case 'exercise-global': + this._showGlobalExercises(); + break; + } + } + + _getPhaseInfo(stepType) { + const phaseMap = { + 'explanation-basic': { icon: '📚', text: 'Basic Explanation' }, + 'examples-simple': { icon: '💡', text: 'Simple Examples' }, + 'exercise-basic': { icon: '✏️', text: 'Basic Practice' }, + 'explanation-detailed': { icon: '🔍', text: 'Detailed Explanation' }, + 'examples-complex': { icon: '🧩', text: 'Complex Examples' }, + 'exercise-intermediate': { icon: '💪', text: 'Intermediate Practice' }, + 'summary': { icon: '📝', text: 'Summary' }, + 'exercise-global': { icon: '🏆', text: 'Final Test' } + }; + return phaseMap[stepType] || { icon: '❓', text: 'Unknown' }; + } + + _nextStep() { + this._currentStep++; + if (this._currentStep >= this._rotationSteps.length) { + this._completeRotation(); + } else { + this._showCurrentStep(); + } + } + + _completeRotation() { + // Handle localStorage best score + const currentScore = this._score; + const bestScore = parseInt(localStorage.getItem('grammar-discovery-best-score') || '0'); + const isNewBest = currentScore > bestScore; + + if (isNewBest) { + localStorage.setItem('grammar-discovery-best-score', currentScore.toString()); + } + + // Calculate completion stats + const completionRate = Math.round((this._currentStep / this._rotationSteps.length) * 100); + + this._showVictoryPopup({ + gameTitle: 'Grammar Discovery', + currentScore, + bestScore: isNewBest ? currentScore : bestScore, + isNewBest, + stats: { + 'Concept Studied': this._conceptData.title || 'Grammar Concept', + 'Steps Completed': `${this._currentStep}/${this._rotationSteps.length}`, + 'Completion Rate': `${completionRate}%`, + 'Learning Mode': 'Full Rotation' + } + }); + } + + _showBasicExplanation() { + const concept = this._conceptData; + const contentDiv = document.getElementById('grammar-content'); + + contentDiv.innerHTML = ` +
+
+ 📚 ${concept.title} +
+
+ ${concept.explanation} +
+ + ${concept.mainRules ? ` +
+

🎯 Key Rules:

+
    + ${concept.mainRules.slice(0, 3).map(rule => `
  • ${rule}
  • `).join('')} +
+
+ ` : ''} + +
+ +
+
+ `; + } + + _showSimpleExamples() { + const contentDiv = document.getElementById('grammar-content'); + + contentDiv.innerHTML = ` +
+
+ 💡 Simple Examples +
+
+ ${this._simpleExamples.map((example, index) => ` +
+
${example.chinese || example.text || example.sentence}
+
${example.english || example.translation}
+
${example.pronunciation || example.prononciation || ''}
+
${example.explanation || example.breakdown || ''}
+
+ +
+
+ `).join('')} +
+ +
+ +
+
+ `; + } + + _showBasicExercises() { + if (this._basicExercises.length === 0) { + this._nextStep(); + return; + } + + this._currentExerciseSet = this._basicExercises; + this._currentExerciseIndex = 0; + this._showExercise('basic'); + } + + _showDetailedExplanation() { + const concept = this._conceptData; + const contentDiv = document.getElementById('grammar-content'); + + let detailsHtml = ''; + if (concept.detailedExplanation) { + detailsHtml = Object.entries(concept.detailedExplanation).map(([key, section]) => ` +
+

🔍 ${section.title}

+

${section.explanation}

+ ${section.pattern ? `
Pattern: ${section.pattern}
` : ''} +
+ `).join(''); + } + + contentDiv.innerHTML = ` +
+
+ 🔍 Detailed Explanation +
+ + ${detailsHtml} + + ${concept.commonMistakes ? ` +
+

⚠️ Common Mistakes:

+ ${concept.commonMistakes.map(mistake => ` +
+
❌ ${mistake.wrong}
+
✅ ${mistake.correct}
+
${mistake.explanation}
+
+ `).join('')} +
+ ` : ''} + +
+ +
+
+ `; + } + + _showComplexExamples() { + const contentDiv = document.getElementById('grammar-content'); + + contentDiv.innerHTML = ` +
+
+ 🧩 Complex Examples +
+
+ ${this._complexExamples.map((example, index) => ` +
+
${example.chinese || example.text || example.sentence}
+
${example.english || example.translation}
+
${example.pronunciation || example.prononciation || ''}
+
${example.explanation || example.breakdown || ''}
+
+ +
+
+ `).join('')} +
+ +
+ +
+
+ `; + } + + _showIntermediateExercises() { + if (this._intermediateExercises.length === 0) { + this._nextStep(); + return; + } + + this._currentExerciseSet = this._intermediateExercises; + this._currentExerciseIndex = 0; + this._showExercise('intermediate'); + } + + _showSummary() { + const concept = this._conceptData; + const contentDiv = document.getElementById('grammar-content'); + + contentDiv.innerHTML = ` +
+
+ 📝 Summary: ${concept.title} +
+ +
+

🎯 What You've Learned:

+
+
+ Basic Concept:
+ ${concept.explanation} +
+ ${concept.mainRules ? ` +
+ Key Rules:
+ ${concept.mainRules.map(rule => `• ${rule}`).join('
')} +
+ ` : ''} + ${concept.practicePoints ? ` +
+ Practice Points:
+ ${concept.practicePoints.map(point => `• ${point}`).join('
')} +
+ ` : ''} +
+
+ +
+ +
+
+ `; + } + + _showGlobalExercises() { + if (this._globalExercises.length === 0) { + this._completeRotation(); + return; + } + + this._currentExerciseSet = this._globalExercises; + this._currentExerciseIndex = 0; + this._showExercise('global'); + } + + _showExercise(type) { + const exercise = this._currentExerciseSet[this._currentExerciseIndex]; + if (!exercise) { + this._nextStep(); + return; + } + + const contentDiv = document.getElementById('grammar-content'); + const typeInfo = { + 'basic': { title: '✏️ Basic Practice', color: '#48bb78' }, + 'intermediate': { title: '💪 Intermediate Practice', color: '#3182ce' }, + 'global': { title: '🏆 Final Test', color: '#805ad5' } + }; + + const info = typeInfo[type] || typeInfo['basic']; + + if (exercise.type === 'correction') { + contentDiv.innerHTML = ` +
+
+ ${info.title} +
+
+ ${exercise.question} +
+
+ ${exercise.options.map(option => ` + + `).join('')} +
+ +
+ Exercise ${this._currentExerciseIndex + 1} of ${this._currentExerciseSet.length} +
+
+ `; + } else { + contentDiv.innerHTML = ` +
+
+ ${info.title} +
+
+ Fill in the blank: ${exercise.sentence} +
+
+ ${exercise.options.map(option => ` + + `).join('')} +
+ +
+ Exercise ${this._currentExerciseIndex + 1} of ${this._currentExerciseSet.length} +
+
+ `; + } + } + + _selectAnswer(selected, correct, exerciseType) { + const buttons = document.querySelectorAll('.option-btn'); + const feedback = document.getElementById('feedback'); + + buttons.forEach(btn => { + btn.disabled = true; + if (btn.textContent.trim() === correct) { + btn.classList.add('correct'); + } else if (btn.textContent.trim() === selected && selected !== correct) { + btn.classList.add('incorrect'); + } + }); + + if (selected === correct) { + const points = exerciseType === 'global' ? 30 : (exerciseType === 'intermediate' ? 20 : 15); + this._score += points; + } else { + this._score = Math.max(0, this._score - 5); + } + + document.getElementById('score-value').textContent = this._score; + feedback.classList.add('show'); + + setTimeout(() => { + this._currentExerciseIndex++; + if (this._currentExerciseIndex >= this._currentExerciseSet.length) { + this._nextStep(); + } else { + this._showExercise(exerciseType); + } + }, 2500); + } + + _restart() { + this._score = 0; + this._currentStep = 0; + this._currentExerciseIndex = 0; + this._conceptSelected = false; + this._grammarConcept = null; + this._conceptData = {}; + + document.getElementById('score-value').textContent = '0'; + document.getElementById('phase-icon').textContent = '🎯'; + document.getElementById('phase-text').textContent = 'Select Grammar Concept'; + document.getElementById('step-progress').style.display = 'none'; + + this._showConceptSelector(); + } + + _speakText(text, lang = 'zh-CN') { + if ('speechSynthesis' in window) { + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = lang; + utterance.rate = 0.8; + speechSynthesis.speak(utterance); + } + } + + _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; + } + + _injectCSS() { + const cssId = 'grammar-discovery-styles'; + if (document.getElementById(cssId)) return; + + const style = document.createElement('style'); + style.id = cssId; + style.textContent = ` + .grammar-discovery { + display: flex; + flex-direction: column; + height: 100vh; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + max-width: 1000px; + margin: 0 auto; + padding: 20px; + } + + .grammar-hud { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 15px 25px; + display: flex; + justify-content: space-between; + align-items: center; + border-radius: 12px; + margin-bottom: 20px; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); + } + + .grammar-phase { + font-size: 18px; + font-weight: bold; + display: flex; + align-items: center; + gap: 10px; + } + + .phase-icon { + font-size: 24px; + } + + .step-progress { + font-size: 16px; + opacity: 0.9; + } + + .score { + font-size: 16px; + font-weight: bold; + } + + .grammar-content { + flex: 1; + background: linear-gradient(145deg, #f8f9ff, #e6e9ff); + border-radius: 12px; + padding: 20px; + overflow-y: auto; + } + + .concept-selector { + background: white; + border-radius: 15px; + padding: 30px; + box-shadow: 0 8px 32px rgba(102, 126, 234, 0.1); + text-align: center; + max-width: 600px; + margin: 0 auto; + } + + .selector-title { + font-size: 28px; + font-weight: bold; + color: #4c51bf; + margin-bottom: 20px; + } + + .selector-description { + font-size: 16px; + color: #4a5568; + margin-bottom: 25px; + line-height: 1.6; + } + + .concept-dropdown { + width: 100%; + max-width: 400px; + padding: 15px; + font-size: 16px; + border: 2px solid #e2e8f0; + border-radius: 10px; + background: white; + margin-bottom: 20px; + cursor: pointer; + transition: all 0.3s ease; + } + + .concept-dropdown:hover { + border-color: #667eea; + } + + .concept-dropdown:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); + } + + .concept-preview { + background: #f7fafc; + border-radius: 10px; + padding: 20px; + margin: 20px 0; + text-align: left; + display: none; + } + + .concept-preview.show { + display: block; + animation: fadeIn 0.3s ease; + } + + .preview-title { + font-size: 18px; + font-weight: bold; + color: #2d3748; + margin-bottom: 10px; + } + + .preview-explanation { + font-size: 14px; + color: #4a5568; + margin-bottom: 15px; + } + + .preview-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 10px; + font-size: 12px; + color: #718096; + } + + .stat-item { + background: white; + padding: 8px; + border-radius: 6px; + text-align: center; + } + + .discover-btn { + background: linear-gradient(135deg, #667eea, #764ba2); + color: white; + border: none; + padding: 12px 25px; + border-radius: 25px; + font-size: 16px; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); + } + + .discover-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); + } + + .discover-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + } + + .rule-card { + background: white; + border-radius: 15px; + padding: 25px; + margin-bottom: 20px; + box-shadow: 0 8px 32px rgba(102, 126, 234, 0.1); + border: 2px solid transparent; + transition: all 0.3s ease; + } + + .rule-card.active { + border-color: #667eea; + transform: translateY(-2px); + box-shadow: 0 12px 40px rgba(102, 126, 234, 0.2); + } + + .rule-title { + font-size: 24px; + font-weight: bold; + color: #4c51bf; + margin-bottom: 15px; + display: flex; + align-items: center; + gap: 10px; + } + + .rule-explanation { + font-size: 16px; + line-height: 1.6; + color: #4a5568; + margin-bottom: 20px; + background: #f7fafc; + padding: 15px; + border-radius: 8px; + border-left: 4px solid #667eea; + } + + .rules-list h4 { + color: #2d3748; + margin-bottom: 10px; + } + + .rules-list ul { + margin: 0; + padding-left: 20px; + } + + .rules-list li { + margin-bottom: 8px; + color: #4a5568; + } + + .examples-section { + margin-top: 20px; + } + + .example-item { + background: #ffffff; + border: 2px solid #e2e8f0; + border-radius: 12px; + padding: 20px; + margin-bottom: 15px; + transition: all 0.3s ease; + } + + .example-item.revealed { + border-color: #48bb78; + background: linear-gradient(135deg, #f0fff4, #c6f6d5); + } + + .chinese-text { + font-size: 22px; + font-weight: bold; + color: #2d3748; + margin-bottom: 8px; + } + + .english-text { + font-size: 18px; + color: #4a5568; + margin-bottom: 8px; + } + + .pronunciation { + font-size: 16px; + color: #718096; + font-style: italic; + margin-bottom: 10px; + } + + .explanation-text { + font-size: 14px; + color: #667eea; + background: #edf2f7; + padding: 10px; + border-radius: 6px; + margin-bottom: 10px; + } + + .tts-controls { + display: flex; + gap: 10px; + align-items: center; + } + + .tts-btn { + background: #667eea; + color: white; + border: none; + border-radius: 20px; + padding: 8px 15px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s ease; + } + + .tts-btn:hover { + background: #5a67d8; + transform: scale(1.05); + } + + .discovery-controls { + display: flex; + gap: 15px; + margin-top: 20px; + justify-content: center; + } + + .detail-section { + margin-bottom: 20px; + padding: 15px; + background: #f7fafc; + border-radius: 8px; + border-left: 4px solid #3182ce; + } + + .detail-section h4 { + color: #2d3748; + margin-bottom: 10px; + } + + .pattern { + background: #edf2f7; + padding: 8px; + border-radius: 4px; + margin-top: 10px; + font-family: monospace; + } + + .mistakes-section { + margin-top: 20px; + padding: 15px; + background: #fed7d7; + border-radius: 8px; + border-left: 4px solid #f56565; + } + + .mistakes-section h4 { + color: #742a2a; + margin-bottom: 15px; + } + + .mistake-item { + margin-bottom: 15px; + padding: 10px; + background: white; + border-radius: 6px; + } + + .mistake-wrong { + color: #c53030; + font-weight: bold; + margin-bottom: 5px; + } + + .mistake-correct { + color: #38a169; + font-weight: bold; + margin-bottom: 5px; + } + + .mistake-explanation { + color: #4a5568; + font-size: 14px; + } + + .summary-section { + background: #f7fafc; + padding: 20px; + border-radius: 10px; + border-left: 4px solid #667eea; + } + + .summary-section h4 { + color: #2d3748; + margin-bottom: 15px; + } + + .summary-grid { + display: grid; + gap: 15px; + } + + .summary-item { + background: white; + padding: 15px; + border-radius: 8px; + border-left: 4px solid #48bb78; + } + + .practice-question { + background: white; + border-radius: 15px; + padding: 25px; + margin-bottom: 20px; + box-shadow: 0 8px 32px rgba(102, 126, 234, 0.1); + } + + .question-text { + font-size: 20px; + color: #2d3748; + margin-bottom: 20px; + line-height: 1.6; + } + + .options-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; + margin-bottom: 20px; + } + + .option-btn { + background: white; + border: 2px solid #e2e8f0; + border-radius: 12px; + padding: 15px; + font-size: 16px; + cursor: pointer; + transition: all 0.3s ease; + text-align: center; + } + + .option-btn:hover:not(:disabled) { + border-color: #667eea; + background: #f7fafc; + } + + .option-btn.correct { + border-color: #48bb78; + background: linear-gradient(135deg, #f0fff4, #c6f6d5); + color: #22543d; + } + + .option-btn.incorrect { + border-color: #f56565; + background: linear-gradient(135deg, #fed7d7, #feb2b2); + color: #742a2a; + } + + .option-btn:disabled { + cursor: not-allowed; + } + + .feedback { + background: #f7fafc; + border-radius: 10px; + padding: 15px; + margin-top: 15px; + border-left: 4px solid #667eea; + display: none; + } + + .feedback.show { + display: block; + animation: slideIn 0.3s ease; + } + + .exercise-progress { + text-align: center; + margin-top: 15px; + font-size: 14px; + color: #718096; + } + + .phase-complete { + text-align: center; + padding: 40px; + background: linear-gradient(135deg, #f0fff4, #c6f6d5); + border-radius: 15px; + margin: 20px 0; + } + + .complete-icon { + font-size: 48px; + margin-bottom: 20px; + } + + .complete-title { + font-size: 24px; + font-weight: bold; + color: #22543d; + margin-bottom: 15px; + } + + @keyframes fadeIn { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } + } + + @keyframes slideIn { + from { opacity: 0; transform: translateX(-20px); } + to { opacity: 1; transform: translateX(0); } + } + + @media (max-width: 768px) { + .grammar-discovery { + padding: 10px; + } + + .grammar-hud { + flex-direction: column; + gap: 10px; + text-align: center; + } + + .options-grid { + grid-template-columns: 1fr; + } + + .discovery-controls { + flex-direction: column; + align-items: center; + } + + .preview-stats { + grid-template-columns: repeat(2, 1fr); + } + } + + /* Victory Popup Styles */ + .victory-popup { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: fadeIn 0.3s ease-out; + } + + .victory-content { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 20px; + padding: 40px; + text-align: center; + color: white; + max-width: 500px; + width: 90%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: slideUp 0.4s ease-out; + } + + .victory-header { + margin-bottom: 30px; + } + + .victory-icon { + font-size: 4rem; + margin-bottom: 15px; + animation: bounce 0.6s ease-out; + } + + .victory-title { + font-size: 2rem; + font-weight: bold; + margin: 0 0 10px 0; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + + .new-best-badge { + background: linear-gradient(45deg, #f093fb 0%, #f5576c 100%); + color: white; + padding: 8px 20px; + border-radius: 25px; + font-size: 0.9rem; + font-weight: bold; + display: inline-block; + margin-top: 10px; + animation: glow 1s ease-in-out infinite alternate; + } + + .victory-scores { + display: flex; + justify-content: space-around; + margin: 30px 0; + gap: 20px; + } + + .score-display { + background: rgba(255, 255, 255, 0.1); + border-radius: 15px; + padding: 20px; + flex: 1; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + } + + .score-label { + font-size: 0.9rem; + opacity: 0.9; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 1px; + } + + .score-value { + font-size: 2rem; + font-weight: bold; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + + .victory-stats { + background: rgba(255, 255, 255, 0.1); + border-radius: 15px; + padding: 20px; + margin: 30px 0; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + } + + .stat-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + + .stat-row:last-child { + border-bottom: none; + } + + .stat-name { + font-size: 0.95rem; + opacity: 0.9; + } + + .stat-value { + font-weight: bold; + font-size: 1rem; + } + + .victory-buttons { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 30px; + } + + .victory-btn { + padding: 15px 30px; + border: none; + border-radius: 25px; + font-size: 1rem; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; + text-transform: uppercase; + letter-spacing: 1px; + } + + .victory-btn.primary { + background: linear-gradient(45deg, #4facfe 0%, #00f2fe 100%); + color: white; + box-shadow: 0 8px 25px rgba(79, 172, 254, 0.3); + } + + .victory-btn.primary:hover { + transform: translateY(-2px); + box-shadow: 0 12px 35px rgba(79, 172, 254, 0.4); + } + + .victory-btn.secondary { + background: linear-gradient(45deg, #a8edea 0%, #fed6e3 100%); + color: #333; + box-shadow: 0 8px 25px rgba(168, 237, 234, 0.3); + } + + .victory-btn.secondary:hover { + transform: translateY(-2px); + box-shadow: 0 12px 35px rgba(168, 237, 234, 0.4); + } + + .victory-btn.tertiary { + background: rgba(255, 255, 255, 0.2); + color: white; + border: 2px solid rgba(255, 255, 255, 0.3); + backdrop-filter: blur(10px); + } + + .victory-btn.tertiary:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); + } + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px) scale(0.9); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes bounce { + 0%, 20%, 50%, 80%, 100% { + transform: translateY(0); + } + 40% { + transform: translateY(-10px); + } + 60% { + transform: translateY(-5px); + } + } + + @keyframes glow { + from { + box-shadow: 0 0 20px rgba(245, 87, 108, 0.5); + } + to { + box-shadow: 0 0 30px rgba(245, 87, 108, 0.8); + } + } + + @media (max-width: 768px) { + .victory-content { + padding: 30px 20px; + width: 95%; + } + + .victory-scores { + flex-direction: column; + gap: 15px; + } + + .victory-icon { + font-size: 3rem; + } + + .victory-title { + font-size: 1.5rem; + } + + .victory-buttons { + gap: 10px; + } + + .victory-btn { + padding: 12px 25px; + font-size: 0.9rem; + } + } + `; + + document.head.appendChild(style); + } + + _showVictoryPopup({ gameTitle, currentScore, bestScore, isNewBest, stats }) { + const popup = document.createElement('div'); + popup.className = 'victory-popup'; + popup.innerHTML = ` +
+
+
🏆
+

${gameTitle} Complete!

+ ${isNewBest ? '
🎉 New Best Score!
' : ''} +
+ +
+
+
Your Score
+
${currentScore}
+
+
+
Best Score
+
${bestScore}
+
+
+ +
+ ${Object.entries(stats).map(([key, value]) => ` +
+ ${key} + ${value} +
+ `).join('')} +
+ +
+ + + +
+
+ `; + + document.body.appendChild(popup); + + // Emit completion event after showing popup + this._eventBus.emit('game:end', { + gameId: 'grammar-discovery', + score: currentScore, + module: this.name + }); + } + + _removeCSS() { + const cssElement = document.getElementById('grammar-discovery-styles'); + if (cssElement) { + cssElement.remove(); + } + + if (window.currentGrammarGame === this) { + delete window.currentGrammarGame; + } + } +} + +export default GrammarDiscovery; \ No newline at end of file diff --git a/src/games/LetterDiscovery.js b/src/games/LetterDiscovery.js new file mode 100644 index 0000000..29e1244 --- /dev/null +++ b/src/games/LetterDiscovery.js @@ -0,0 +1,1205 @@ +import Module from '../core/Module.js'; + +/** + * LetterDiscovery - Interactive letter and word discovery game + * Three phases: letter discovery, word exploration, and practice challenges + */ +class LetterDiscovery extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); + + // Validate dependencies + if (!dependencies.eventBus || !dependencies.content) { + throw new Error('LetterDiscovery requires eventBus and content dependencies'); + } + + this._eventBus = dependencies.eventBus; + this._content = dependencies.content; + this._config = { + container: null, + maxPracticeRounds: 8, + autoPlayTTS: true, + ttsSpeed: 0.8, + ...config + }; + + // Game state + this._currentPhase = 'letter-discovery'; // letter-discovery, word-exploration, practice + this._currentLetterIndex = 0; + this._discoveredLetters = []; + this._currentLetter = null; + this._currentWordIndex = 0; + this._discoveredWords = []; + this._score = 0; + this._lives = 3; + + // Content data + this._letters = []; + this._letterWords = {}; // Map letter -> words starting with that letter + + // Practice system + this._practiceLevel = 1; + this._practiceRound = 0; + this._practiceCorrectAnswers = 0; + this._practiceErrors = 0; + this._currentPracticeItems = []; + this._currentCorrectAnswer = null; + + Object.seal(this); + } + + /** + * Get game metadata + * @returns {Object} Game metadata + */ + static getMetadata() { + return { + name: 'Letter Discovery', + description: 'Discover letters and explore words that start with each letter', + difficulty: 'beginner', + category: 'letters', + estimatedTime: 10, // minutes + skills: ['alphabet', 'vocabulary', 'pronunciation'] + }; + } + + /** + * Calculate compatibility score with content + * @param {Object} content - Content to check compatibility with + * @returns {Object} Compatibility score and details + */ + static getCompatibilityScore(content) { + const letters = content?.letters || content?.rawContent?.letters; + + // Try to create letters from vocabulary if direct letters not found + let lettersData = letters; + if (!lettersData && content?.vocabulary) { + lettersData = this._createLettersFromVocabulary(content.vocabulary); + } + + if (!lettersData || Object.keys(lettersData).length === 0) { + return { + score: 0, + reason: 'No letter structure found', + requirements: ['letters'], + details: 'Letter Discovery requires content with predefined letters system' + }; + } + + const letterCount = letters ? Object.keys(letters).length : 0; + const totalWords = letters ? Object.values(letters).reduce((sum, words) => sum + (words?.length || 0), 0) : 0; + + if (totalWords === 0) { + return { + score: 0.2, + reason: 'Letters found but no words', + requirements: ['letters with words'], + details: `Found ${letterCount} letters but no associated words` + }; + } + + // Perfect score at 26 letters, good score for 10+ letters + const score = Math.min(letterCount / 26, 1); + + return { + score, + reason: `${letterCount} letters with ${totalWords} total words`, + requirements: ['letters'], + optimalLetters: 26, + details: `Can create discovery experience with ${letterCount} letters and ${totalWords} words` + }; + } + + async init() { + this._validateNotDestroyed(); + + try { + // Validate container + if (!this._config.container) { + throw new Error('Game container is required'); + } + + // Extract and validate content + this._extractContent(); + + if (this._letters.length === 0) { + throw new Error('No letter content found for discovery'); + } + + // Set up event listeners + this._eventBus.on('game:pause', this._handlePause.bind(this), this.name); + this._eventBus.on('game:resume', this._handleResume.bind(this), this.name); + + // Initialize game interface + this._injectCSS(); + this._createGameInterface(); + this._setupEventListeners(); + + // Start with first letter + this._showLetterCard(); + + // Emit game ready event + this._eventBus.emit('game:ready', { + gameId: 'letter-discovery', + instanceId: this.name, + letters: this._letters.length, + totalWords: Object.values(this._letterWords).reduce((sum, words) => sum + words.length, 0) + }, this.name); + + this._setInitialized(); + + } catch (error) { + this._showError(error.message); + throw error; + } + } + + async destroy() { + this._validateNotDestroyed(); + + // Clean up container + if (this._config.container) { + this._config.container.innerHTML = ''; + } + + // Remove injected CSS + this._removeInjectedCSS(); + + // Emit game end event + this._eventBus.emit('game:ended', { + gameId: 'letter-discovery', + instanceId: this.name, + score: this._score, + lettersDiscovered: this._discoveredLetters.length, + wordsLearned: this._discoveredWords.length + }, this.name); + + this._setDestroyed(); + } + + /** + * Get current game state + * @returns {Object} Current game state + */ + getGameState() { + this._validateInitialized(); + + return { + phase: this._currentPhase, + score: this._score, + lives: this._lives, + currentLetter: this._currentLetter, + lettersDiscovered: this._discoveredLetters.length, + totalLetters: this._letters.length, + wordsLearned: this._discoveredWords.length, + practiceAccuracy: this._config.maxPracticeRounds > 0 ? + (this._practiceCorrectAnswers / this._config.maxPracticeRounds) * 100 : 0 + }; + } + + // Private methods + _extractContent() { + const letters = this._content.letters || this._content.rawContent?.letters; + + if (letters && Object.keys(letters).length > 0) { + this._letters = Object.keys(letters).sort(); + this._letterWords = letters; + } else { + this._letters = []; + this._letterWords = {}; + } + } + + _injectCSS() { + if (document.getElementById('letter-discovery-styles')) return; + + const styleSheet = document.createElement('style'); + styleSheet.id = 'letter-discovery-styles'; + styleSheet.textContent = ` + .letter-discovery-wrapper { + background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e0 100%); + min-height: 100vh; + padding: 20px; + position: relative; + overflow-y: auto; + box-sizing: border-box; + } + + .letter-discovery-hud { + display: flex; + justify-content: space-between; + align-items: center; + background: rgba(255,255,255,0.1); + padding: 15px 20px; + border-radius: 15px; + backdrop-filter: blur(10px); + margin-bottom: 20px; + flex-wrap: wrap; + gap: 10px; + } + + .hud-group { + display: flex; + align-items: center; + gap: 15px; + } + + .hud-item { + color: white; + font-weight: bold; + font-size: 1.1em; + } + + .phase-indicator { + background: rgba(255,255,255,0.2); + padding: 8px 16px; + border-radius: 20px; + font-size: 0.9em; + color: white; + backdrop-filter: blur(5px); + } + + .letter-discovery-main { + background: rgba(255,255,255,0.1); + border-radius: 20px; + padding: 30px; + backdrop-filter: blur(10px); + min-height: 60vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + + .game-content { + width: 100%; + max-width: 900px; + text-align: center; + } + + /* Letter Display Styles */ + .letter-card { + background: rgba(255,255,255,0.95); + border-radius: 25px; + padding: 60px 40px; + margin: 30px auto; + max-width: 400px; + box-shadow: 0 20px 40px rgba(0,0,0,0.1); + transform: scale(0.8); + animation: letterAppear 0.8s ease-out forwards; + } + + @keyframes letterAppear { + to { transform: scale(1); } + } + + .letter-display { + font-size: 8em; + font-weight: bold; + color: #2d3748; + margin-bottom: 20px; + text-shadow: 0 4px 8px rgba(0,0,0,0.1); + font-family: 'Arial Black', Arial, sans-serif; + } + + .letter-info { + font-size: 1.5em; + color: #333; + margin-bottom: 15px; + } + + .letter-pronunciation { + font-size: 1.2em; + color: #666; + font-style: italic; + margin-bottom: 25px; + } + + .letter-controls { + display: flex; + gap: 15px; + justify-content: center; + margin-top: 30px; + flex-wrap: wrap; + } + + /* Word Exploration Styles */ + .word-exploration-header { + background: rgba(255,255,255,0.1); + padding: 20px; + border-radius: 15px; + margin-bottom: 30px; + backdrop-filter: blur(5px); + } + + .exploring-letter { + font-size: 3em; + color: white; + margin-bottom: 10px; + font-weight: bold; + } + + .word-progress { + color: rgba(255,255,255,0.8); + font-size: 1.1em; + } + + .word-card { + background: rgba(255,255,255,0.95); + border-radius: 20px; + padding: 40px 30px; + margin: 25px auto; + max-width: 500px; + box-shadow: 0 15px 30px rgba(0,0,0,0.1); + transform: translateY(20px); + animation: wordSlideIn 0.6s ease-out forwards; + } + + @keyframes wordSlideIn { + to { transform: translateY(0); } + } + + .word-text { + font-size: 2.5em; + color: #2d3748; + margin-bottom: 15px; + font-weight: bold; + } + + .word-translation { + font-size: 1.3em; + color: #333; + margin-bottom: 10px; + } + + .word-pronunciation { + font-size: 1.1em; + color: #666; + font-style: italic; + margin-bottom: 10px; + } + + .word-type { + font-size: 0.9em; + color: #2d3748; + background: rgba(66, 153, 225, 0.1); + padding: 4px 12px; + border-radius: 15px; + display: inline-block; + margin-bottom: 15px; + font-weight: 500; + } + + .word-example { + font-size: 1em; + color: #555; + font-style: italic; + padding: 10px 15px; + background: rgba(0, 0, 0, 0.05); + border-left: 3px solid #4299e1; + border-radius: 0 8px 8px 0; + margin-bottom: 15px; + } + + /* Practice Challenge Styles */ + .practice-challenge { + text-align: center; + margin-bottom: 30px; + } + + .challenge-text { + font-size: 1.8em; + color: white; + margin-bottom: 25px; + text-shadow: 0 2px 4px rgba(0,0,0,0.3); + } + + .practice-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; + max-width: 800px; + margin: 0 auto; + } + + .practice-option { + background: rgba(255,255,255,0.9); + border: none; + border-radius: 15px; + padding: 20px; + font-size: 1.2em; + cursor: pointer; + transition: all 0.3s ease; + color: #333; + font-weight: 500; + } + + .practice-option:hover:not(.correct):not(.incorrect) { + background: rgba(255,255,255,1); + transform: translateY(-3px); + box-shadow: 0 8px 20px rgba(0,0,0,0.2); + } + + .practice-option.correct { + background: #4CAF50; + color: white; + animation: correctPulse 0.6s ease; + } + + .practice-option.incorrect { + background: #F44336; + color: white; + animation: incorrectShake 0.6s ease; + } + + @keyframes correctPulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } + } + + @keyframes incorrectShake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-5px); } + 75% { transform: translateX(5px); } + } + + .practice-stats { + display: flex; + justify-content: space-around; + margin-top: 20px; + color: white; + font-size: 1.1em; + gap: 10px; + } + + .stat-item { + text-align: center; + padding: 10px; + background: rgba(255,255,255,0.1); + border-radius: 10px; + backdrop-filter: blur(5px); + flex: 1; + } + + /* Control Buttons */ + .discovery-btn { + background: linear-gradient(45deg, #4299e1, #3182ce); + color: white; + border: none; + padding: 15px 30px; + border-radius: 25px; + font-size: 1.1em; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; + margin: 0 5px; + } + + .discovery-btn:hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(0,0,0,0.3); + } + + .discovery-btn:active { + transform: translateY(0); + } + + .audio-btn { + background: none; + border: none; + font-size: 2em; + cursor: pointer; + color: #2d3748; + transition: all 0.3s ease; + padding: 10px; + border-radius: 50%; + } + + .audio-btn:hover { + transform: scale(1.2); + color: #3182ce; + background: rgba(255,255,255,0.1); + } + + /* Completion Message */ + .completion-message { + text-align: center; + padding: 40px; + background: rgba(255,255,255,0.1); + border-radius: 20px; + backdrop-filter: blur(10px); + color: white; + } + + .completion-title { + font-size: 2.5em; + margin-bottom: 20px; + color: #00ff88; + text-shadow: 0 2px 10px rgba(0,255,136,0.3); + } + + .completion-stats { + font-size: 1.3em; + margin-bottom: 30px; + line-height: 1.6; + } + + .exit-btn { + background: rgba(255,255,255,0.2); + color: white; + border: 1px solid rgba(255,255,255,0.3); + padding: 10px 20px; + border-radius: 15px; + font-size: 1em; + cursor: pointer; + transition: all 0.3s ease; + } + + .exit-btn:hover { + background: rgba(255,255,255,0.3); + } + + /* Responsive Design */ + @media (max-width: 768px) { + .letter-discovery-wrapper { + padding: 15px; + } + + .letter-display { + font-size: 5em; + } + + .word-text { + font-size: 2em; + } + + .challenge-text { + font-size: 1.4em; + } + + .practice-grid { + grid-template-columns: 1fr; + } + + .letter-controls { + flex-direction: column; + align-items: center; + } + + .discovery-btn { + margin: 5px 0; + width: 100%; + max-width: 250px; + } + + .practice-stats { + flex-direction: column; + } + } + `; + document.head.appendChild(styleSheet); + } + + _removeInjectedCSS() { + const styleSheet = document.getElementById('letter-discovery-styles'); + if (styleSheet) { + styleSheet.remove(); + } + } + + _createGameInterface() { + this._config.container.innerHTML = ` +
+
+
+
Score: ${this._score}
+
Lives: ${this._lives}
+
+
Letter Discovery
+
+
Progress: 0/${this._letters.length}
+ +
+
+
+
+ +
+
+
+ `; + } + + _setupEventListeners() { + // Exit button + document.getElementById('exit-btn').addEventListener('click', () => { + this._eventBus.emit('game:exit-request', { instanceId: this.name }, this.name); + }); + } + + _updateHUD() { + const scoreDisplay = document.getElementById('score-display'); + const livesDisplay = document.getElementById('lives-display'); + const progressDisplay = document.getElementById('progress-display'); + const phaseIndicator = document.getElementById('phase-indicator'); + + if (scoreDisplay) scoreDisplay.textContent = this._score; + if (livesDisplay) livesDisplay.textContent = this._lives; + + if (this._currentPhase === 'letter-discovery') { + if (progressDisplay) progressDisplay.textContent = `${this._currentLetterIndex}/${this._letters.length}`; + if (phaseIndicator) phaseIndicator.textContent = 'Letter Discovery'; + } else if (this._currentPhase === 'word-exploration') { + const words = this._letterWords[this._currentLetter] || []; + if (progressDisplay) progressDisplay.textContent = `${this._currentWordIndex}/${words.length}`; + if (phaseIndicator) phaseIndicator.textContent = `Exploring Letter "${this._currentLetter}"`; + } else if (this._currentPhase === 'practice') { + if (progressDisplay) progressDisplay.textContent = `Round ${this._practiceRound + 1}/${this._config.maxPracticeRounds}`; + if (phaseIndicator) phaseIndicator.textContent = `Practice - Level ${this._practiceLevel}`; + } + } + + _showLetterCard() { + if (this._currentLetterIndex >= this._letters.length) { + this._showCompletion(); + return; + } + + const letter = this._letters[this._currentLetterIndex]; + const gameContent = document.getElementById('game-content'); + + gameContent.innerHTML = ` +
+
${letter}
+
Letter "${letter}"
+
[${this._getLetterPronunciation(letter)}]
+
+ + +
+
+ `; + + // Set up button listeners + document.getElementById('discover-letter-btn').addEventListener('click', () => { + this._discoverLetter(); + }); + + document.getElementById('play-letter-btn').addEventListener('click', () => { + this._playLetterSound(letter); + }); + + this._updateHUD(); + + // Auto-play letter sound if enabled + if (this._config.autoPlayTTS) { + setTimeout(() => this._playLetterSound(letter), 500); + } + } + + _getLetterPronunciation(letter) { + const pronunciations = { + 'A': 'ay', 'B': 'bee', 'C': 'see', 'D': 'dee', 'E': 'ee', + 'F': 'ef', 'G': 'gee', 'H': 'aych', 'I': 'eye', 'J': 'jay', + 'K': 'kay', 'L': 'el', 'M': 'em', 'N': 'en', 'O': 'oh', + 'P': 'pee', 'Q': 'cue', 'R': 'ar', 'S': 'ess', 'T': 'tee', + 'U': 'you', 'V': 'vee', 'W': 'double-you', 'X': 'ex', 'Y': 'why', 'Z': 'zee' + }; + return pronunciations[letter] || letter.toLowerCase(); + } + + _playLetterSound(letter) { + this._speakText(letter, { rate: this._config.ttsSpeed * 0.8 }); // Slower for letters + } + + _discoverLetter() { + const letter = this._letters[this._currentLetterIndex]; + this._discoveredLetters.push(letter); + this._score += 10; + + // Emit score update event + this._eventBus.emit('game:score-update', { + gameId: 'letter-discovery', + instanceId: this.name, + score: this._score + }, this.name); + + // Start word exploration for this letter + this._currentLetter = letter; + this._currentPhase = 'word-exploration'; + this._currentWordIndex = 0; + + this._showWordExploration(); + } + + _showWordExploration() { + const words = this._letterWords[this._currentLetter]; + + if (!words || this._currentWordIndex >= words.length) { + // Finished exploring words for this letter + this._currentPhase = 'letter-discovery'; + this._currentLetterIndex++; + this._showLetterCard(); + return; + } + + const word = words[this._currentWordIndex]; + const gameContent = document.getElementById('game-content'); + + gameContent.innerHTML = ` +
+
Letter "${this._currentLetter}"
+
Word ${this._currentWordIndex + 1} of ${words.length}
+
+
+
${word.word}
+
${word.translation}
+ ${word.pronunciation ? `
[${word.pronunciation}]
` : ''} + ${word.type ? `
${word.type}
` : ''} + ${word.example ? `
"${word.example}"
` : ''} +
+ + +
+
+ `; + + // Set up button listeners + document.getElementById('next-word-btn').addEventListener('click', () => { + this._nextWord(); + }); + + document.getElementById('play-word-btn').addEventListener('click', () => { + this._playWordSound(word.word); + }); + + // Add word to discovered list + this._discoveredWords.push(word); + + this._updateHUD(); + + // Auto-play word sound if enabled + if (this._config.autoPlayTTS) { + setTimeout(() => this._playWordSound(word.word), 500); + } + } + + _playWordSound(word) { + this._speakText(word, { rate: this._config.ttsSpeed }); + } + + _nextWord() { + this._currentWordIndex++; + this._score += 5; + + // Emit score update event + this._eventBus.emit('game:score-update', { + gameId: 'letter-discovery', + instanceId: this.name, + score: this._score + }, this.name); + + this._showWordExploration(); + } + + _showCompletion() { + const gameContent = document.getElementById('game-content'); + const totalWords = Object.values(this._letterWords).reduce((sum, words) => sum + words.length, 0); + + gameContent.innerHTML = ` +
+
🎉 All Letters Discovered!
+
+ Letters Discovered: ${this._discoveredLetters.length}
+ Words Learned: ${this._discoveredWords.length}
+ Final Score: ${this._score} +
+
+ + +
+
+ `; + + // Set up button listeners + document.getElementById('start-practice-btn').addEventListener('click', () => { + this._startPractice(); + }); + + document.getElementById('restart-btn').addEventListener('click', () => { + this._restart(); + }); + + this._updateHUD(); + } + + _startPractice() { + this._currentPhase = 'practice'; + this._practiceLevel = 1; + this._practiceRound = 0; + this._practiceCorrectAnswers = 0; + this._practiceErrors = 0; + + // Create shuffled practice items from all discovered words + this._currentPracticeItems = this._shuffleArray([...this._discoveredWords]); + + this._showPracticeChallenge(); + } + + _showPracticeChallenge() { + if (this._practiceRound >= this._config.maxPracticeRounds) { + this._endPractice(); + return; + } + + const currentItem = this._currentPracticeItems[this._practiceRound % this._currentPracticeItems.length]; + const gameContent = document.getElementById('game-content'); + + // Generate options (correct + 3 random) + const allWords = this._discoveredWords.filter(w => w.word !== currentItem.word); + const randomOptions = this._shuffleArray([...allWords]).slice(0, 3); + const options = this._shuffleArray([currentItem, ...randomOptions]); + + gameContent.innerHTML = ` +
+
What does "${currentItem.word}" mean?
+
+ ${options.map((option, index) => ` + + `).join('')} +
+
+
Correct: ${this._practiceCorrectAnswers}
+
Errors: ${this._practiceErrors}
+
Round: ${this._practiceRound + 1}/${this._config.maxPracticeRounds}
+
+
+ `; + + // Set up option listeners + document.querySelectorAll('.practice-option').forEach(button => { + button.addEventListener('click', (e) => { + const selectedIndex = parseInt(e.target.dataset.optionIndex); + const selectedWord = e.target.dataset.word; + this._selectPracticeAnswer(selectedIndex, selectedWord); + }); + }); + + // Store correct answer for checking + this._currentCorrectAnswer = currentItem.word; + + this._updateHUD(); + + // Auto-play word if enabled + if (this._config.autoPlayTTS) { + setTimeout(() => this._playWordSound(currentItem.word), 500); + } + } + + _selectPracticeAnswer(selectedIndex, selectedWord) { + const buttons = document.querySelectorAll('.practice-option'); + const isCorrect = selectedWord === this._currentCorrectAnswer; + + // Disable all buttons to prevent multiple clicks + buttons.forEach(btn => btn.disabled = true); + + if (isCorrect) { + buttons[selectedIndex].classList.add('correct'); + this._practiceCorrectAnswers++; + this._score += 10; + + // Emit score update event + this._eventBus.emit('game:score-update', { + gameId: 'letter-discovery', + instanceId: this.name, + score: this._score + }, this.name); + } else { + buttons[selectedIndex].classList.add('incorrect'); + this._practiceErrors++; + + // Show correct answer + buttons.forEach((btn) => { + if (btn.dataset.word === this._currentCorrectAnswer) { + btn.classList.add('correct'); + } + }); + } + + setTimeout(() => { + this._practiceRound++; + this._showPracticeChallenge(); + }, 1500); + } + + _endPractice() { + const accuracy = Math.round((this._practiceCorrectAnswers / this._config.maxPracticeRounds) * 100); + + // Store best score + const gameKey = 'letter-discovery'; + const currentScore = this._score; + const bestScore = parseInt(localStorage.getItem(`${gameKey}-best-score`) || '0'); + const isNewBest = currentScore > bestScore; + + if (isNewBest) { + localStorage.setItem(`${gameKey}-best-score`, currentScore.toString()); + } + + // Show victory popup + this._showVictoryPopup({ + gameTitle: 'Letter Discovery', + currentScore, + bestScore: isNewBest ? currentScore : bestScore, + isNewBest, + stats: { + 'Letters Found': this._discoveredLetters.length, + 'Words Learned': this._discoveredWords.length, + 'Practice Accuracy': `${accuracy}%`, + 'Correct Answers': `${this._practiceCorrectAnswers}/${this._config.maxPracticeRounds}` + } + }); + + // Emit game completion event + this._eventBus.emit('game:completed', { + gameId: 'letter-discovery', + instanceId: this.name, + score: this._score, + lettersDiscovered: this._discoveredLetters.length, + wordsLearned: this._discoveredWords.length, + practiceAccuracy: accuracy + }, this.name); + + this._updateHUD(); + } + + _restart() { + // Reset all game state + this._currentPhase = 'letter-discovery'; + this._currentLetterIndex = 0; + this._discoveredLetters = []; + this._currentLetter = null; + this._currentWordIndex = 0; + this._discoveredWords = []; + this._score = 0; + this._lives = 3; + this._practiceLevel = 1; + this._practiceRound = 0; + this._practiceCorrectAnswers = 0; + this._practiceErrors = 0; + this._currentPracticeItems = []; + this._currentCorrectAnswer = null; + + this._showLetterCard(); + } + + _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; + } + + _speakText(text, options = {}) { + if (!text) return; + + try { + if ('speechSynthesis' in window) { + speechSynthesis.cancel(); + + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = this._getContentLanguage(); + utterance.rate = options.rate || this._config.ttsSpeed; + utterance.volume = 1.0; + + speechSynthesis.speak(utterance); + } + } catch (error) { + console.warn('TTS error:', error); + } + } + + _getContentLanguage() { + if (this._content.language) { + const langMap = { + 'chinese': 'zh-CN', + 'english': 'en-US', + 'french': 'fr-FR', + 'spanish': 'es-ES' + }; + return langMap[this._content.language] || this._content.language; + } + return 'en-US'; + } + + _showError(message) { + if (this._config.container) { + this._config.container.innerHTML = ` +
+
+
+

Letter Discovery Error

+

${message}

+
+ +
+
+
+ `; + } + } + + _handlePause() { + this._eventBus.emit('game:paused', { instanceId: this.name }, this.name); + } + + _handleResume() { + this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name); + } + + _showVictoryPopup({ gameTitle, currentScore, bestScore, isNewBest, stats }) { + const popup = document.createElement('div'); + popup.className = 'victory-popup'; + popup.innerHTML = ` +
+
+
🔤
+

${gameTitle} Complete!

+ ${isNewBest ? '
🎉 New Best Score!
' : ''} +
+ +
+
+
Your Score
+
${currentScore}
+
+
+
Best Score
+
${bestScore}
+
+
+ +
+ ${Object.entries(stats).map(([key, value]) => ` +
+
${key}
+
${value}
+
+ `).join('')} +
+ +
+ + + +
+
+ `; + + document.body.appendChild(popup); + + // Animate in + requestAnimationFrame(() => { + popup.classList.add('show'); + }); + + // Add event listeners + popup.querySelector('#play-again-btn').addEventListener('click', () => { + popup.remove(); + this._restart(); + }); + + popup.querySelector('#different-game-btn').addEventListener('click', () => { + popup.remove(); + if (window.app && window.app.getCore().router) { + window.app.getCore().router.navigate('/games'); + // Force content reload by re-emitting navigation event + setTimeout(() => { + const chapterId = window.currentChapterId || 'sbs'; + this._eventBus.emit('navigation:games', { + path: `/games/${chapterId}`, + data: { path: `/games/${chapterId}` } + }, 'Application'); + }, 100); + } else { + window.location.href = '/#/games'; + } + }); + + popup.querySelector('#main-menu-btn').addEventListener('click', () => { + popup.remove(); + if (window.app && window.app.getCore().router) { + window.app.getCore().router.navigate('/'); + } else { + window.location.href = '/'; + } + }); + + // Close on backdrop click + popup.addEventListener('click', (e) => { + if (e.target === popup) { + popup.remove(); + if (window.app && window.app.getCore().router) { + window.app.getCore().router.navigate('/games'); + // Force content reload by re-emitting navigation event + setTimeout(() => { + const chapterId = window.currentChapterId || 'sbs'; + this._eventBus.emit('navigation:games', { + path: `/games/${chapterId}`, + data: { path: `/games/${chapterId}` } + }, 'Application'); + }, 100); + } else { + window.location.href = '/#/games'; + } + } + }); + } + + // Helper method to convert vocabulary to letters format + static _createLettersFromVocabulary(vocabulary) { + const letters = {}; + + Object.entries(vocabulary).forEach(([word, data]) => { + const firstLetter = word.charAt(0).toUpperCase(); + if (!letters[firstLetter]) { + letters[firstLetter] = []; + } + + letters[firstLetter].push({ + word: word, + translation: data.user_language || data.translation || data, + type: data.type || 'word', + pronunciation: data.pronunciation + }); + }); + + return letters; + } +} + +export default LetterDiscovery; \ No newline at end of file diff --git a/src/games/MarioEducational.js b/src/games/MarioEducational.js new file mode 100644 index 0000000..ba69cc5 --- /dev/null +++ b/src/games/MarioEducational.js @@ -0,0 +1,3902 @@ +import Module from '../core/Module.js'; + +/** + * MarioEducational - 2D Mario-style educational game with question blocks + * Classic side-scrolling platformer that integrates vocabulary learning through interactive question blocks + */ +class MarioEducational extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); + + // Validate dependencies + if (!dependencies.eventBus || !dependencies.content) { + throw new Error('MarioEducational requires eventBus and content dependencies'); + } + + this._eventBus = dependencies.eventBus; + this._content = dependencies.content; + this._config = { + container: null, + canvasWidth: 1200, + canvasHeight: 690, // +15% viewport height (600 * 1.15 = 690) + gravity: 0.8, + jumpForce: -16, + moveSpeed: 5, + maxLevels: 7, + ...config + }; + + // Game state + this._canvas = null; + this._ctx = null; + this._gameLoop = null; + this._keys = {}; + this._isGameOver = false; + this._isPaused = false; + this._score = 0; + this._questionsAnswered = 0; + this._currentLevel = 1; + this._gameStartTime = null; + + // Mario character + this._mario = { + x: 100, + y: 400, + width: 32, + height: 32, + velocityX: 0, + velocityY: 0, + onGround: false, + facing: 'right', + color: '#FF6B6B' // Red Mario + }; + + // Game world + this._camera = { x: 0, y: 0 }; + this._platforms = []; + this._questionBlocks = []; + this._enemies = []; + this._collectibles = []; + this._particles = []; + this._walls = []; + this._castleStructure = null; + + // Advanced level elements + this._piranhaPlants = []; + this._projectiles = []; + + // Level 4+ catapult system + this._catapults = []; + this._boulders = []; + this._stones = []; // Small stones from onager + + // Level 5+ flying eyes system + this._flyingEyes = []; + + // Level 6 boss system + this._boss = null; + this._bossTurrets = []; + this._bossMinions = []; + this._powerUps = []; + this._isBoostMode = false; + this._boostTimer = 0; + this._bossCollisionCooldown = 0; // Prevent bounce loop + + // Level generation + this._levelData = []; + this._currentLevelIndex = 0; + this._levelWidth = 3000; + this._sentences = []; + this._usedSentences = []; + + // Question system + this._questionDialog = null; + this._isQuestionActive = false; + + // Celebration mode - blocks enemies but allows particles + this._isCelebrating = false; + + // Level completion flag to prevent multiple triggers + this._levelCompleted = false; + + // Input handlers (need to be declared before seal) + this._handleKeyDown = null; + this._handleKeyUp = null; + + // UI elements (need to be declared before seal) + this._uiOverlay = null; + + // Sound system + this._audioContext = null; + this._sounds = {}; + + Object.seal(this); + } + + /** + * Get game metadata + * @returns {Object} Game metadata + */ + static getMetadata() { + return { + name: 'Mario Educational Adventure', + description: '2D Mario-style platformer with educational question blocks and vocabulary challenges', + difficulty: 'intermediate', + category: 'platformer', + estimatedTime: 15, // minutes + skills: ['reading', 'vocabulary', 'comprehension', 'reflexes', 'problem-solving'] + }; + } + + /** + * Calculate compatibility score with content + * @param {Object} content - Content to check compatibility with + * @returns {Object} Compatibility score and details + */ + static getCompatibilityScore(content) { + const sentences = content?.sentences || []; + const vocab = content?.vocabulary || {}; + const texts = content?.texts || []; + const story = content?.story || ''; + + const vocabCount = Object.keys(vocab).length; + const sentenceCount = sentences.length; + + // Count sentences from texts and story + let extraSentenceCount = 0; + + if (story && typeof story === 'string') { + extraSentenceCount += story.split(/[.!?]+/).filter(s => s.trim().length > 0).length; + } + + if (Array.isArray(texts)) { + texts.forEach(text => { + if (typeof text === 'string') { + extraSentenceCount += text.split(/[.!?]+/).filter(s => s.trim().length > 0).length; + } else if (text.content) { + extraSentenceCount += text.content.split(/[.!?]+/).filter(s => s.trim().length > 0).length; + } + }); + } + + const totalSentences = sentenceCount + extraSentenceCount; + + if (totalSentences < 3 && vocabCount < 10) { + 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' + }; + } + + 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); + + return { + score, + reason: `${totalSentences} sentences and ${vocabCount} vocabulary words available`, + requirements: ['sentences', 'vocabulary', 'texts', 'story'], + minSentences: 3, + optimalSentences: 30, + details: `Can create ${Math.min(totalSentences + vocabCount, 50)} question blocks from all content sources` + }; + } + + async init() { + this._validateNotDestroyed(); + + try { + // Validate container + if (!this._config.container) { + throw new Error('Game container is required'); + } + + // Extract sentences from content + this._extractSentences(); + if (this._sentences.length < 5) { + throw new Error('Insufficient content for Mario Educational game'); + } + + // Setup canvas + this._setupCanvas(); + + // Setup input handlers + this._setupInputHandlers(); + + // Initialize sound system + this._initializeSoundSystem(); + + // Generate all levels + this._generateAllLevels(); + + // Start first level + this._startLevel(5); // Start at level 6 (index 5) to continue boss work + + // Setup game UI + this._setupGameUI(); + + // Start game loop + this._startGameLoop(); + + this._gameStartTime = Date.now(); + console.log('🎮 Mario Educational game initialized successfully'); + + this._setInitialized(); + + } catch (error) { + console.error('❌ Error initializing Mario Educational game:', error); + throw error; + } + } + + async destroy() { + this._validateNotDestroyed(); + + // Stop game loop + if (this._gameLoop) { + cancelAnimationFrame(this._gameLoop); + this._gameLoop = null; + } + + // Remove event listeners + document.removeEventListener('keydown', this._handleKeyDown); + document.removeEventListener('keyup', this._handleKeyUp); + + // Clear canvas + if (this._canvas && this._canvas.parentNode) { + this._canvas.parentNode.removeChild(this._canvas); + } + + // Clear game state + this._platforms = []; + this._questionBlocks = []; + this._enemies = []; + this._collectibles = []; + this._particles = []; + + console.log('🎮 Mario Educational game destroyed'); + this._setDestroyed(); + } + + // Private methods + _extractSentences() { + const sentences = this._content.sentences || []; + const vocab = this._content.vocabulary || {}; + const texts = this._content.texts || []; + const story = this._content.story || ''; + + // Combine sentences and vocabulary for questions + this._sentences = []; + + // Add actual sentences - handle both formats + sentences.forEach(sentence => { + // Format 1: Modern format with english/user_language + if (sentence.english && sentence.user_language) { + this._sentences.push({ + type: 'sentence', + english: sentence.english, + translation: sentence.user_language, + context: sentence.context || '' + }); + } + // Format 2: Current format with text field + else if (sentence.text) { + this._sentences.push({ + type: 'sentence', + english: sentence.text, + translation: sentence.translation || 'Read this sentence carefully', + context: `Difficulty: ${sentence.difficulty || 'intermediate'}` + }); + } + }); + + // Extract sentences from story text + if (story && typeof story === 'string') { + const storySentences = this._splitTextIntoSentences(story); + storySentences.forEach(sentence => { + this._sentences.push({ + type: 'story', + english: sentence, + translation: 'Story sentence - read carefully', + context: 'Story' + }); + }); + } + + // Extract sentences from texts array + if (Array.isArray(texts)) { + texts.forEach((text, index) => { + if (typeof text === 'string') { + const textSentences = this._splitTextIntoSentences(text); + textSentences.forEach(sentence => { + this._sentences.push({ + type: 'text', + english: sentence, + translation: `Text passage ${index + 1} - practice reading`, + context: `Text ${index + 1}` + }); + }); + } else if (text.content) { + const textSentences = this._splitTextIntoSentences(text.content); + textSentences.forEach(sentence => { + this._sentences.push({ + type: 'text', + english: sentence, + translation: text.translation || `Read this text sentence`, + context: text.title || `Text ${index + 1}` + }); + }); + } + }); + } + + // Add vocabulary as contextual sentences + Object.entries(vocab).forEach(([word, data]) => { + if (data.user_language) { + const generatedSentence = this._generateSentenceFromWord(word, data); + this._sentences.push({ + type: 'vocabulary', + english: generatedSentence.english, + translation: generatedSentence.translation, + context: data.type || 'vocabulary' + }); + } + }); + + // Shuffle sentences for variety + this._sentences = this._shuffleArray(this._sentences); + + console.log(`📝 Extracted ${this._sentences.length} sentences/vocabulary for questions`); + } + + /** + * Generate contextual sentences from vocabulary words + * @param {string} word - The vocabulary word + * @param {Object} data - Word data including type and translation + * @returns {Object} Generated sentence with English and translation + */ + /** + * Split long text into individual sentences + * @param {string} text - Long text to split + * @returns {Array} Array of sentences + */ + _splitTextIntoSentences(text) { + if (!text || typeof text !== 'string') return []; + + // Clean the text + const cleanText = text.trim(); + + // Split by sentence-ending punctuation + const sentences = cleanText + .split(/[.!?]+/) + .map(s => s.trim()) + .filter(s => s.length > 0) + .map(s => { + // Add period if sentence doesn't end with punctuation + if (!s.match(/[.!?]$/)) { + s += '.'; + } + // Capitalize first letter + return s.charAt(0).toUpperCase() + s.slice(1); + }); + + // Filter out very short sentences (less than 3 words) + return sentences.filter(sentence => sentence.split(' ').length >= 3); + } + + _generateSentenceFromWord(word, data) { + const type = data.type || 'noun'; + const translation = data.user_language.split(';')[0]; // Use first translation + + // Simple sentence templates based on word type + const templates = { + 'noun': [ + `This is a ${word}.`, + `I see a ${word}.`, + `The ${word} is here.`, + `Where is the ${word}?`, + `I need a ${word}.` + ], + 'adjective': [ + `The house is ${word}.`, + `This looks ${word}.`, + `It seems ${word}.`, + `How ${word} it is!`, + `The weather is ${word}.` + ], + 'verb': [ + `I ${word} every day.`, + `Please ${word} this.`, + `Don't ${word} too fast.`, + `Can you ${word}?`, + `Let's ${word} together.` + ], + 'adverb': [ + `He walks ${word}.`, + `She speaks ${word}.`, + `They work ${word}.`, + `Do it ${word}.`, + `Move ${word}.` + ], + 'preposition': [ + `The book is ${word} the table.`, + `Walk ${word} the street.`, + `It's ${word} the house.`, + `Look ${word} the window.`, + `Go ${word} the door.` + ] + }; + + // Get templates for this word type, fallback to noun if type unknown + const typeTemplates = templates[type] || templates['noun']; + const randomTemplate = typeTemplates[Math.floor(Math.random() * typeTemplates.length)]; + + return { + english: randomTemplate, + translation: `${translation} - ${randomTemplate.replace(word, `**${word}**`)}` + }; + } + + _setupCanvas() { + this._canvas = document.createElement('canvas'); + this._canvas.width = this._config.canvasWidth; + this._canvas.height = this._config.canvasHeight; + this._canvas.style.border = '2px solid #333'; + this._canvas.style.borderRadius = '8px'; + this._canvas.style.background = 'linear-gradient(to bottom, #87CEEB, #98FB98)'; + + this._ctx = this._canvas.getContext('2d'); + this._ctx.imageSmoothingEnabled = false; // Pixel art style + + this._config.container.appendChild(this._canvas); + } + + _setupInputHandlers() { + this._handleKeyDown = (e) => { + this._keys[e.code] = true; + + // Prevent default for game controls + if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'Space'].includes(e.code)) { + e.preventDefault(); + } + }; + + this._handleKeyUp = (e) => { + this._keys[e.code] = false; + }; + + document.addEventListener('keydown', this._handleKeyDown); + document.addEventListener('keyup', this._handleKeyUp); + } + + _initializeSoundSystem() { + try { + // Initialize Web Audio Context + this._audioContext = new (window.AudioContext || window.webkitAudioContext)(); + console.log('🔊 Sound system initialized'); + + // Create sound library + this._createSoundLibrary(); + } catch (error) { + console.warn('⚠️ Sound system not available:', error); + this._audioContext = null; + } + } + + _createSoundLibrary() { + // Sound definitions with parameters for programmatic generation + this._sounds = { + jump: { type: 'sweep', frequency: 330, endFrequency: 600, duration: 0.1 }, + coin: { type: 'bell', frequency: 800, duration: 0.3 }, + powerup: { type: 'arpeggio', frequencies: [264, 330, 396, 528], duration: 0.6 }, + enemy_defeat: { type: 'noise_sweep', frequency: 200, endFrequency: 50, duration: 0.2 }, + question_block: { type: 'sparkle', frequency: 600, endFrequency: 1200, duration: 0.4 }, + level_complete: { type: 'victory', frequencies: [523, 659, 784, 1047], duration: 1.0 }, + death: { type: 'descend', frequency: 300, endFrequency: 100, duration: 0.8 }, + finish_stars: { type: 'magical', frequencies: [880, 1100, 1320, 1760], duration: 2.0 } + }; + + console.log('🎵 Sound library created with', Object.keys(this._sounds).length, 'sounds'); + } + + _playSound(soundName, volume = 0.3) { + if (!this._audioContext || !this._sounds[soundName]) { + return; + } + + try { + const sound = this._sounds[soundName]; + const oscillator = this._audioContext.createOscillator(); + const gainNode = this._audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(this._audioContext.destination); + + const currentTime = this._audioContext.currentTime; + const duration = sound.duration; + + // Set volume + gainNode.gain.setValueAtTime(0, currentTime); + gainNode.gain.linearRampToValueAtTime(volume, currentTime + 0.01); + gainNode.gain.exponentialRampToValueAtTime(0.01, currentTime + duration); + + // Configure sound based on type + switch (sound.type) { + case 'sweep': + oscillator.type = 'square'; + oscillator.frequency.setValueAtTime(sound.frequency, currentTime); + oscillator.frequency.linearRampToValueAtTime(sound.endFrequency, currentTime + duration); + break; + + case 'bell': + oscillator.type = 'sine'; + oscillator.frequency.setValueAtTime(sound.frequency, currentTime); + oscillator.frequency.exponentialRampToValueAtTime(sound.frequency * 0.5, currentTime + duration); + break; + + case 'noise_sweep': + oscillator.type = 'sawtooth'; + oscillator.frequency.setValueAtTime(sound.frequency, currentTime); + oscillator.frequency.linearRampToValueAtTime(sound.endFrequency, currentTime + duration); + break; + + case 'sparkle': + oscillator.type = 'triangle'; + oscillator.frequency.setValueAtTime(sound.frequency, currentTime); + oscillator.frequency.linearRampToValueAtTime(sound.endFrequency, currentTime + duration * 0.7); + oscillator.frequency.linearRampToValueAtTime(sound.frequency, currentTime + duration); + break; + + case 'descend': + oscillator.type = 'square'; + oscillator.frequency.setValueAtTime(sound.frequency, currentTime); + oscillator.frequency.exponentialRampToValueAtTime(sound.endFrequency, currentTime + duration); + break; + + case 'arpeggio': + case 'victory': + case 'magical': + // For complex sounds, play the first frequency and schedule others + oscillator.type = sound.type === 'magical' ? 'triangle' : 'square'; + oscillator.frequency.setValueAtTime(sound.frequencies[0], currentTime); + + // Schedule frequency changes for arpeggio effect + const noteLength = duration / sound.frequencies.length; + sound.frequencies.forEach((freq, index) => { + if (index > 0) { + oscillator.frequency.setValueAtTime(freq, currentTime + noteLength * index); + } + }); + break; + + default: + oscillator.type = 'square'; + oscillator.frequency.setValueAtTime(sound.frequency || 440, currentTime); + } + + oscillator.start(currentTime); + oscillator.stop(currentTime + duration); + + console.log(`🎵 Playing sound: ${soundName}`); + + } catch (error) { + console.warn('⚠️ Failed to play sound:', soundName, error); + } + } + + _generateAllLevels() { + this._levelData = []; + + for (let i = 0; i < this._config.maxLevels; i++) { + const isLastLevel = i === this._config.maxLevels - 1; + const level = this._generateLevel(i, isLastLevel); + this._levelData.push(level); + } + + console.log(`🏗️ Generated ${this._levelData.length} levels`); + } + + _generateLevel(levelIndex, isCastleLevel = false) { + const level = { + index: levelIndex, + isCastleLevel, + platforms: [], + questionBlocks: [], + enemies: [], + collectibles: [], + walls: [], + startX: 100, + startY: 400, + endX: this._levelWidth - 200, + width: this._levelWidth + }; + + if (isCastleLevel) { + return this._generateCastleLevel(level); + } else { + return this._generateRegularLevel(level); + } + } + + _generateRegularLevel(level) { + const { index } = level; + const difficulty = Math.min(index + 1, 5); // Difficulty scales 1-5 + + // Generate ground platforms + for (let x = 0; x < this._levelWidth; x += 200) { + level.platforms.push({ + x: x, + y: this._config.canvasHeight - 50, + width: 200, + height: 50, + type: 'ground', + color: '#8B4513' + }); + } + + // Generate floating platforms with intelligent placement + this._generateIntelligentPlatforms(level, difficulty); + + // Generate question blocks on reachable platforms + const questionCount = 3 + difficulty; + const availablePlatforms = level.platforms.filter(p => p.y < this._config.canvasHeight - 100); + + for (let i = 0; i < questionCount; i++) { + const maxAttempts = 5; + let attemptCount = 0; + let blockPlaced = false; + + while (attemptCount < maxAttempts && !blockPlaced) { + attemptCount++; + + const platform = availablePlatforms[Math.floor(Math.random() * availablePlatforms.length)]; + const sentence = this._getRandomSentence(); + + if (sentence && platform) { + const blockX = platform.x + platform.width / 2 - 16; + const blockY = platform.y - 32; + const blockWidth = 32; + const blockHeight = 32; + + // Check minimum distance from other question blocks + const hasMinDistance = this._checkQuestionBlockDistance( + level.questionBlocks, blockX, blockY, blockWidth, blockHeight, 60 + ); + + if (hasMinDistance) { + level.questionBlocks.push({ + x: blockX, + y: blockY, + width: blockWidth, + height: blockHeight, + sentence: sentence, + hit: false, + color: '#FFD700', + symbol: '?' + }); + + console.log(`💰 Question block ${i}: x=${blockX.toFixed(0)}, y=${blockY.toFixed(0)}, attempts=${attemptCount}`); + blockPlaced = true; + } else { + console.log(`❌ Question block ${i} attempt ${attemptCount} failed: too close to other blocks`); + } + } + } + + if (!blockPlaced) { + console.log(`⚠️ Failed to place question block ${i} after ${maxAttempts} attempts`); + } + } + + // Generate simple enemies (avoid walls) + let helmetEnemyPlaced = false; + + for (let i = 0; i < difficulty; i++) { + let enemyPlaced = false; + let attempts = 0; + const maxAttempts = 20; + + while (!enemyPlaced && attempts < maxAttempts) { + const platform = level.platforms[Math.floor(Math.random() * level.platforms.length)]; + const enemyX = platform.x + Math.random() * (platform.width - 20); + const enemyY = platform.y - 20; + + // Check if enemy would spawn inside a wall + const wouldOverlapWall = level.walls.some(wall => { + return enemyX < wall.x + wall.width && + enemyX + 20 > wall.x && + enemyY < wall.y + wall.height && + enemyY + 20 > wall.y; + }); + + // Check if enemy would spawn over a hole (more thorough check) + const wouldOverlapHole = level.holes && level.holes.some(hole => { + 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 + + if (!wouldOverlapWall && !wouldOverlapHole && hasSolidSupport) { + // Level 2+ gets exactly ONE helmet enemy per level + const isHelmetEnemy = index >= 1 && !helmetEnemyPlaced && i === 0; // Only first enemy can be helmet + + if (isHelmetEnemy) { + helmetEnemyPlaced = true; + } + + level.enemies.push({ + x: enemyX, + y: enemyY, + width: 20, + height: 20, + velocityX: (Math.random() > 0.5 ? 1 : -1) * (1 + Math.random()), + color: isHelmetEnemy ? '#4169E1' : '#8B0000', // Blue for helmet, red for normal + type: isHelmetEnemy ? 'koopa' : 'goomba', + hasHelmet: isHelmetEnemy + }); + enemyPlaced = true; + } else { + console.log(`🚫 Enemy ${i} attempt ${attempts} would overlap wall/hole/unsupported ground, retrying...`); + } + attempts++; + } + + if (!enemyPlaced) { + console.log(`⚠️ Failed to place enemy ${i} after ${maxAttempts} attempts`); + } + } + + // Level 3+ gets advanced features (except level 6 boss level) + 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) + if (index === 5) { + this._generateBossStairs(level, difficulty); + // Reduced piranha plants for boss level + if (difficulty > 3) { + this._generatePiranhaPlants(level, Math.min(difficulty - 2, 2)); + } + } + + // Level 4-5 gets catapults (NOT level 6 - boss level) + if (index >= 3 && index <= 4) { + this._generateCatapults(level, difficulty); + } + + // Level 5+ gets flying eyes + if (index >= 4) { + this._generateFlyingEyes(level, difficulty); + } + + // Level 6 gets colossal boss + if (index === 5) { + this._generateColossalBoss(level, difficulty); + } + + return level; + } + + _generateIntelligentPlatforms(level, difficulty) { + // Calculate Mario's jump capabilities + const jumpForce = Math.abs(this._config.jumpForce); + const gravity = this._config.gravity; + const moveSpeed = this._config.moveSpeed; + + // Physics calculations for jump distances + const maxJumpHeight = (jumpForce * jumpForce) / (2 * gravity); // Peak height + const totalAirTime = (2 * jumpForce) / gravity; // Time in air + const maxHorizontalDistance = moveSpeed * totalAirTime; // Max horizontal distance + + // Safe margins for reachable platforms + const safeJumpHeight = maxJumpHeight * 0.85; + const safeHorizontalDistance = maxHorizontalDistance * 0.8; + + console.log(`🔧 Jump physics: height=${safeJumpHeight.toFixed(1)}, distance=${safeHorizontalDistance.toFixed(1)}`); + + const platformCount = 8 + difficulty * 2; + const groundHeight = this._config.canvasHeight - 50; + + // Define height zones for better vertical distribution + const heightZones = { + low: groundHeight - safeJumpHeight * 0.3, // Just above ground level + mid: groundHeight - safeJumpHeight * 0.6, // Medium height + high: groundHeight - safeJumpHeight * 0.9, // High platforms + veryHigh: groundHeight - safeJumpHeight // Max reachable height + }; + + // Force vertical progression - alternate between height zones + for (let i = 0; i < platformCount; i++) { + const maxAttempts = 5; + let attemptCount = 0; + let platformPlaced = false; + + while (attemptCount < maxAttempts && !platformPlaced) { + attemptCount++; + + // Find a suitable previous platform to connect from + const availablePlatforms = level.platforms.filter(p => + p.x > 100 && p.x < this._levelWidth - 400 + ); + + if (availablePlatforms.length === 0) break; + + const fromPlatform = availablePlatforms[Math.floor(Math.random() * availablePlatforms.length)]; + + // Force better height distribution + let targetY; + const progressRatio = i / platformCount; + + // Bias towards higher platforms as we progress + if (progressRatio < 0.3) { + // Early platforms: mix of low and mid + targetY = Math.random() < 0.7 ? heightZones.low : heightZones.mid; + } else if (progressRatio < 0.7) { + // Middle platforms: mix of mid and high + targetY = Math.random() < 0.6 ? heightZones.mid : heightZones.high; + } else { + // Later platforms: mix of high and very high + targetY = Math.random() < 0.5 ? heightZones.high : heightZones.veryHigh; + } + + // Add some randomness around target height + targetY += (Math.random() - 0.5) * safeJumpHeight * 0.2; + + // Calculate horizontal position within jump range + const distanceRange = { + min: safeHorizontalDistance * 0.4, + max: safeHorizontalDistance * 0.9 + }; + + const direction = Math.random() > 0.5 ? 1 : -1; + const distance = distanceRange.min + Math.random() * (distanceRange.max - distanceRange.min); + const newX = fromPlatform.x + (direction * distance); + const width = 120 + Math.random() * 80; + const height = 20; + const finalY = Math.max(50, Math.min(this._config.canvasHeight - 100, targetY)); + + // Validate all conditions + const heightDifference = Math.abs(finalY - fromPlatform.y); + const horizontalDistance = Math.abs(newX - fromPlatform.x); + + const isReachable = this._validateJumpPossible( + horizontalDistance, heightDifference, + safeHorizontalDistance, safeJumpHeight, + finalY < fromPlatform.y // jumping up or down + ); + + const minDistance = 100; // Increased minimum distance + const hasMinDistance = this._checkMinimumDistance( + level.platforms, newX, finalY, width, height, minDistance + ); + + const isInBounds = newX > 150 && newX + width < this._levelWidth - 300; + + if (isReachable && hasMinDistance && isInBounds) { + level.platforms.push({ + x: newX, + y: finalY, + width: width, + height: height, + type: 'floating', + color: this._getPlatformColor(finalY, heightZones) + }); + + console.log(`📍 Platform ${i}: x=${newX.toFixed(0)}, y=${finalY.toFixed(0)}, attempts=${attemptCount}`); + platformPlaced = true; + } else { + console.log(`❌ Platform ${i} attempt ${attemptCount} failed: reachable=${isReachable}, minDist=${hasMinDistance}, bounds=${isInBounds}`); + } + } + + if (!platformPlaced) { + console.log(`⚠️ Failed to place platform ${i} after ${maxAttempts} attempts`); + } + } + + // Add some challenge platforms at max difficulty + if (difficulty >= 3) { + this._addChallengePlatforms(level, safeHorizontalDistance, safeJumpHeight); + } + + // Generate walls as obstacles + this._generateWalls(level, difficulty, safeJumpHeight); + } + + _validateJumpPossible(horizontalDist, heightDiff, maxHorizontalDist, maxJumpHeight, isJumpingUp) { + // Check horizontal distance + if (horizontalDist > maxHorizontalDist) return false; + + // Check vertical distance + if (isJumpingUp && heightDiff > maxJumpHeight) return false; + + // For downward jumps, allow more flexibility (gravity helps) + if (!isJumpingUp && heightDiff > maxJumpHeight * 1.5) return false; + + return true; + } + + _checkMinimumDistance(existingPlatforms, newX, newY, newWidth, newHeight, minDistance) { + for (const platform of existingPlatforms) { + // Skip ground platforms for distance check, but increase minimum distance from ground + if (platform.type === 'ground') { + // Check minimum distance from ground level (increased) + if (Math.abs(newY - platform.y) < 120) { // Increased from 80 to 120 + return false; + } + continue; + } + + // Check for overlap and minimum distance + const newRight = newX + newWidth; + const newBottom = newY + newHeight; + const platformRight = platform.x + platform.width; + const platformBottom = platform.y + platform.height; + + // Calculate distances between edges + const horizontalDistance = Math.max(0, + Math.max(platform.x - newRight, newX - platformRight) + ); + const verticalDistance = Math.max(0, + Math.max(platform.y - newBottom, newY - platformBottom) + ); + + // Check if platforms overlap or are too close + if (horizontalDistance === 0 && verticalDistance === 0) { + // Overlapping + return false; + } + + // Check minimum distance + const totalDistance = Math.sqrt(horizontalDistance * horizontalDistance + verticalDistance * verticalDistance); + if (totalDistance < minDistance) { + return false; + } + } + return true; + } + + _checkQuestionBlockDistance(existingBlocks, newX, newY, newWidth, newHeight, minDistance) { + for (const block of existingBlocks) { + // Calculate distances between edges + const newRight = newX + newWidth; + const newBottom = newY + newHeight; + const blockRight = block.x + block.width; + const blockBottom = block.y + block.height; + + const horizontalDistance = Math.max(0, + Math.max(block.x - newRight, newX - blockRight) + ); + const verticalDistance = Math.max(0, + Math.max(block.y - newBottom, newY - blockBottom) + ); + + // Check if blocks overlap or are too close + if (horizontalDistance === 0 && verticalDistance === 0) { + // Overlapping + return false; + } + + // Check minimum distance + const totalDistance = Math.sqrt(horizontalDistance * horizontalDistance + verticalDistance * verticalDistance); + if (totalDistance < minDistance) { + return false; + } + } + return true; + } + + _getPlatformColor(y, heightZones) { + if (y >= heightZones.low) return '#90EE90'; // Light green (low) + if (y >= heightZones.mid) return '#32CD32'; // Green (mid) + if (y >= heightZones.high) return '#228B22'; // Forest green (high) + return '#006400'; // Dark green (very high) + } + + _generateWalls(level, difficulty, maxJumpHeight) { + const wallCount = Math.floor(difficulty * 1.5); // More walls as difficulty increases + const groundHeight = this._config.canvasHeight - 50; + + console.log(`🧱 Generating ${wallCount} walls for difficulty ${difficulty}`); + + for (let i = 0; i < wallCount; i++) { + // Find suitable positions that don't block critical paths + const attempts = 20; + let placed = false; + + for (let attempt = 0; attempt < attempts && !placed; attempt++) { + // Random position in the middle section of the level + const x = 400 + Math.random() * (this._levelWidth - 800); + + // Wall heights based on difficulty + let wallHeight; + if (difficulty <= 2) { + // Low walls - can be jumped over + wallHeight = maxJumpHeight * (0.3 + Math.random() * 0.4); + } else if (difficulty <= 4) { + // Medium walls - some jumpable, some need platform + wallHeight = Math.random() < 0.5 + ? maxJumpHeight * (0.3 + Math.random() * 0.4) // Jumpable + : maxJumpHeight * (0.8 + Math.random() * 0.3); // Need platform + } else { + // High walls - mostly need platforms or alternate routes + wallHeight = maxJumpHeight * (0.7 + Math.random() * 0.4); + } + + const wallY = groundHeight - wallHeight; + const wallWidth = 20 + Math.random() * 30; // Variable width + + // Check if wall placement is valid (not too close to other obstacles) + const isValidPosition = this._isValidWallPosition( + level, x, wallY, wallWidth, wallHeight + ); + + if (isValidPosition) { + level.walls.push({ + x: x, + y: wallY, + width: wallWidth, + height: wallHeight, + color: '#8B4513', // Brown color + type: wallHeight > maxJumpHeight * 0.6 ? 'tall' : 'short', + health: 3, // Wall can be damaged 3 times before being destroyed + maxHealth: 3 + }); + + console.log(`🧱 Wall placed: x=${x.toFixed(0)}, height=${wallHeight.toFixed(0)} (${wallHeight > maxJumpHeight * 0.6 ? 'tall' : 'short'})`); + placed = true; + } + } + } + } + + _isValidWallPosition(level, wallX, wallY, wallWidth, wallHeight) { + const wallRight = wallX + wallWidth; + const wallBottom = wallY + wallHeight; + const minDistance = 120; // Minimum distance from other obstacles + + // Check distance from platforms + for (const platform of level.platforms) { + if (platform.type === 'ground') continue; // Skip ground check + + const platformRight = platform.x + platform.width; + + // Check overlap or too close + if (!(wallRight < platform.x - minDistance || + wallX > platformRight + minDistance || + wallBottom < platform.y - minDistance || + wallY > platform.y + platform.height + minDistance)) { + return false; + } + } + + // Check distance from other walls + for (const wall of level.walls) { + const wallRight2 = wall.x + wall.width; + + if (!(wallRight < wall.x - minDistance || + wallX > wallRight2 + minDistance)) { + return false; + } + } + + // Don't place walls too close to start/end + if (wallX < 300 || wallRight > this._levelWidth - 300) { + return false; + } + + return true; + } + + _addChallengePlatforms(level, maxDistance, maxHeight) { + // Add 2-3 challenging but reachable platforms + const challengeCount = 2 + Math.floor(Math.random() * 2); + + for (let i = 0; i < challengeCount; i++) { + const availablePlatforms = level.platforms.filter(p => + p.type === 'floating' && p.x > 300 && p.x < this._levelWidth - 500 + ); + + if (availablePlatforms.length === 0) continue; + + const fromPlatform = availablePlatforms[Math.floor(Math.random() * availablePlatforms.length)]; + + // Use 90-95% of max capabilities for challenge + const challengeDistance = maxDistance * (0.9 + Math.random() * 0.05); + const challengeHeight = maxHeight * (0.8 + Math.random() * 0.15); + + const direction = Math.random() > 0.5 ? 1 : -1; + const newX = fromPlatform.x + (direction * challengeDistance); + const newY = Math.max(50, fromPlatform.y - challengeHeight); + + if (newX > 150 && newX < this._levelWidth - 300) { + level.platforms.push({ + x: newX, + y: newY, + width: 100, // Smaller platforms for challenge + height: 20, + type: 'floating', + color: '#FF6B35' // Orange for challenge platforms + }); + } + } + } + + _generateCastleLevel(level) { + // Ground platforms for entire level + for (let x = 0; x < this._levelWidth; x += 200) { + level.platforms.push({ + x: x, + y: this._config.canvasHeight - 50, + width: 200, + height: 50, + type: 'ground', + color: '#8B4513' + }); + } + + // Create single ascending staircase to castle + const jumpDistance = 180; // Comfortable jump distance + const castleX = this._levelWidth - 200; // Castle position + const numberOfPlatforms = Math.floor((castleX - 400) / jumpDistance) + 1; + + // Progressive platforms leading directly to castle + for (let i = 0; i < numberOfPlatforms; i++) { + const x = 400 + i * jumpDistance; + const y = this._config.canvasHeight - 100 - (i * 30); // Steeper but manageable ascent + const isNearCastle = x > castleX - 300; + + level.platforms.push({ + x: x, + y: Math.max(200, y), // Higher ceiling for castle approach + width: isNearCastle ? 160 : 140, // Wider near castle + height: 20, + type: 'ascending', + color: isNearCastle ? '#FFD700' : '#32CD32' // Gold near castle + }); + } + + // Final platform right at castle level + level.platforms.push({ + x: this._levelWidth - 360, // Aligned with shifted castle + y: this._config.canvasHeight - 160, // Lower since castle is on ground + width: 160, + height: 20, + type: 'castle_entrance', + color: '#FFD700' + }); + + // Castle emoji (visual only - no collision!) + const groundLevel = this._config.canvasHeight - 50; + level.castleStructure = { + x: this._levelWidth - 280, // Shifted left + y: groundLevel - 140, // Slightly in ground (360/2 = 180, but -140 puts it 40px in ground) + emoji: '🏰', + size: 360, + princess: { + x: this._levelWidth - 280, // Same X as castle + y: groundLevel - 200, // Above castle, adjusted for lower castle + emoji: '👸', + size: 50 // Slightly bigger princess + } + }; + + // Question blocks on accessible platforms + const accessiblePlatforms = level.platforms.filter(p => p.type === 'ascending' || p.type === 'floating'); + + for (let i = 0; i < Math.min(5, accessiblePlatforms.length); i++) { + const platform = accessiblePlatforms[i]; + const sentence = this._getRandomSentence(); + + if (sentence) { + level.questionBlocks.push({ + x: platform.x + platform.width / 2 - 16, + y: platform.y - 32, + width: 32, + height: 32, + sentence: sentence, + hit: false, + color: '#FFD700', + symbol: '?' + }); + } + } + + console.log(`🏰 Castle level generated with ${level.platforms.length} platforms and castle structure`); + return level; + } + + _generateHoles(level, difficulty) { + const holeCount = Math.min(difficulty - 1, 3); // 1-3 holes based on difficulty + level.holes = []; // Store hole positions to avoid spawning over them + + for (let i = 0; i < holeCount; i++) { + let holeX, holeWidth = 1; // TEST: 1 pixel wide to see the holes clearly + let validPosition = false; + let attempts = 0; + const maxAttempts = 20; + + while (!validPosition && attempts < maxAttempts) { + holeX = 300 + (i * 800) + Math.random() * 400; // Spread holes out + + // Check minimum distance from other holes (at least 2 tiles = 400px apart) + const tooCloseToOtherHole = level.holes.some(existingHole => { + return Math.abs(holeX - existingHole.x) < 400; + }); + + if (!tooCloseToOtherHole) { + validPosition = true; + } + attempts++; + } + + if (validPosition) { + // Store hole position for collision avoidance + level.holes.push({ + x: holeX, + width: holeWidth + }); + + // Simply remove ground platforms that overlap with hole area + level.platforms = level.platforms.filter(platform => { + if (platform.type === 'ground') { + return !(platform.x < holeX + holeWidth && platform.x + platform.width > holeX); + } + return true; + }); + + console.log(`🕳️ Hole ${i} created by removing ground at x=${holeX.toFixed(0)}, width=${holeWidth}px`); + } else { + console.log(`⚠️ Failed to place hole ${i} after ${maxAttempts} attempts - too close to other holes`); + } + } + } + + _generateStairs(level, difficulty) { + const groundLevel = this._config.canvasHeight - 50; + const stairCount = Math.min(difficulty, 2); // 1-2 stair sets + + for (let i = 0; i < stairCount; i++) { + let stairPlaced = false; + let attempts = 0; + const maxAttempts = 20; + + while (!stairPlaced && attempts < maxAttempts) { + const stairX = 500 + (i * 1000) + Math.random() * 300; + const stepWidth = 80; + const stepHeight = 40; + const steps = 3 + Math.floor(Math.random() * 3); // 3-5 steps + + // Check if stairs would collide with existing platforms or holes + let wouldCollide = false; + + // Check collision with holes first + const overHole = level.holes && level.holes.some(hole => { + return stairX < hole.x + hole.width && stairX + (steps * stepWidth) > hole.x; + }); + + if (overHole) { + wouldCollide = true; + } else { + // Check collision with existing platforms + for (let step = 0; step < steps; step++) { + const stepX = stairX + (step * stepWidth); + const stepY = groundLevel - ((step + 1) * stepHeight); + + const hasCollision = level.platforms.some(platform => { + return stepX < platform.x + platform.width && + stepX + stepWidth > platform.x && + stepY < platform.y + platform.height && + stepY + stepHeight > platform.y; + }); + + if (hasCollision) { + wouldCollide = true; + break; + } + } + } + + if (!wouldCollide) { + // Generate ascending stairs + for (let step = 0; step < steps; step++) { + level.platforms.push({ + x: stairX + (step * stepWidth), + y: groundLevel - ((step + 1) * stepHeight), + width: stepWidth, + height: stepHeight, + color: '#8B4513', // Brown like walls + type: 'stair' + }); + } + stairPlaced = true; + console.log(`🪜 Stairs created at x=${stairX.toFixed(0)}, ${steps} steps`); + } else { + console.log(`🚫 Stair attempt ${attempts} at x=${stairX.toFixed(0)} would collide, retrying...`); + } + attempts++; + } + + if (!stairPlaced) { + console.log(`⚠️ Failed to place stairs ${i} after ${maxAttempts} attempts`); + } + } + } + + _generatePiranhaPlants(level, difficulty) { + const plantCount = Math.min(difficulty - 2, 2); // 0-2 plants for level 3+ + + for (let i = 0; i < plantCount; i++) { + // Find a suitable ground platform for the plant + const groundPlatforms = level.platforms.filter(p => p.type === 'ground'); + if (groundPlatforms.length === 0) continue; + + const platform = groundPlatforms[Math.floor(Math.random() * groundPlatforms.length)]; + const plantX = platform.x + Math.random() * (platform.width - 30); + + level.piranhaPlants = level.piranhaPlants || []; + level.piranhaPlants.push({ + x: plantX, + y: platform.y - 40, // Plant height + width: 30, + height: 40, + color: '#228B22', // Forest green + lastShot: 0, + shootCooldown: 2000 + Math.random() * 1000, // 2-3 second intervals + type: 'piranha' + }); + + console.log(`🌸 Piranha plant placed at x=${plantX.toFixed(0)}`); + } + } + + _generateCatapults(level, difficulty) { + const { index } = level; + + let catapultCount = 1; // Always 1 catapult for level 4+ + let onagerCount = 0; + + // Level 5+ gets onagers + if (index >= 4) { + onagerCount = 1; // 1 onager for level 5+ + } + + const totalCount = catapultCount + onagerCount; + console.log(`🏹 Generating ${catapultCount} catapult(s) and ${onagerCount} onager(s) for level ${index + 1}`); + + for (let i = 0; i < totalCount; i++) { + const isOnager = i >= catapultCount; // Onagers come after catapults + // Place catapults near END of level to help finish it + const nearEndX = this._levelWidth * 0.7; // 70% through level + const catapultX = nearEndX + (i * 300) + Math.random() * 200; + let catapultY = this._config.canvasHeight - 100; // Default: on background ground + + // Check if there's a platform, wall, or stair above this position + const platformAbove = this._findPlatformAbove(catapultX, catapultY, level.platforms || []); + const wallAbove = this._findWallAbove(catapultX, catapultY, level.walls || []); + const stairAbove = this._findStairAbove(catapultX, catapultY, level.stairs || []); + + // Choose the lowest obstacle (closest to ground = highest Y value) + let obstacleAbove = null; + const obstacles = [platformAbove, wallAbove, stairAbove].filter(obs => obs !== null); + + if (obstacles.length > 0) { + // Find the obstacle with the highest Y value (closest to ground) + obstacleAbove = obstacles.reduce((lowest, current) => + current.y > lowest.y ? current : lowest + ); + } + + if (obstacleAbove) { + // Place catapult ON TOP of the obstacle + catapultY = obstacleAbove.y - 80; // 80 is catapult height + console.log(`🏹 Catapult moved to obstacle at y=${catapultY.toFixed(0)}`); + } + + level.catapults = level.catapults || []; + level.catapults.push({ + x: catapultX, + y: catapultY, + width: 60, + height: 80, + color: isOnager ? '#654321' : '#8B4513', // Onager: darker brown, Catapult: normal brown + lastShot: 0, + shootCooldown: isOnager ? 6000 + Math.random() * 2000 : 4000 + Math.random() * 2000, // Onager shoots much less often (6-8s vs 4-6s) + type: isOnager ? 'onager' : 'catapult', + isOnager: isOnager + }); + + console.log(`${isOnager ? '🏛️' : '🏹'} ${isOnager ? 'Onager' : 'Catapult'} placed at x=${catapultX.toFixed(0)}, y=${catapultY.toFixed(0)}`); + } + } + + _findPlatformAbove(x, groundY, platforms) { + // Find the lowest platform that's above the ground position and overlaps horizontally + let bestPlatform = null; + let lowestY = 0; // We want the platform closest to ground (highest Y value) + + platforms.forEach(platform => { + // Check horizontal overlap (catapult width is 60) + const catapultLeft = x; + const catapultRight = x + 60; + const platformLeft = platform.x; + const platformRight = platform.x + platform.width; + + const hasHorizontalOverlap = catapultLeft < platformRight && catapultRight > platformLeft; + + // Check if platform is above ground and has horizontal overlap + if (hasHorizontalOverlap && platform.y < groundY && platform.y > lowestY) { + bestPlatform = platform; + lowestY = platform.y; + } + }); + + return bestPlatform; + } + + _findWallAbove(x, groundY, walls) { + // Find the lowest wall that's above the ground position and overlaps horizontally + let bestWall = null; + let lowestY = 0; // We want the wall closest to ground (highest Y value) + + walls.forEach(wall => { + // Check horizontal overlap (catapult width is 60) + const catapultLeft = x; + const catapultRight = x + 60; + const wallLeft = wall.x; + const wallRight = wall.x + wall.width; + + const hasHorizontalOverlap = catapultLeft < wallRight && catapultRight > wallLeft; + + // Check if wall is above ground and has horizontal overlap + if (hasHorizontalOverlap && wall.y < groundY && wall.y > lowestY) { + bestWall = wall; + lowestY = wall.y; + } + }); + + return bestWall; + } + + _findStairAbove(x, groundY, stairs) { + // Find the lowest stair that's above the ground position and overlaps horizontally + let bestStair = null; + let lowestY = 0; // We want the stair closest to ground (highest Y value) + + stairs.forEach(stair => { + // Check horizontal overlap (catapult width is 60) + const catapultLeft = x; + const catapultRight = x + 60; + const stairLeft = stair.x; + const stairRight = stair.x + stair.width; + + const hasHorizontalOverlap = catapultLeft < stairRight && catapultRight > stairLeft; + + // Check if stair is above ground and has horizontal overlap + if (hasHorizontalOverlap && stair.y < groundY && stair.y > lowestY) { + bestStair = stair; + lowestY = stair.y; + } + }); + + return bestStair; + } + + _generateColossalBoss(level, difficulty) { + console.log(`👹 Generating Colossal Boss for level 6!`); + + // Boss positioned in center-right of level to block the path + const bossX = this._levelWidth * 0.6; // 60% through the level + const bossY = this._config.canvasHeight - 250; // Standing on ground + const bossWidth = 150; + const bossHeight = 200; + + level.boss = { + x: bossX, + y: bossY, + width: bossWidth, + height: bossHeight, + health: 5, // Takes 5 hits (1/5 each) + maxHealth: 5, + color: '#2F4F4F', // Dark slate gray + type: 'colossus', + // Collision boxes (knees for damage) + leftKnee: { + x: bossX + 20, + y: bossY + bossHeight - 60, + width: 40, + height: 40 + }, + rightKnee: { + x: bossX + bossWidth - 60, + y: bossY + bossHeight - 60, + width: 40, + height: 40 + }, + // Boss behavior + lastTurretShot: Date.now(), + turretCooldown: 2000, // Turrets fire every 2 seconds + lastMinionLaunch: Date.now(), + minionCooldown: 4000, // Launch minions every 4 seconds + // Visual + eyeColor: '#FF0000', // Red glowing eyes + isDamaged: false, + damageFlashTimer: 0 + }; + + // Generate turrets on the boss (2 turrets) + level.bossTurrets = [ + { + x: bossX + 30, + y: bossY + 50, + width: 25, + height: 25, + color: '#8B4513', + type: 'turret', + lastShot: Date.now(), + shootCooldown: 2500 // Individual cooldown + }, + { + x: bossX + bossWidth - 55, + y: bossY + 50, + width: 25, + height: 25, + color: '#8B4513', + type: 'turret', + lastShot: Date.now(), + shootCooldown: 3000 // Slightly different timing + } + ]; + + console.log(`👹 Colossal Boss spawned at x=${bossX.toFixed(0)}, health=${level.boss.health}`); + console.log(`🔫 ${level.bossTurrets.length} turrets mounted on boss`); + } + + _generateBossStairs(level, difficulty) { + const groundLevel = this._config.canvasHeight - 50; + const stairCount = Math.min(3, 2); // Maximum 3 stair sets for boss level + + for (let i = 0; i < stairCount; i++) { + let stairPlaced = false; + let attempts = 0; + const maxAttempts = 20; + + while (!stairPlaced && attempts < maxAttempts) { + const stairX = 400 + (i * 800) + Math.random() * 300; + const stepWidth = 80; + const stepHeight = 30; + const maxSteps = 2; // BOSS LEVEL: Maximum 2 steps only + + // Check if stair area is clear of holes and other obstacles + const stairAreaClear = true; // No holes in boss level + + if (stairAreaClear) { + level.stairs = level.stairs || []; + + // Generate stair steps (max 2 steps) + for (let step = 0; step < maxSteps; step++) { + level.stairs.push({ + x: stairX + (step * stepWidth), + y: groundLevel - ((step + 1) * stepHeight), + width: stepWidth, + height: stepHeight * (step + 1), // Height increases with each step + color: '#D2691E', // Orange brown for boss level stairs + type: 'stair' + }); + } + + stairPlaced = true; + console.log(`🏰 Boss stair ${i + 1} placed at x=${stairX.toFixed(0)} (${maxSteps} steps max)`); + } + + attempts++; + } + + if (!stairPlaced) { + console.log(`⚠️ Failed to place boss stair ${i} after ${maxAttempts} attempts`); + } + } + } + + _generateFlyingEyes(level, difficulty) { + const eyeCount = Math.min(4, Math.max(3, difficulty - 2)); // 3-4 flying eyes (more guaranteed) + console.log(`👁️ Generating ${eyeCount} flying eyes for level 5+`); + + for (let i = 0; i < eyeCount; i++) { + // Eyes spawn in the middle-upper area of the level + const eyeX = 300 + (i * 400) + Math.random() * 200; // Spread across level + const eyeY = 100 + Math.random() * 150; // Upper area of screen + + level.flyingEyes = level.flyingEyes || []; + level.flyingEyes.push({ + x: eyeX, + y: eyeY, + width: 30, + height: 30, + velocityX: (Math.random() - 0.5) * 2, // Random horizontal drift -1 to +1 + velocityY: (Math.random() - 0.5) * 2, // Random vertical drift -1 to +1 + color: '#DC143C', // Crimson red + pupilColor: '#000000', // Black pupil + type: 'flying_eye', + health: 1, + // AI behavior properties + chaseDistance: 200, // Start chasing Mario within 200px + chaseSpeed: 3.5, // Faster chase speed + idleSpeed: 1.2, // Faster idle movement + lastDirectionChange: Date.now(), + directionChangeInterval: 2000 + Math.random() * 3000, // Change direction every 2-5 seconds + isChasing: false, + // Dash behavior + dashCooldown: 0, + dashDuration: 0, + isDashing: false, + dashSpeed: 8, // Very fast dash + lastDashTime: Date.now(), + dashInterval: 3000 + Math.random() * 2000, // Dash every 3-5 seconds + // Visual properties + blinkTimer: 0, + isBlinking: false + }); + + console.log(`👁️ Flying eye ${i + 1} placed at x=${eyeX.toFixed(0)}, y=${eyeY.toFixed(0)}`); + } + } + + _hasSolidSupportBelow(x, y, level) { + // Check if there's solid support (ground platforms, floating platforms, stairs) directly below this position + const enemyWidth = 20; + const enemyCenterX = x + 10; // Center of enemy + const checkY = y + 5; // Just below the enemy + + // Check if there's a ground platform below (that wasn't removed by holes) + const hasGroundPlatform = level.platforms.some(platform => { + const isGroundLevel = platform.type === 'ground' && platform.y >= this._config.canvasHeight - 60; + const isUnderEnemy = platform.x <= enemyCenterX && platform.x + platform.width >= enemyCenterX; + const isBelow = platform.y >= checkY; + return isGroundLevel && isUnderEnemy && isBelow; + }); + + // Check if there's any other platform below + const hasFloatingPlatform = level.platforms.some(platform => { + const isFloating = platform.type !== 'ground'; + const isUnderEnemy = platform.x <= enemyCenterX && platform.x + platform.width >= enemyCenterX; + const isBelow = platform.y >= checkY && platform.y <= checkY + 50; // Within reasonable distance below + return isFloating && isUnderEnemy && isBelow; + }); + + // Check if there's a stair below + const hasStairSupport = level.stairs && level.stairs.some(stair => { + const isUnderEnemy = stair.x <= enemyCenterX && stair.x + stair.width >= enemyCenterX; + const isBelow = stair.y >= checkY && stair.y <= checkY + 50; + return isUnderEnemy && isBelow; + }); + + return hasGroundPlatform || hasFloatingPlatform || hasStairSupport; + } + + _hasLineOfSight(catapult, targetX, targetY) { + // Check only the BEGINNING of the trajectory (immediate launch area) + const startX = catapult.x + 30; // Center of catapult + const startY = catapult.y - 10; // Top of catapult + + // Check just the first 3 points of trajectory (immediate launch area only) + const steps = 3; + for (let i = 1; i <= steps; i++) { + const progress = i / 10; // Only check first 30% of trajectory + const checkX = startX + (targetX - startX) * progress; + const checkY = startY + (targetY - startY) * progress * 0.5; // Slight arc + + // Check if any platform blocks this immediate launch area + const blocked = this._platforms.some(platform => + checkX >= platform.x && + checkX <= platform.x + platform.width && + checkY >= platform.y && + checkY <= platform.y + platform.height + ); + + if (blocked) { + return false; // Launch area blocked + } + } + + return true; // Clear launch area + } + + _startLevel(levelIndex) { + if (levelIndex >= this._levelData.length) { + this._completeGame(); + return; + } + + this._currentLevelIndex = levelIndex; + this._currentLevel = levelIndex + 1; + + // Reset completion flag for new level + this._levelCompleted = false; + this._isCelebrating = false; + + const level = this._levelData[levelIndex]; + this._platforms = [...level.platforms]; + this._questionBlocks = [...level.questionBlocks]; + this._enemies = [...level.enemies]; + this._collectibles = [...level.collectibles]; + this._walls = [...(level.walls || [])]; + this._castleStructure = level.castleStructure || null; + + // Advanced level elements + this._piranhaPlants = [...(level.piranhaPlants || [])]; + this._projectiles = []; // Reset projectiles each level + + // Level 4+ catapult system + this._catapults = [...(level.catapults || [])]; + this._boulders = []; // Reset boulders each level + this._stones = []; // Reset stones each level + + // Level 5+ flying eyes system + this._flyingEyes = [...(level.flyingEyes || [])]; + + // Level 6 boss system + this._boss = level.boss || null; + this._bossTurrets = [...(level.bossTurrets || [])]; + this._bossMinions = []; // Reset minions each level + this._powerUps = []; // Reset power-ups each level + + // Reset Mario position + this._mario.x = level.startX; + this._mario.y = level.startY; + this._mario.velocityX = 0; + this._mario.velocityY = 0; + this._mario.onGround = false; + + // Reset camera + this._camera.x = 0; + this._camera.y = 0; + + console.log(`🎮 Started level ${this._currentLevel}${level.isCastleLevel ? ' (Castle)' : ''}`); + } + + _setupGameUI() { + // Create UI overlay + const uiOverlay = document.createElement('div'); + uiOverlay.style.position = 'absolute'; + uiOverlay.style.top = '10px'; + uiOverlay.style.left = '10px'; + uiOverlay.style.color = 'white'; + uiOverlay.style.fontFamily = 'Arial, sans-serif'; + uiOverlay.style.fontSize = '18px'; + uiOverlay.style.fontWeight = 'bold'; + uiOverlay.style.textShadow = '2px 2px 4px rgba(0,0,0,0.8)'; + uiOverlay.style.pointerEvents = 'none'; + uiOverlay.style.zIndex = '10'; + + this._uiOverlay = uiOverlay; + this._config.container.style.position = 'relative'; + this._config.container.appendChild(uiOverlay); + } + + _startGameLoop() { + const gameLoop = () => { + if (!this._isGameOver && !this._isPaused && !this._isQuestionActive) { + this._update(); + } + this._render(); + this._gameLoop = requestAnimationFrame(gameLoop); + }; + + gameLoop(); + } + + _update() { + // Handle input + this._updateMarioMovement(); + + // Update Mario physics + this._updateMarioPhysics(); + + // Update enemies + this._updateEnemies(); + + // Check collisions + this._checkCollisions(); + + // Update camera + this._updateCamera(); + + // Update advanced elements + this._updatePiranhaPlants(); + this._updateProjectiles(); + + // Update level 4+ elements + this._updateCatapults(); + this._updateBoulders(); + this._updateStones(); + + // Update level 5+ elements + this._updateFlyingEyes(); + + // Update level 6 boss elements + this._updateBoss(); + + // Update particles + this._updateParticles(); + + // Check level completion + this._checkLevelCompletion(); + } + + _updateMarioMovement() { + // Don't update movement during celebration + if (this._isCelebrating) return; + + // Horizontal movement + if (this._keys['ArrowLeft'] || this._keys['KeyA']) { + this._mario.velocityX = -this._config.moveSpeed; + this._mario.facing = 'left'; + } else if (this._keys['ArrowRight'] || this._keys['KeyD']) { + this._mario.velocityX = this._config.moveSpeed; + this._mario.facing = 'right'; + } else { + this._mario.velocityX *= 0.8; // Friction + } + + // Jumping + if ((this._keys['ArrowUp'] || this._keys['KeyW'] || this._keys['Space']) && this._mario.onGround) { + this._mario.velocityY = this._config.jumpForce; + this._mario.onGround = false; + this._playSound('jump'); + } + } + + _updateMarioPhysics() { + // Apply gravity + this._mario.velocityY += this._config.gravity; + + // Update position + this._mario.x += this._mario.velocityX; + this._mario.y += this._mario.velocityY; + + // Prevent going off left edge + if (this._mario.x < 0) { + this._mario.x = 0; + } + + // Stop Mario at finish line during celebration + const level = this._levelData[this._currentLevelIndex]; + if (this._mario.x > level.endX && this._levelCompleted) { + this._mario.x = level.endX; + this._mario.velocityX = 0; + } + + // Check if Mario fell off the world + if (this._mario.y > this._config.canvasHeight + 100) { + this._restartLevel(); + } + } + + _updateEnemies() { + // Don't update enemies during celebration + if (this._isCelebrating) return; + + this._enemies.forEach(enemy => { + // Store old position for collision detection + const oldX = enemy.x; + enemy.x += enemy.velocityX; + + // Check wall collisions + const hitWall = this._walls.some(wall => { + return enemy.x < wall.x + wall.width && + enemy.x + enemy.width > wall.x && + enemy.y < wall.y + wall.height && + enemy.y + enemy.height > wall.y; + }); + + if (hitWall) { + // Reverse position and direction + enemy.x = oldX; + enemy.velocityX *= -1; + console.log(`🧱 Enemy hit wall, reversing direction`); + } + + // Simple AI: reverse direction at platform edges + const platform = this._platforms.find(p => + enemy.x >= p.x - 10 && enemy.x <= p.x + p.width + 10 && + enemy.y >= p.y - enemy.height - 5 && enemy.y <= p.y + 5 + ); + + if (!platform || enemy.x <= 0 || enemy.x >= this._levelWidth) { + enemy.velocityX *= -1; + } + }); + } + + _checkCollisions() { + // Platform collisions + this._mario.onGround = false; + + this._platforms.forEach(platform => { + if (this._isColliding(this._mario, platform)) { + // Calculate overlap amounts to determine collision direction + const overlapLeft = (this._mario.x + this._mario.width) - platform.x; + const overlapRight = (platform.x + platform.width) - this._mario.x; + const overlapTop = (this._mario.y + this._mario.height) - platform.y; + const overlapBottom = (platform.y + platform.height) - this._mario.y; + + // Find the smallest overlap to determine collision side + const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom); + + if (minOverlap === overlapTop && this._mario.velocityY > 0) { + // Landing on top of platform + this._mario.y = platform.y - this._mario.height; + this._mario.velocityY = 0; + this._mario.onGround = true; + } + else if (minOverlap === overlapBottom && this._mario.velocityY < 0) { + // Hitting platform from below + this._mario.y = platform.y + platform.height; + this._mario.velocityY = 0; + } + else if (minOverlap === overlapLeft && this._mario.velocityX > 0) { + // Hitting platform from left + this._mario.x = platform.x - this._mario.width; + this._mario.velocityX = 0; + } + else if (minOverlap === overlapRight && this._mario.velocityX < 0) { + // Hitting platform from right + this._mario.x = platform.x + platform.width; + this._mario.velocityX = 0; + } + } + }); + + // Boulder platform collisions (landed boulders act as platforms) + this._boulders.forEach(boulder => { + if (boulder.hasLanded && this._isColliding(this._mario, boulder)) { + // Same collision logic as platforms + const overlapLeft = (this._mario.x + this._mario.width) - boulder.x; + const overlapRight = (boulder.x + boulder.width) - this._mario.x; + const overlapTop = (this._mario.y + this._mario.height) - boulder.y; + const overlapBottom = (boulder.y + boulder.height) - this._mario.y; + const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom); + + if (minOverlap === overlapTop && this._mario.velocityY > 0) { + // Landing on top of boulder + this._mario.y = boulder.y - this._mario.height; + this._mario.velocityY = 0; + this._mario.onGround = true; + } + else if (minOverlap === overlapBottom && this._mario.velocityY < 0) { + // Hitting boulder from below + this._mario.y = boulder.y + boulder.height; + this._mario.velocityY = 0; + } + else if (minOverlap === overlapLeft && this._mario.velocityX > 0) { + // Hitting boulder from left + this._mario.x = boulder.x - this._mario.width; + this._mario.velocityX = 0; + } + else if (minOverlap === overlapRight && this._mario.velocityX < 0) { + // Hitting boulder from right + this._mario.x = boulder.x + boulder.width; + this._mario.velocityX = 0; + } + } + }); + + // Question block collisions + this._questionBlocks.forEach(block => { + if (!block.hit && this._isColliding(this._mario, block)) { + // Hit from below (jumping into block) + if (this._mario.velocityY < 0 && + this._mario.y > block.y + block.height / 2) { + this._hitQuestionBlock(block); + } + // Touch from side or top + else { + this._hitQuestionBlock(block); + } + } + }); + + // Wall collisions + this._walls.forEach(wall => { + if (this._isColliding(this._mario, wall)) { + // Calculate overlap amounts to determine collision direction + const overlapLeft = (this._mario.x + this._mario.width) - wall.x; + const overlapRight = (wall.x + wall.width) - this._mario.x; + const overlapTop = (this._mario.y + this._mario.height) - wall.y; + const overlapBottom = (wall.y + wall.height) - this._mario.y; + + // Find the smallest overlap to determine collision side + const minOverlap = Math.min(overlapLeft, overlapRight, overlapTop, overlapBottom); + + if (minOverlap === overlapTop && this._mario.velocityY > 0) { + // Landing on top of wall + this._mario.y = wall.y - this._mario.height; + this._mario.velocityY = 0; + this._mario.onGround = true; + } + else if (minOverlap === overlapBottom && this._mario.velocityY < 0) { + // Hitting wall from below + this._mario.y = wall.y + wall.height; + this._mario.velocityY = 0; + } + else if (minOverlap === overlapLeft && this._mario.velocityX > 0) { + // Hitting wall from left + this._mario.x = wall.x - this._mario.width; + this._mario.velocityX = 0; + } + else if (minOverlap === overlapRight && this._mario.velocityX < 0) { + // Hitting wall from right + this._mario.x = wall.x + wall.width; + this._mario.velocityX = 0; + } + } + }); + + // Catapult collisions (Mario destroys catapults!) + this._catapults.forEach((catapult, index) => { + if (this._isColliding(this._mario, catapult)) { + console.log(`💥 Mario destroyed catapult! Explosion!`); + + // Create massive explosion effect at catapult center + const explosionX = catapult.x + catapult.width / 2; + const explosionY = catapult.y + catapult.height / 2; + + // Generate lots of particles flying in all directions + for (let i = 0; i < 25; i++) { + const angle = (Math.PI * 2 * i) / 25; // Spread particles in circle + const speed = 3 + Math.random() * 4; // Random speed 3-7 + const particleColor = ['#8B4513', '#654321', '#D2691E', '#FF4500', '#FFD700'][Math.floor(Math.random() * 5)]; + + this._particles.push({ + x: explosionX, + y: explosionY, + velocityX: Math.cos(angle) * speed, + velocityY: Math.sin(angle) * speed, + color: particleColor, + life: 60 + Math.random() * 30, // Live 60-90 frames + maxLife: 60 + Math.random() * 30, + size: 3 + Math.random() * 4 // Size 3-7 + }); + } + + // Remove the destroyed catapult + this._catapults.splice(index, 1); + + // Play destruction sound and bounce Mario up slightly + this._playSound('enemy_defeat'); + this._mario.velocityY = this._config.jumpForce * 0.5; // Small bounce from impact + + console.log(`🏹 Catapult destroyed! ${this._catapults.length} catapults remaining.`); + } + }); + + // Enemy collisions + this._enemies.forEach((enemy, index) => { + if (this._isColliding(this._mario, enemy)) { + // Calculate overlap to determine if Mario is stomping enemy + const overlapTop = (this._mario.y + this._mario.height) - enemy.y; + const overlapBottom = (enemy.y + enemy.height) - this._mario.y; + + // Stomp enemy if Mario is coming from above + if (this._mario.velocityY > 0 && overlapTop < overlapBottom) { + if (enemy.hasHelmet) { + // Can't stomp helmet enemies! Mario bounces off + this._mario.velocityY = this._config.jumpForce * 0.7; // Big bounce back + this._addParticles(enemy.x, enemy.y, '#C0C0C0'); // Silver particles + this._playSound('jump'); // Bounce sound + console.log(`🛡️ Mario bounced off helmet enemy!`); + } else { + // Normal enemy - can be stomped + this._enemies.splice(index, 1); + this._mario.velocityY = this._config.jumpForce / 2; // Small bounce + // NO SCORE for killing enemies anymore! + this._addParticles(enemy.x, enemy.y, '#FFD700'); + this._playSound('enemy_defeat'); + console.log(`🦶 Mario stomped normal enemy! (No score)`); + } + } else { + // Mario gets hurt by side/bottom collision + console.log(`💥 Mario hit by enemy - restarting level`); + this._restartLevel(); + } + } + }); + + // Check piranha plant collisions (skip flattened plants) + this._piranhaPlants.forEach((plant, index) => { + if (!plant.flattened && this._isColliding(this._mario, plant)) { + // Calculate overlap to determine if Mario is stomping plant + const overlapTop = (this._mario.y + this._mario.height) - plant.y; + const overlapBottom = (plant.y + plant.height) - this._mario.y; + + // Stomp plant if Mario is coming from above + if (this._mario.velocityY > 0 && overlapTop < overlapBottom) { + // Flatten the plant instead of removing it + plant.flattened = true; + plant.height = 5; // Very flat + plant.y += 35; // Move to ground level (original height was 40, new is 5) + plant.shootCooldown = Infinity; // Stop shooting + this._mario.velocityY = this._config.jumpForce / 2; // Small bounce + this._addParticles(plant.x, plant.y, '#228B22'); // Green particles + this._playSound('enemy_defeat'); + console.log(`🌸 Mario flattened piranha plant!`); + } else { + // Mario gets hurt by side collision + console.log(`💥 Mario hit by piranha plant - restarting level`); + this._restartLevel(); + } + } + }); + + // Check walking on flattened plants (for particles) + this._piranhaPlants.forEach((plant, index) => { + if (plant.flattened && this._isColliding(this._mario, plant)) { + // Mario is standing on a flattened plant - add particles occasionally + if (Math.random() < 0.1) { // 10% chance per frame for particles + this._addSmallParticles(plant.x + Math.random() * plant.width, plant.y, '#8B4513'); // Brown dust particles + } + } + }); + } + + _isColliding(rect1, rect2) { + return rect1.x < rect2.x + rect2.width && + rect1.x + rect1.width > rect2.x && + rect1.y < rect2.y + rect2.height && + rect1.y + rect1.height > rect2.y; + } + + _hitQuestionBlock(block) { + if (block.hit) return; + + block.hit = true; + block.color = '#8B4513'; // Brown when hit + block.symbol = '!'; + + this._score += 100; // Increased points for question blocks + this._addParticles(block.x, block.y, '#FFD700'); + this._playSound('question_block'); + + // Show question dialog + this._showQuestionDialog(block.sentence); + } + + _showQuestionDialog(sentence) { + this._isPaused = true; + this._isQuestionActive = true; + + // Create dialog overlay + const overlay = document.createElement('div'); + overlay.style.position = 'fixed'; + overlay.style.top = '0'; + overlay.style.left = '0'; + overlay.style.width = '100%'; + overlay.style.height = '100%'; + overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.8)'; + overlay.style.display = 'flex'; + overlay.style.justifyContent = 'center'; + overlay.style.alignItems = 'center'; + overlay.style.zIndex = '1000'; + + const dialog = document.createElement('div'); + dialog.style.backgroundColor = 'white'; + dialog.style.padding = '30px'; + dialog.style.borderRadius = '15px'; + dialog.style.maxWidth = '500px'; + dialog.style.textAlign = 'center'; + dialog.style.boxShadow = '0 10px 30px rgba(0,0,0,0.3)'; + dialog.style.fontFamily = 'Arial, sans-serif'; + + const title = document.createElement('h2'); + title.textContent = '🎧 Listen & Learn!'; + title.style.color = '#FF6B6B'; + title.style.marginBottom = '20px'; + + const questionText = document.createElement('div'); + questionText.innerHTML = ` +

English: ${sentence.english}

+

Translation: ${sentence.translation}

+

${sentence.context ? `Context: ${sentence.context}` : ''}

+ `; + + // Add progress indicator + const progressBar = document.createElement('div'); + progressBar.style.width = '100%'; + progressBar.style.height = '4px'; + progressBar.style.backgroundColor = '#E0E0E0'; + progressBar.style.borderRadius = '2px'; + progressBar.style.marginTop = '20px'; + progressBar.style.overflow = 'hidden'; + + const progressFill = document.createElement('div'); + progressFill.style.width = '0%'; + progressFill.style.height = '100%'; + progressFill.style.backgroundColor = '#4ECDC4'; + progressFill.style.transition = 'width linear'; + progressBar.appendChild(progressFill); + + dialog.appendChild(title); + dialog.appendChild(questionText); + dialog.appendChild(progressBar); + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + // Start TTS and auto-close + this._playTTSAndAutoClose(sentence.english, overlay, progressFill); + } + + _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 + const baseDuration = Math.max(2000, (words / wordsPerMinute) * 60000); // Minimum 2 seconds + const duration = Math.min(baseDuration, 8000); // Maximum 8 seconds + + console.log(`🔊 Playing TTS for: "${text}" (${words} words, ${duration}ms)`); + + // Use Web Speech API for TTS + if ('speechSynthesis' in window) { + const utterance = new SpeechSynthesisUtterance(text); + utterance.rate = 0.8; // Slightly slower for clarity + 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')); + + if (englishVoice) { + utterance.voice = englishVoice; + console.log(`🎤 Using voice: ${englishVoice.name}`); + } + + speechSynthesis.speak(utterance); + } + + // Animate progress bar + progressBar.style.transitionDuration = `${duration}ms`; + progressBar.style.width = '100%'; + + // Auto close after duration + setTimeout(() => { + if (overlay && overlay.parentNode) { + document.body.removeChild(overlay); + this._isPaused = false; + this._isQuestionActive = false; + this._questionsAnswered++; + this._score += 300; // Increased bonus for listening + console.log(`✅ Question dialog auto-closed. Questions answered: ${this._questionsAnswered}`); + } + }, duration); + } + + _updatePiranhaPlants() { + const currentTime = Date.now(); + + this._piranhaPlants.forEach(plant => { + // Check if it's time to shoot + if (currentTime - plant.lastShot > plant.shootCooldown) { + // Check if Mario is in range (within 400 pixels) + const distanceToMario = Math.abs(plant.x - this._mario.x); + + if (distanceToMario < 400) { + // Shoot projectile towards Mario + const direction = this._mario.x > plant.x ? 1 : -1; + + this._projectiles.push({ + x: plant.x + plant.width / 2, + y: plant.y + plant.height / 2, + velocityX: direction * 3, // Projectile speed + velocityY: 0, + width: 8, + height: 8, + color: '#FF4500', // Orange fireball + type: 'fireball', + life: 200 // 200 frames lifetime + }); + + plant.lastShot = currentTime; + this._playSound('enemy_defeat'); // Shooting sound + console.log(`🔥 Piranha plant shot fireball towards Mario!`); + } + } + }); + } + + _updateProjectiles() { + // Update projectile positions + this._projectiles.forEach((projectile, index) => { + projectile.x += projectile.velocityX; + projectile.y += projectile.velocityY; + projectile.life--; + + // Remove projectiles that are off-screen or expired + if (projectile.life <= 0 || projectile.x < -50 || projectile.x > this._levelWidth + 50) { + this._projectiles.splice(index, 1); + return; + } + + // Check collision with Mario + if (this._isColliding(this._mario, projectile)) { + console.log(`🔥 Mario hit by projectile - restarting level`); + this._restartLevel(); + return; + } + + // Check collision with walls/platforms + const hitObstacle = this._platforms.some(platform => this._isColliding(projectile, platform)) || + this._walls.some(wall => this._isColliding(projectile, wall)); + + if (hitObstacle) { + this._projectiles.splice(index, 1); + this._addParticles(projectile.x, projectile.y, '#FF4500'); + console.log(`💥 Projectile hit obstacle`); + } + }); + } + + _updateCatapults() { + const currentTime = Date.now(); + + this._catapults.forEach(catapult => { + // Check if it's time to shoot + if (currentTime - catapult.lastShot > catapult.shootCooldown) { + // Target Mario's position with imperfect aim (randomness) + const aimOffset = 100 + Math.random() * 150; // 100-250 pixel spread + const aimDirection = Math.random() < 0.5 ? -1 : 1; + const targetX = this._mario.x + (aimOffset * aimDirection); + const targetY = this._config.canvasHeight - 50; // Ground level + + // ONAGER ONLY: Check minimum range - don't fire if Mario is too close + if (catapult.isOnager) { + const distanceToMario = Math.abs(catapult.x - this._mario.x); + const minimumRange = 300; // Onager won't fire if Mario is within 300px + + if (distanceToMario < minimumRange) { + console.log(`🏛️ Onager held fire - Mario too close! Distance: ${distanceToMario.toFixed(0)}px (min: ${minimumRange}px)`); + return; // Skip this shot, Mario is too close + } + } + + // Check if there's a clear line of sight (no platform blocking) + if (!this._hasLineOfSight(catapult, targetX, targetY)) { + console.log(`🚫 ${catapult.type} blocked by obstacle, skipping shot`); + return; // Skip this shot, try again next time + } + + if (catapult.isOnager) { + // ONAGER: Fire 8 small stones in spread pattern + console.log(`🏛️ Onager firing stone rain!`); + + for (let stone = 0; stone < 8; stone++) { + // Much more random targeting for fear factor + const randomSpreadX = (Math.random() - 0.5) * 400; // ±200px spread + const randomSpreadY = (Math.random() - 0.5) * 100; // ±50px spread + const stoneTargetX = targetX + randomSpreadX; + const stoneTargetY = targetY + randomSpreadY; + + // Calculate trajectory for small stone + const deltaX = stoneTargetX - catapult.x; + const deltaY = stoneTargetY - catapult.y; + const time = 5 + Math.random() * 2; // 5-7 seconds flight time (varied) + const velocityX = deltaX / (time * 60); + const velocityY = (deltaY - 0.5 * 0.015 * time * time * 60 * 60) / (time * 60); // Slightly more gravity + + this._stones.push({ + x: catapult.x + 30 + (Math.random() - 0.5) * 20, // Spread launch point + y: catapult.y - 10 + (Math.random() - 0.5) * 10, + width: 8, // Much smaller than boulders + height: 8, + velocityX: velocityX, + velocityY: velocityY, + color: '#A0522D', // Brown stone color + type: 'stone', + sourceCatapultX: catapult.x, + sourceCatapultY: catapult.y + }); + } + + catapult.lastShot = currentTime; + this._playSound('enemy_defeat'); // Different sound for stone rain + console.log(`🏛️ Onager fired 8 stones in spread pattern!`); + } else { + // CATAPULT: Fire single boulder (original behavior) + const deltaX = targetX - catapult.x; + const deltaY = targetY - catapult.y; + const time = 7.5; // 7.5 seconds flight time + const velocityX = deltaX / (time * 60); + const velocityY = (deltaY - 0.5 * 0.01 * time * time * 60 * 60) / (time * 60); + + this._boulders.push({ + x: catapult.x + 30, + y: catapult.y - 10, + width: 25, + height: 25, + velocityX: velocityX, + velocityY: velocityY, + color: '#696969', // Dark gray + type: 'boulder', + hasLanded: false, + sourceCatapultX: catapult.x, + sourceCatapultY: catapult.y, + health: 2, + maxHealth: 2 + }); + + catapult.lastShot = currentTime; + this._playSound('jump'); // Boulder launch sound + console.log(`🏹 Catapult fired boulder towards x=${targetX.toFixed(0)}`); + } + } + }); + } + + _updateBoulders() { + this._boulders.forEach((boulder, index) => { + if (boulder.hasLanded) return; // Don't update landed boulders + + // Apply gravity and movement (MUCH slower with very light gravity) + boulder.velocityY += 0.01; // Ultra-light gravity so boulders can actually fly properly + boulder.x += boulder.velocityX; + boulder.y += boulder.velocityY; + + // Check ground collision + const groundLevel = this._config.canvasHeight - 50; + if (boulder.y + boulder.height >= groundLevel) { + this._handleBoulderImpact(boulder, index, boulder.x, groundLevel - boulder.height); + return; + } + + // Check collision with platforms, walls, stairs + let hasHit = false; + + // Check platforms + this._platforms.forEach((platform, platformIndex) => { + if (!hasHit && this._isColliding(boulder, platform)) { + this._handleBoulderImpact(boulder, index, boulder.x, platform.y - boulder.height, platform, platformIndex); + hasHit = true; + } + }); + + // Check walls (boulder damages walls) + if (!hasHit) { + this._walls.forEach((wall, wallIndex) => { + if (!hasHit && this._isColliding(boulder, wall)) { + console.log(`🪨 Boulder hit wall! Wall damage system activated.`); + + // Boulder is DESTROYED when hitting wall + this._addParticles(boulder.x + boulder.width/2, boulder.y + boulder.height/2, '#696969'); + this._boulders.splice(index, 1); + this._playSound('enemy_defeat'); + console.log(`💥 Boulder destroyed on wall impact`); + + // Wall takes damage + wall.health--; + console.log(`🧱 Wall health: ${wall.health}/${wall.maxHealth}`); + + if (wall.health <= 0) { + // Wall is destroyed + this._addParticles(wall.x + wall.width/2, wall.y + wall.height/2, '#8B4513'); + this._walls.splice(wallIndex, 1); + console.log(`💥 Wall destroyed!`); + } else { + // Visual damage - change color to show damage + if (wall.health === 2) { + wall.color = '#A0522D'; // Slightly damaged - darker brown + } else if (wall.health === 1) { + wall.color = '#654321'; // Heavily damaged - dark brown + } + console.log(`🩹 Wall damaged but still standing`); + } + + hasHit = true; + } + }); + } + + // Check collision with catapults (but NOT the source catapult) + if (!hasHit) { + this._catapults.forEach((catapult, catapultIndex) => { + // Don't collide with the catapult that fired this boulder + const isSameCatapult = Math.abs(boulder.sourceCatapultX - catapult.x) < 10 && + Math.abs(boulder.sourceCatapultY - catapult.y) < 10; + + if (!hasHit && !isSameCatapult && this._isColliding(boulder, catapult)) { + // Boulder hits a different catapult - land on top + this._handleBoulderImpact(boulder, index, boulder.x, catapult.y - boulder.height, catapult, catapultIndex, 'catapult'); + hasHit = true; + console.log(`🪨 Boulder hit different catapult!`); + } + }); + } + + // Check collision with Mario (boulder resets level like enemies!) + if (!hasHit && this._isColliding(boulder, this._mario)) { + console.log(`💀 Boulder hit Mario! Respawning at level start.`); + this._addParticles(this._mario.x, this._mario.y, '#FF0000'); // Red death particles + this._playSound('enemy_defeat'); + + // Reset Mario to start position like other enemy deaths + const level = this._levelData[this._currentLevelIndex]; + this._mario.x = level.startX; + this._mario.y = level.startY; + this._mario.velocityX = 0; + this._mario.velocityY = 0; + + // Boulder lands after hitting Mario + this._handleBoulderImpact(boulder, index, boulder.x, this._config.canvasHeight - 75); + hasHit = true; + return; + } + + // Check collision with OTHER boulders (boulder-to-boulder damage system) + if (!hasHit) { + this._boulders.forEach((otherBoulder, otherIndex) => { + if (!hasHit && otherIndex !== index && otherBoulder.hasLanded && this._isColliding(boulder, otherBoulder)) { + console.log(`🪨 Flying boulder hit landed boulder! Damage system activated.`); + + // Flying boulder is DESTROYED (removed completely) + this._addParticles(boulder.x + boulder.width/2, boulder.y + boulder.height/2, '#696969'); + this._boulders.splice(index, 1); + this._playSound('enemy_defeat'); + console.log(`💥 Flying boulder destroyed on impact`); + + // Landed boulder takes damage + otherBoulder.health--; + console.log(`🩹 Landed boulder health: ${otherBoulder.health}/${otherBoulder.maxHealth}`); + + if (otherBoulder.health <= 0) { + // Landed boulder is destroyed + this._addParticles(otherBoulder.x + otherBoulder.width/2, otherBoulder.y + otherBoulder.height/2, '#8B4513'); + this._boulders.splice(otherIndex, 1); + console.log(`💥 Landed boulder destroyed!`); + } else { + // Visual damage - change color to show damage + otherBoulder.color = otherBoulder.health === 1 ? '#A0522D' : '#696969'; // Darker when damaged + } + + hasHit = true; + } + }); + } + + // Remove boulders that go off-screen + if (boulder.x < -100 || boulder.x > this._levelWidth + 100 || boulder.y > this._config.canvasHeight + 100) { + this._boulders.splice(index, 1); + } + }); + } + + _handleBoulderImpact(boulder, boulderIndex, impactX, impactY, hitObject = null, hitObjectIndex = -1, hitType = 'platform') { + // Set boulder as landed at impact point + boulder.x = impactX; + boulder.y = impactY; + boulder.velocityX = 0; + boulder.velocityY = 0; + boulder.hasLanded = true; + boulder.color = '#8B4513'; // Brown when landed (becomes platform) + + // Explosion effect + this._addParticles(boulder.x + boulder.width/2, boulder.y + boulder.height/2, '#FF4500'); + this._playSound('enemy_defeat'); + + // Kill enemies in explosion radius (50 pixels) + this._enemies.forEach((enemy, enemyIndex) => { + const distance = Math.sqrt( + Math.pow(enemy.x - (boulder.x + boulder.width/2), 2) + + Math.pow(enemy.y - (boulder.y + boulder.height/2), 2) + ); + if (distance < 50) { + this._enemies.splice(enemyIndex, 1); + this._addParticles(enemy.x, enemy.y, '#FFD700'); + console.log(`💥 Boulder explosion killed enemy at distance ${distance.toFixed(0)}`); + } + }); + + // Reset Mario to level start if he's in explosion radius (50 pixels) + const marioDistance = Math.sqrt( + Math.pow(this._mario.x - (boulder.x + boulder.width/2), 2) + + Math.pow(this._mario.y - (boulder.y + boulder.height/2), 2) + ); + if (marioDistance < 50) { + console.log(`💀 Mario killed by boulder explosion! Respawning at level start. Distance: ${marioDistance.toFixed(0)}`); + this._addParticles(this._mario.x, this._mario.y, '#FF0000'); // Red death particles + this._playSound('enemy_defeat'); + + // Reset Mario to start position like other enemy deaths + const level = this._levelData[this._currentLevelIndex]; + this._mario.x = level.startX; + this._mario.y = level.startY; + this._mario.velocityX = 0; + this._mario.velocityY = 0; + } + + // Boulder lands only on platforms, ground, stairs - NOT on walls or other boulders (they're destroyed on impact) + if (hitObject && (hitType === 'platform' || hitType === 'stair')) { + console.log(`🪨 Boulder landed on ${hitType}, creating additional platform`); + } + + console.log(`🪨 Boulder landed at x=${impactX.toFixed(0)}, y=${impactY.toFixed(0)} - now a platform!`); + } + + _updateStones() { + this._stones.forEach((stone, index) => { + // Apply gravity and movement (similar to boulders but simpler) + stone.velocityY += 0.015; // Slightly more gravity than boulders + stone.x += stone.velocityX; + stone.y += stone.velocityY; + + // Check ground collision + const groundLevel = this._config.canvasHeight - 50; + if (stone.y + stone.height >= groundLevel) { + // Stone hits ground - create small particle effect and disappear + this._addParticles(stone.x + stone.width/2, stone.y + stone.height/2, '#A0522D'); + this._stones.splice(index, 1); + return; + } + + // Check collision with Mario (stones kill Mario!) + if (this._isColliding(stone, this._mario)) { + console.log(`🪨 Stone hit Mario! Respawning at level start.`); + this._addParticles(this._mario.x, this._mario.y, '#FF0000'); // Red death particles + this._playSound('enemy_defeat'); + + // Reset Mario to start position like other deaths + const level = this._levelData[this._currentLevelIndex]; + this._mario.x = level.startX; + this._mario.y = level.startY; + this._mario.velocityX = 0; + this._mario.velocityY = 0; + + // Remove the stone that hit Mario + this._stones.splice(index, 1); + return; + } + + // Check collision with platforms/walls (stones disappear on impact) + let hasHit = false; + + // Check platforms + this._platforms.forEach((platform) => { + if (!hasHit && this._isColliding(stone, platform)) { + this._addParticles(stone.x + stone.width/2, stone.y + stone.height/2, '#A0522D'); + this._stones.splice(index, 1); + hasHit = true; + return; + } + }); + + // Check walls + if (!hasHit) { + this._walls.forEach((wall) => { + if (!hasHit && this._isColliding(stone, wall)) { + this._addParticles(stone.x + stone.width/2, stone.y + stone.height/2, '#A0522D'); + this._stones.splice(index, 1); + hasHit = true; + return; + } + }); + } + + // Remove stones that go off-screen + if (stone.x < -100 || stone.x > this._levelWidth + 100 || stone.y > this._config.canvasHeight + 100) { + this._stones.splice(index, 1); + } + }); + } + + _renderStones() { + this._stones.forEach(stone => { + // Draw small stone as a circle + this._ctx.fillStyle = stone.color; + this._ctx.beginPath(); + this._ctx.arc(stone.x + stone.width/2, stone.y + stone.height/2, stone.width/2, 0, 2 * Math.PI); + this._ctx.fill(); + + // Add small highlight for 3D effect + this._ctx.fillStyle = '#D2B48C'; // Light brown highlight + this._ctx.beginPath(); + this._ctx.arc(stone.x + stone.width/2 - 1, stone.y + stone.height/2 - 1, stone.width/4, 0, 2 * Math.PI); + this._ctx.fill(); + }); + } + + _updateCamera() { + // Follow Mario with smooth camera movement + const targetX = this._mario.x - this._config.canvasWidth / 2; + this._camera.x = Math.max(0, Math.min(targetX, this._levelWidth - this._config.canvasWidth)); + } + + _addParticles(x, y, color) { + for (let i = 0; i < 8; i++) { + this._particles.push({ + x: x, + y: y, + velocityX: (Math.random() - 0.5) * 10, + velocityY: -Math.random() * 8 - 2, + color: color, + life: 60, + maxLife: 60 + }); + } + } + + _addSmallParticles(x, y, color) { + // Only 2-3 small particles for walking dust + for (let i = 0; i < 3; i++) { + this._particles.push({ + x: x, + y: y, + velocityX: (Math.random() - 0.5) * 4, // Smaller velocity + velocityY: -Math.random() * 3 - 1, // Less upward movement + color: color, + life: 30, // Shorter life + maxLife: 30 + }); + } + } + + _updateParticles() { + this._particles = this._particles.filter(particle => { + particle.x += particle.velocityX; + particle.y += particle.velocityY; + + if (particle.isFinishStar) { + // Slower gravity for stars and some sparkle effect + particle.velocityY += 0.1; // Light gravity + particle.velocityX *= 0.98; // Slight friction + } else { + particle.velocityY += 0.3; // Normal gravity + } + + particle.life--; + return particle.life > 0; + }); + } + + _checkLevelCompletion() { + const level = this._levelData[this._currentLevelIndex]; + + // Debug: log Mario position vs end position + if (this._mario.x > level.endX - 100) { // Close to end + console.log(`🎯 Mario position: ${this._mario.x.toFixed(0)}, Level end: ${level.endX.toFixed(0)}`); + } + + if (this._mario.x >= level.endX && !this._levelCompleted) { + console.log(`🏁 Level ${this._currentLevel} completed!`); + this._levelCompleted = true; // Prevent multiple triggers + this._triggerFinishLineAnimation(); + } + } + + _triggerFinishLineAnimation() { + this._isCelebrating = true; // Block enemies but allow animations + + // Create star animation particles + this._createFinishLineStars(); + this._playSound('finish_stars'); + + // Complete level after animation + setTimeout(() => { + this._completeLevel(); + }, 2000); + } + + _createFinishLineStars() { + const centerX = this._mario.x; + const centerY = this._mario.y; + + // Create explosion of stars + for (let i = 0; i < 20; i++) { + const angle = (Math.PI * 2 * i) / 20; + const speed = 5 + Math.random() * 5; + const distance = 50 + Math.random() * 100; + + this._particles.push({ + x: centerX, + y: centerY, + velocityX: Math.cos(angle) * speed, + velocityY: Math.sin(angle) * speed, + life: 2000, + maxLife: 2000, + color: i % 3 === 0 ? '#FFD700' : i % 3 === 1 ? '#FF69B4' : '#00BFFF', + size: 8 + Math.random() * 4, + isFinishStar: true + }); + } + + console.log('🌟 Finish line star animation triggered!'); + } + + _createLevelCompleteStars() { + const centerX = this._mario.x; + const centerY = this._mario.y; + + // Create celebration stars around Mario + for (let i = 0; i < 15; i++) { + const angle = (Math.PI * 2 * i) / 15; + const speed = 3 + Math.random() * 4; + + this._particles.push({ + x: centerX, + y: centerY, + velocityX: Math.cos(angle) * speed, + velocityY: Math.sin(angle) * speed, + life: 2500, + maxLife: 2500, + color: ['#FFD700', '#FF69B4', '#00BFFF', '#32CD32', '#FF4500'][Math.floor(Math.random() * 5)], + size: 6 + Math.random() * 3, + isLevelCompleteStar: true + }); + } + + console.log('⭐ Level complete star animation created!'); + } + + _createEpicFinalStars() { + const centerX = this._mario.x; + const centerY = this._mario.y; + + // 🌟💥 ULTRA EPIC FINAL EXPLOSION - 8 MASSIVE WAVES! 💥🌟 + for (let wave = 0; wave < 8; wave++) { + setTimeout(() => { + // MASSIVE number of stars per wave (80, 100, 120, 140, 160, 180, 200, 220) + const starsInWave = 80 + (wave * 20); + + for (let i = 0; i < starsInWave; i++) { + const angle = (Math.PI * 2 * i) / starsInWave; + const speed = 3 + Math.random() * 8 + (wave * 1.5); + const waveOffset = wave * 50; // Much bigger spread + + this._particles.push({ + x: centerX + (Math.random() - 0.5) * waveOffset, + y: centerY + (Math.random() - 0.5) * waveOffset, + velocityX: Math.cos(angle) * speed, + velocityY: Math.sin(angle) * speed - (Math.random() * 3), // More upward bias + life: 6000 + (wave * 800), // Longer lasting + maxLife: 6000 + (wave * 800), + color: ['#FFD700', '#FF69B4', '#00BFFF', '#32CD32', '#FF4500', '#9400D3', '#FF1493', '#00FF7F', '#FFFF00', '#FF6347', '#98FB98', '#DDA0DD'][Math.floor(Math.random() * 12)], + size: 10 + Math.random() * 8 + (wave * 0.5), // Bigger stars + isEpicFinalStar: true + }); + } + + // Play sound effects for each wave + this._playSound('finish_stars'); + + // Extra sound effect for bigger waves + if (wave >= 4) { + setTimeout(() => this._playSound('powerup'), 100); + } + }, wave * 200); // Faster succession + } + + // BONUS MEGA EXPLOSION at the end! + setTimeout(() => { + for (let i = 0; i < 300; i++) { // 300 bonus stars! + const angle = Math.random() * Math.PI * 2; + const speed = 2 + Math.random() * 12; + + this._particles.push({ + x: centerX + (Math.random() - 0.5) * 200, + y: centerY + (Math.random() - 0.5) * 200, + velocityX: Math.cos(angle) * speed, + velocityY: Math.sin(angle) * speed - Math.random() * 4, + life: 8000, + maxLife: 8000, + color: ['#FFD700', '#FF1493', '#00FFFF', '#FF4500', '#9400D3'][Math.floor(Math.random() * 5)], + size: 15 + Math.random() * 10, // HUGE stars + isEpicFinalStar: true + }); + } + this._playSound('level_complete'); + console.log('🌟💥🎆 MEGA BONUS EXPLOSION! 300 GIANT STARS! 🎆💥🌟'); + }, 2000); + + console.log('🌟💥🎆 ULTRA EPIC FINAL STAR EXPLOSION ACTIVATED! OVER 1500+ STARS! 🎆💥🌟'); + } + + _completeLevel() { + // NO SCORE for completing level anymore! + this._isCelebrating = true; // Block enemies but allow animations + + // Play level complete sound + this._playSound('level_complete'); + + // Add star animations for every level completion + this._createLevelCompleteStars(); + + // Show level complete message + this._showLevelCompleteMessage(); + + if (this._currentLevelIndex < this._levelData.length - 1) { + // Move to next level after delay + setTimeout(() => { + this._isCelebrating = false; + this._startLevel(this._currentLevelIndex + 1); + }, 3000); // Increased delay for star animation + } else { + // Game completed - EPIC FINAL STAR EXPLOSION + this._createEpicFinalStars(); + setTimeout(() => { + this._completeGame(); + }, 5000); // Longer delay for epic animation + } + } + + _showLevelCompleteMessage() { + // Create level complete overlay + const overlay = document.createElement('div'); + overlay.id = 'level-complete-overlay'; + overlay.style.position = 'fixed'; + overlay.style.top = '0'; + overlay.style.left = '0'; + overlay.style.width = '100%'; + overlay.style.height = '100%'; + overlay.style.backgroundColor = 'rgba(0, 150, 0, 0.8)'; + overlay.style.display = 'flex'; + overlay.style.flexDirection = 'column'; + overlay.style.justifyContent = 'center'; + overlay.style.alignItems = 'center'; + overlay.style.zIndex = '1000'; + overlay.style.color = 'white'; + overlay.style.textAlign = 'center'; + overlay.style.fontFamily = 'Arial, sans-serif'; + + const isLastLevel = this._currentLevelIndex >= this._levelData.length - 1; + + overlay.innerHTML = ` +

+ ${isLastLevel ? '🏆 VICTORY!' : '🎉 LEVEL COMPLETE!'} +

+

+ ${isLastLevel ? 'You completed all levels!' : `Level ${this._currentLevel} Complete`} +

+

Score: ${this._score.toLocaleString()}

+

Questions Answered: ${this._questionsAnswered}

+ ${!isLastLevel ? '

Next level starting soon...

' : ''} + `; + + document.body.appendChild(overlay); + + // Remove overlay after delay + setTimeout(() => { + const existingOverlay = document.getElementById('level-complete-overlay'); + if (existingOverlay) { + existingOverlay.remove(); + } + }, isLastLevel ? 5000 : 1800); + } + + _completeGame() { + this._isGameOver = true; + + // Show victory screen + const overlay = document.createElement('div'); + overlay.style.position = 'fixed'; + overlay.style.top = '0'; + overlay.style.left = '0'; + overlay.style.width = '100%'; + overlay.style.height = '100%'; + overlay.style.backgroundColor = 'rgba(0, 100, 0, 0.9)'; + overlay.style.display = 'flex'; + overlay.style.justifyContent = 'center'; + overlay.style.alignItems = 'center'; + overlay.style.zIndex = '1000'; + overlay.style.color = 'white'; + overlay.style.textAlign = 'center'; + overlay.style.fontFamily = 'Arial, sans-serif'; + + const playTime = Math.round((Date.now() - this._gameStartTime) / 1000); + + const content = document.createElement('div'); + content.innerHTML = ` +

🏰 Congratulations! 🏰

+

You completed Mario Educational Adventure!

+
+

🏆 Final Score: ${this._score}

+

📚 Questions Answered: ${this._questionsAnswered}

+

⏰ Time Played: ${playTime} seconds

+

🎮 Levels Completed: ${this._config.maxLevels}

+
+ `; + + const exitButton = document.createElement('button'); + exitButton.textContent = 'Return to Games'; + exitButton.style.cssText = ` + background: #FF6B6B; + color: white; + border: none; + padding: 15px 30px; + border-radius: 25px; + font-size: 18px; + font-weight: bold; + cursor: pointer; + margin-top: 30px; + `; + + exitButton.addEventListener('click', () => { + document.body.removeChild(overlay); + // Try to go back to game selection or reload page + if (window.history.length > 1) { + window.history.back(); + } else { + window.location.reload(); + } + }); + + content.appendChild(exitButton); + overlay.appendChild(content); + document.body.appendChild(overlay); + } + + _restartLevel() { + // Reset Mario to start position + const level = this._levelData[this._currentLevelIndex]; + this._mario.x = level.startX; + this._mario.y = level.startY; + this._mario.velocityX = 0; + this._mario.velocityY = 0; + this._mario.onGround = false; + + // Reset camera + this._camera.x = 0; + this._camera.y = 0; + + // Subtract score penalty for death + const penalty = 250; + this._score = Math.max(0, this._score - penalty); + this._playSound('death'); + console.log(`💀 Mario died! Score penalty: -${penalty}. New score: ${this._score}`); + } + + _render() { + // Clear canvas + this._ctx.clearRect(0, 0, this._config.canvasWidth, this._config.canvasHeight); + + // Render background + this._renderBackground(); + + // Save context for camera translation + this._ctx.save(); + this._ctx.translate(-this._camera.x, -this._camera.y); + + // Render platforms + this._renderPlatforms(); + + // Render question blocks + this._renderQuestionBlocks(); + + // Render enemies + this._renderEnemies(); + + // Render walls + this._renderWalls(); + + // Render advanced level elements + this._renderPiranhaPlants(); + this._renderProjectiles(); + + // Render level 4+ elements + this._renderCatapults(); + this._renderBoulders(); + this._renderStones(); + + // Render level 5+ elements + this._renderFlyingEyes(); + + // Render level 6 boss elements + this._renderBoss(); + + // Render castle (visual only) + this._renderCastle(); + + // Render finish line + this._renderFinishLine(); + + // Render Mario + this._renderMario(); + + // Render particles + this._renderParticles(); + + // Render debug hitboxes if needed + this._renderDebugHitboxes(); + + // Restore context + this._ctx.restore(); + + // Render UI + this._renderUI(); + } + + _renderBackground() { + // Sky gradient + const gradient = this._ctx.createLinearGradient(0, 0, 0, this._config.canvasHeight); + gradient.addColorStop(0, '#87CEEB'); + gradient.addColorStop(1, '#98FB98'); + this._ctx.fillStyle = gradient; + this._ctx.fillRect(0, 0, this._config.canvasWidth, this._config.canvasHeight); + + // Clouds (fixed position, don't scroll with camera) + this._ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'; + for (let i = 0; i < 5; i++) { + const x = (i * 300 + 100 - this._camera.x * 0.3) % (this._config.canvasWidth + 200); + const y = 80 + i * 30; + this._renderCloud(x, y); + } + } + + _renderCloud(x, y) { + this._ctx.beginPath(); + this._ctx.arc(x, y, 30, 0, Math.PI * 2); + this._ctx.arc(x + 30, y, 40, 0, Math.PI * 2); + this._ctx.arc(x + 60, y, 30, 0, Math.PI * 2); + this._ctx.fill(); + } + + _renderPlatforms() { + this._platforms.forEach(platform => { + this._ctx.fillStyle = platform.color; + this._ctx.fillRect(platform.x, platform.y, platform.width, platform.height); + + // Add outline + this._ctx.strokeStyle = '#000'; + this._ctx.lineWidth = 2; + this._ctx.strokeRect(platform.x, platform.y, platform.width, platform.height); + }); + } + + _renderQuestionBlocks() { + this._questionBlocks.forEach(block => { + // Block + this._ctx.fillStyle = block.color; + this._ctx.fillRect(block.x, block.y, block.width, block.height); + + // Outline + this._ctx.strokeStyle = '#000'; + this._ctx.lineWidth = 2; + this._ctx.strokeRect(block.x, block.y, block.width, block.height); + + // Symbol + this._ctx.fillStyle = '#000'; + this._ctx.font = 'bold 24px Arial'; + this._ctx.textAlign = 'center'; + this._ctx.textBaseline = 'middle'; + this._ctx.fillText( + block.symbol, + block.x + block.width / 2, + block.y + block.height / 2 + ); + }); + } + + _renderEnemies() { + this._enemies.forEach(enemy => { + this._ctx.fillStyle = enemy.color; + this._ctx.fillRect(enemy.x, enemy.y, enemy.width, enemy.height); + + // Simple face + this._ctx.fillStyle = '#FFF'; + this._ctx.fillRect(enemy.x + 3, enemy.y + 3, 3, 3); // Eye + this._ctx.fillRect(enemy.x + 14, enemy.y + 3, 3, 3); // Eye + + // Draw helmet for protected enemies + if (enemy.hasHelmet) { + this._ctx.fillStyle = '#C0C0C0'; // Silver helmet + this._ctx.fillRect(enemy.x - 1, enemy.y - 4, enemy.width + 2, 6); + + // Helmet shine + this._ctx.fillStyle = '#E8E8E8'; + this._ctx.fillRect(enemy.x + 2, enemy.y - 3, 4, 2); + + // Helmet symbol (shield) + this._ctx.fillStyle = '#FFD700'; + this._ctx.fillRect(enemy.x + 8, enemy.y - 2, 4, 2); + } + }); + } + + _renderWalls() { + this._walls.forEach(wall => { + // Wall main body + this._ctx.fillStyle = wall.color; + this._ctx.fillRect(wall.x, wall.y, wall.width, wall.height); + + // Add brick pattern for visual appeal + this._ctx.strokeStyle = '#654321'; // Darker brown for lines + this._ctx.lineWidth = 1; + + // Horizontal lines + for (let y = wall.y + 20; y < wall.y + wall.height; y += 20) { + this._ctx.beginPath(); + this._ctx.moveTo(wall.x, y); + this._ctx.lineTo(wall.x + wall.width, y); + this._ctx.stroke(); + } + + // Vertical lines (offset every other row) + for (let row = 0; row < Math.floor(wall.height / 20); row++) { + const yPos = wall.y + row * 20; + const offset = (row % 2) * 10; // Offset every other row + + for (let x = wall.x + offset + 20; x < wall.x + wall.width; x += 20) { + this._ctx.beginPath(); + this._ctx.moveTo(x, yPos); + this._ctx.lineTo(x, Math.min(yPos + 20, wall.y + wall.height)); + this._ctx.stroke(); + } + } + + // Wall type indicator (for debugging) + if (wall.type === 'tall') { + this._ctx.fillStyle = '#FF0000'; + this._ctx.fillRect(wall.x + wall.width - 5, wall.y, 5, 20); + } + }); + } + + + _renderPiranhaPlants() { + this._piranhaPlants.forEach(plant => { + if (plant.flattened) { + // Flattened plant - just a green pancake on the ground + this._ctx.fillStyle = '#556B2F'; // Dark olive green (dead/flat) + this._ctx.fillRect(plant.x, plant.y + plant.height - 5, plant.width, 5); + + // Some scattered debris + this._ctx.fillStyle = '#8B4513'; // Brown debris + this._ctx.fillRect(plant.x + 5, plant.y + plant.height - 3, 3, 2); + this._ctx.fillRect(plant.x + 20, plant.y + plant.height - 4, 2, 3); + this._ctx.fillRect(plant.x + 12, plant.y + plant.height - 2, 4, 1); + } else { + // Normal living plant + // Plant stem + this._ctx.fillStyle = '#228B22'; // Forest green + this._ctx.fillRect(plant.x + 10, plant.y, 10, plant.height); + + // Plant head (circular) + this._ctx.fillStyle = '#32CD32'; // Lime green + this._ctx.beginPath(); + this._ctx.arc(plant.x + 15, plant.y + 10, 12, 0, Math.PI * 2); + this._ctx.fill(); + + // Sharp teeth + this._ctx.fillStyle = '#FFFFFF'; + for (let i = 0; i < 6; i++) { + const angle = (i * Math.PI * 2) / 6; + const toothX = plant.x + 15 + Math.cos(angle) * 8; + const toothY = plant.y + 10 + Math.sin(angle) * 8; + this._ctx.fillRect(toothX - 1, toothY - 1, 2, 4); + } + + // Red mouth center + this._ctx.fillStyle = '#DC143C'; // Crimson + this._ctx.beginPath(); + this._ctx.arc(plant.x + 15, plant.y + 10, 6, 0, Math.PI * 2); + this._ctx.fill(); + + // Eyes + this._ctx.fillStyle = '#000000'; + this._ctx.fillRect(plant.x + 10, plant.y + 5, 3, 3); + this._ctx.fillRect(plant.x + 17, plant.y + 5, 3, 3); + } + }); + } + + _renderProjectiles() { + this._projectiles.forEach(projectile => { + // Fireball effect + this._ctx.fillStyle = projectile.color; + this._ctx.beginPath(); + this._ctx.arc(projectile.x + projectile.width/2, projectile.y + projectile.height/2, projectile.width/2, 0, Math.PI * 2); + this._ctx.fill(); + + // Inner glow + this._ctx.fillStyle = '#FFFF00'; // Yellow center + this._ctx.beginPath(); + this._ctx.arc(projectile.x + projectile.width/2, projectile.y + projectile.height/2, projectile.width/4, 0, Math.PI * 2); + this._ctx.fill(); + }); + } + + _renderCatapults() { + this._catapults.forEach(catapult => { + // Catapult base + this._ctx.fillStyle = catapult.color; + this._ctx.fillRect(catapult.x, catapult.y + 40, catapult.width, 40); + + // Catapult arm + this._ctx.fillStyle = '#654321'; // Dark brown + this._ctx.fillRect(catapult.x + 10, catapult.y, 8, 50); + + // Bucket + this._ctx.fillStyle = '#8B4513'; + this._ctx.fillRect(catapult.x + 5, catapult.y, 18, 12); + + // Support beams + this._ctx.strokeStyle = '#654321'; + this._ctx.lineWidth = 3; + this._ctx.beginPath(); + this._ctx.moveTo(catapult.x + 30, catapult.y + 40); + this._ctx.lineTo(catapult.x + 14, catapult.y + 25); + this._ctx.stroke(); + }); + } + + _renderBoulders() { + this._boulders.forEach(boulder => { + // Boulder body + this._ctx.fillStyle = boulder.color; + this._ctx.beginPath(); + this._ctx.arc(boulder.x + boulder.width/2, boulder.y + boulder.height/2, boulder.width/2, 0, Math.PI * 2); + this._ctx.fill(); + + // Boulder texture/cracks + this._ctx.strokeStyle = '#555555'; + this._ctx.lineWidth = 1; + this._ctx.beginPath(); + this._ctx.arc(boulder.x + boulder.width/2 - 5, boulder.y + boulder.height/2 - 3, 3, 0, Math.PI); + this._ctx.stroke(); + this._ctx.beginPath(); + this._ctx.arc(boulder.x + boulder.width/2 + 4, boulder.y + boulder.height/2 + 2, 2, 0, Math.PI); + this._ctx.stroke(); + + // Show trajectory line for flying boulders (debug) + if (!boulder.hasLanded && boulder.velocityX !== 0) { + this._ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)'; + this._ctx.lineWidth = 1; + this._ctx.setLineDash([5, 5]); + this._ctx.beginPath(); + this._ctx.moveTo(boulder.x + boulder.width/2, boulder.y + boulder.height/2); + this._ctx.lineTo(boulder.x + boulder.velocityX * 10, boulder.y + boulder.velocityY * 10); + this._ctx.stroke(); + this._ctx.setLineDash([]); + } + }); + } + + _renderFinishLine() { + const level = this._levelData[this._currentLevelIndex]; + const finishX = level.endX; + + // Draw checkered flag pattern + this._ctx.fillStyle = '#FFD700'; // Gold color + this._ctx.fillRect(finishX - 5, 50, 10, this._config.canvasHeight - 100); + + // Add black and white checkered pattern + const squareSize = 20; + for (let y = 50; y < this._config.canvasHeight - 50; y += squareSize) { + for (let x = 0; x < 10; x += squareSize / 2) { + const isBlack = Math.floor((y - 50) / squareSize) % 2 === Math.floor(x / (squareSize / 2)) % 2; + this._ctx.fillStyle = isBlack ? '#000000' : '#FFFFFF'; + this._ctx.fillRect(finishX - 5 + x, y, squareSize / 2, squareSize); + } + } + + // Add "FINISH" text + this._ctx.save(); + this._ctx.translate(finishX, 30); + this._ctx.rotate(-Math.PI / 2); + this._ctx.fillStyle = '#FF0000'; + this._ctx.font = 'bold 16px Arial'; + this._ctx.textAlign = 'center'; + this._ctx.fillText('FINISH', 0, 0); + this._ctx.restore(); + + // Add arrow pointing to finish + if (this._mario.x < finishX - 200) { + this._ctx.fillStyle = '#FFD700'; + this._ctx.font = 'bold 20px Arial'; + this._ctx.textAlign = 'left'; + this._ctx.fillText('→ FINISH', finishX - 150, 25); + } + } + + _updateFlyingEyes() { + const currentTime = Date.now(); + + this._flyingEyes.forEach((eye, index) => { + // Calculate distance to Mario + const distanceToMario = Math.sqrt( + Math.pow(eye.x - this._mario.x, 2) + + Math.pow(eye.y - this._mario.y, 2) + ); + + // Determine if eye should chase Mario + eye.isChasing = distanceToMario < eye.chaseDistance; + + // Handle dash behavior + if (eye.isDashing) { + eye.dashDuration--; + if (eye.dashDuration <= 0) { + eye.isDashing = false; + eye.lastDashTime = currentTime; + console.log(`👁️ Eye finished dashing!`); + } + // During dash, maintain dash velocity (no other movement changes) + } else { + // Check if it's time to dash (only when chasing) + if (eye.isChasing && currentTime - eye.lastDashTime > eye.dashInterval && Math.random() < 0.3) { + // Start dash in random 90-degree direction + const dashDirections = [ + { x: 1, y: 0 }, // Right + { x: -1, y: 0 }, // Left + { x: 0, y: 1 }, // Down + { x: 0, y: -1 } // Up + ]; + const dashDir = dashDirections[Math.floor(Math.random() * 4)]; + + eye.velocityX = dashDir.x * eye.dashSpeed; + eye.velocityY = dashDir.y * eye.dashSpeed; + eye.isDashing = true; + eye.dashDuration = 20; // Dash for 20 frames (~0.33 seconds) + console.log(`👁️ Eye started dashing! Direction: ${dashDir.x}, ${dashDir.y}`); + } else if (eye.isChasing) { + // Normal chase behavior - move toward Mario + const deltaX = this._mario.x - eye.x; + const deltaY = this._mario.y - eye.y; + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + if (distance > 0) { + // Normalize direction and apply chase speed + eye.velocityX = (deltaX / distance) * eye.chaseSpeed; + eye.velocityY = (deltaY / distance) * eye.chaseSpeed; + } + + console.log(`👁️ Eye chasing Mario! Distance: ${distanceToMario.toFixed(0)}`); + } else { + // Idle behavior - random floating movement (faster now) + if (currentTime - eye.lastDirectionChange > eye.directionChangeInterval) { + // Change direction randomly + eye.velocityX = (Math.random() - 0.5) * eye.idleSpeed * 2; + eye.velocityY = (Math.random() - 0.5) * eye.idleSpeed * 2; + eye.lastDirectionChange = currentTime; + eye.directionChangeInterval = 2000 + Math.random() * 3000; + } + } + } + + // Apply movement + eye.x += eye.velocityX; + eye.y += eye.velocityY; + + // Keep eyes within screen bounds + if (eye.x < 0) { + eye.x = 0; + eye.velocityX = Math.abs(eye.velocityX); + } else if (eye.x > this._levelWidth - eye.width) { + eye.x = this._levelWidth - eye.width; + eye.velocityX = -Math.abs(eye.velocityX); + } + + if (eye.y < 0) { + eye.y = 0; + eye.velocityY = Math.abs(eye.velocityY); + } else if (eye.y > this._config.canvasHeight - eye.height - 50) { // Stay above ground + eye.y = this._config.canvasHeight - eye.height - 50; + eye.velocityY = -Math.abs(eye.velocityY); + } + + // Blinking animation + eye.blinkTimer++; + if (eye.blinkTimer > 120 + Math.random() * 180) { // Blink every 2-5 seconds + eye.isBlinking = true; + eye.blinkTimer = 0; + } + if (eye.isBlinking && eye.blinkTimer > 10) { // Blink lasts 10 frames + eye.isBlinking = false; + } + + // Check collision with Mario + if (this._isColliding(this._mario, eye)) { + // Eye can be stomped like normal enemies + const overlapTop = (this._mario.y + this._mario.height) - eye.y; + const overlapBottom = (eye.y + eye.height) - this._mario.y; + + if (this._mario.velocityY > 0 && overlapTop < overlapBottom) { + // Mario stomped the eye + this._flyingEyes.splice(index, 1); + this._mario.velocityY = this._config.jumpForce / 2; // Small bounce + this._addParticles(eye.x, eye.y, '#DC143C'); // Red particles + this._playSound('enemy_defeat'); + console.log(`👁️ Mario stomped flying eye!`); + } else { + // Eye hurts Mario - reset level + console.log(`💥 Mario hit by flying eye - restarting level`); + this._restartLevel(); + } + } + }); + } + + _renderFlyingEyes() { + this._flyingEyes.forEach(eye => { + // Draw eye body (white oval) + this._ctx.fillStyle = '#FFFFFF'; + this._ctx.beginPath(); + this._ctx.ellipse(eye.x + eye.width/2, eye.y + eye.height/2, eye.width/2, eye.height/2, 0, 0, 2 * Math.PI); + this._ctx.fill(); + + if (!eye.isBlinking) { + // Draw red iris + this._ctx.fillStyle = eye.color; + this._ctx.beginPath(); + this._ctx.ellipse(eye.x + eye.width/2, eye.y + eye.height/2, eye.width/3, eye.height/3, 0, 0, 2 * Math.PI); + this._ctx.fill(); + + // Draw black pupil that follows Mario + const deltaX = this._mario.x - eye.x; + const deltaY = this._mario.y - eye.y; + const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); + + let pupilOffsetX = 0; + let pupilOffsetY = 0; + + if (distance > 0) { + const maxOffset = 4; // Maximum pupil movement + pupilOffsetX = (deltaX / distance) * maxOffset; + pupilOffsetY = (deltaY / distance) * maxOffset; + } + + this._ctx.fillStyle = eye.pupilColor; + this._ctx.beginPath(); + this._ctx.arc( + eye.x + eye.width/2 + pupilOffsetX, + eye.y + eye.height/2 + pupilOffsetY, + eye.width/6, + 0, + 2 * Math.PI + ); + this._ctx.fill(); + } else { + // Draw closed eye (horizontal line) + this._ctx.strokeStyle = '#000000'; + this._ctx.lineWidth = 2; + this._ctx.beginPath(); + this._ctx.moveTo(eye.x + 5, eye.y + eye.height/2); + this._ctx.lineTo(eye.x + eye.width - 5, eye.y + eye.height/2); + this._ctx.stroke(); + } + + // Add subtle outline + this._ctx.strokeStyle = '#CCCCCC'; + this._ctx.lineWidth = 1; + this._ctx.beginPath(); + this._ctx.ellipse(eye.x + eye.width/2, eye.y + eye.height/2, eye.width/2, eye.height/2, 0, 0, 2 * Math.PI); + this._ctx.stroke(); + }); + } + + _updateBoss() { + if (!this._boss) return; + + const currentTime = Date.now(); + + // Update boss collision cooldown + if (this._bossCollisionCooldown > 0) { + this._bossCollisionCooldown--; + } + + // Update damage flash timer + if (this._boss.damageFlashTimer > 0) { + this._boss.damageFlashTimer--; + if (this._boss.damageFlashTimer <= 0) { + this._boss.isDamaged = false; + } + } + + // Boss collision with Mario (blocks the path) - with cooldown to prevent loop + if (this._isColliding(this._mario, this._boss) && this._bossCollisionCooldown <= 0) { + console.log(`👹 Mario hit boss body - bounced back!`); + + // TRUE velocity inversion: velocity = velocity * -1 + this._mario.velocityX = this._mario.velocityX * -1 - 3; // Invert + add knockback + this._mario.velocityY = this._config.jumpForce * 0.7; // Bounce upward + + // Set collision cooldown (3 ticks) + this._bossCollisionCooldown = 3; + + // Impact particles + this._addParticles( + this._mario.x + this._mario.width / 2, + this._mario.y + this._mario.height / 2, + '#FFFF00' // Yellow impact particles + ); + + // Impact sound + this._playSound('enemy_defeat'); + + console.log(`⏱️ Boss collision cooldown set: ${this._bossCollisionCooldown} ticks`); + } + } + + _renderBoss() { + if (!this._boss) return; + + const ctx = this._ctx; + const x = this._boss.x; + const y = this._boss.y; + const w = this._boss.width; + const h = this._boss.height; + + // Boss main body (torso) - more humanoid proportions + ctx.fillStyle = this._boss.isDamaged ? '#FF6666' : this._boss.color; + ctx.fillRect(x + 20, y, w - 40, h * 0.6); // Torso + + // Boss legs (separated) + ctx.fillRect(x + 10, y + h * 0.6, 35, h * 0.4); // Left leg + ctx.fillRect(x + w - 45, y + h * 0.6, 35, h * 0.4); // Right leg + + // Boss head (bigger and more menacing) + const headWidth = 60; + const headHeight = 50; + ctx.fillRect(x + (w - headWidth) / 2, y - headHeight, headWidth, headHeight); + + // Boss outline for all parts + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 3; + ctx.strokeRect(x + 20, y, w - 40, h * 0.6); // Torso outline + ctx.strokeRect(x + 10, y + h * 0.6, 35, h * 0.4); // Left leg outline + ctx.strokeRect(x + w - 45, y + h * 0.6, 35, h * 0.4); // Right leg outline + ctx.strokeRect(x + (w - headWidth) / 2, y - headHeight, headWidth, headHeight); // Head outline + + // Boss eyes (glowing red, larger) + ctx.fillStyle = this._boss.eyeColor; + const eyeSize = 12; + ctx.fillRect(x + (w - headWidth) / 2 + 10, y - headHeight + 15, eyeSize, eyeSize); // Left eye + ctx.fillRect(x + (w - headWidth) / 2 + headWidth - 22, y - headHeight + 15, eyeSize, eyeSize); // Right eye + + // Boss mouth (menacing) + ctx.fillStyle = '#000000'; + ctx.fillRect(x + (w - headWidth) / 2 + 15, y - headHeight + 35, headWidth - 30, 8); + + // Shoulder spikes for more intimidating look + ctx.fillStyle = '#8B4513'; + ctx.fillRect(x + 15, y + 5, 15, 25); // Left shoulder + ctx.fillRect(x + w - 30, y + 5, 15, 25); // Right shoulder + + // Boss knees (damage zones) - integrated styling as kneepads + ctx.fillStyle = '#FFD700'; // Gold kneepads + ctx.fillRect( + this._boss.leftKnee.x + 5, + this._boss.leftKnee.y + 5, + this._boss.leftKnee.width - 10, + this._boss.leftKnee.height - 10 + ); + ctx.fillRect( + this._boss.rightKnee.x + 5, + this._boss.rightKnee.y + 5, + this._boss.rightKnee.width - 10, + this._boss.rightKnee.height - 10 + ); + + // Kneepad outlines + ctx.strokeStyle = '#B8860B'; // Dark goldenrod outline + ctx.lineWidth = 2; + ctx.strokeRect( + this._boss.leftKnee.x + 5, + this._boss.leftKnee.y + 5, + this._boss.leftKnee.width - 10, + this._boss.leftKnee.height - 10 + ); + ctx.strokeRect( + this._boss.rightKnee.x + 5, + this._boss.rightKnee.y + 5, + this._boss.rightKnee.width - 10, + this._boss.rightKnee.height - 10 + ); + + // Boss health bar + const healthBarWidth = 200; + const healthBarHeight = 20; + const healthBarX = this._boss.x + (this._boss.width / 2) - (healthBarWidth / 2); + const healthBarY = this._boss.y - 40; + + // Health bar background + ctx.fillStyle = '#FF0000'; + ctx.fillRect(healthBarX, healthBarY, healthBarWidth, healthBarHeight); + + // Health bar foreground + const healthPercent = this._boss.health / this._boss.maxHealth; + ctx.fillStyle = '#00FF00'; + ctx.fillRect(healthBarX, healthBarY, healthBarWidth * healthPercent, healthBarHeight); + + // Health bar border + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 2; + ctx.strokeRect(healthBarX, healthBarY, healthBarWidth, healthBarHeight); + + // Render turrets on boss + this._bossTurrets.forEach(turret => { + ctx.fillStyle = turret.color; + ctx.fillRect(turret.x, turret.y, turret.width, turret.height); + ctx.strokeStyle = '#000000'; + ctx.lineWidth = 1; + ctx.strokeRect(turret.x, turret.y, turret.width, turret.height); + }); + } + + _renderMario() { + // Mario body + this._ctx.fillStyle = this._mario.color; + this._ctx.fillRect(this._mario.x, this._mario.y, this._mario.width, this._mario.height); + + // Hat + this._ctx.fillStyle = '#8B0000'; + this._ctx.fillRect(this._mario.x + 4, this._mario.y - 8, this._mario.width - 8, 12); + + // Face + this._ctx.fillStyle = '#FFDBAC'; + this._ctx.fillRect(this._mario.x + 8, this._mario.y + 8, 16, 16); + + // Eyes + this._ctx.fillStyle = '#000'; + const eyeOffset = this._mario.facing === 'right' ? 2 : -2; + this._ctx.fillRect(this._mario.x + 10 + eyeOffset, this._mario.y + 12, 2, 2); + this._ctx.fillRect(this._mario.x + 18 + eyeOffset, this._mario.y + 12, 2, 2); + + // Mustache + this._ctx.fillRect(this._mario.x + 12, this._mario.y + 18, 8, 2); + } + + _renderParticles() { + this._particles.forEach(particle => { + const alpha = particle.life / particle.maxLife; + + if (particle.isFinishStar) { + // Render finish line stars as actual star symbols + this._ctx.font = `${particle.size}px serif`; + this._ctx.textAlign = 'center'; + this._ctx.textBaseline = 'middle'; + this._ctx.fillStyle = particle.color + Math.floor(alpha * 255).toString(16).padStart(2, '0'); + this._ctx.fillText('⭐', particle.x, particle.y); + } else { + // Regular particles + this._ctx.fillStyle = particle.color + Math.floor(alpha * 255).toString(16).padStart(2, '0'); + this._ctx.fillRect(particle.x, particle.y, 4, 4); + } + }); + + // Reset text alignment + this._ctx.textAlign = 'left'; + this._ctx.textBaseline = 'top'; + } + + _renderCastle() { + if (!this._castleStructure) return; + + const castle = this._castleStructure; + + // Set font for castle emoji + this._ctx.font = `${castle.size}px serif`; + this._ctx.textAlign = 'center'; + this._ctx.textBaseline = 'middle'; + + // Render castle emoji + this._ctx.fillText(castle.emoji, castle.x, castle.y); + + // Render princess in the castle + if (castle.princess) { + this._ctx.font = `${castle.princess.size}px serif`; + this._ctx.fillText(castle.princess.emoji, castle.princess.x, castle.princess.y); + } + + // Add sparkles around the massive castle (360px) + this._ctx.font = '40px serif'; + this._ctx.fillText('✨', castle.x - 180, castle.y - 120); + this._ctx.fillText('✨', castle.x + 180, castle.y - 120); + this._ctx.fillText('✨', castle.x - 120, castle.y + 120); + this._ctx.fillText('✨', castle.x + 120, castle.y + 120); + + // Additional sparkles for the massive castle + this._ctx.font = '30px serif'; + this._ctx.fillText('✨', castle.x - 220, castle.y); + this._ctx.fillText('✨', castle.x + 220, castle.y); + this._ctx.fillText('✨', castle.x, castle.y - 200); + this._ctx.fillText('✨', castle.x, castle.y + 180); + + // Extra sparkles for grandeur + this._ctx.font = '25px serif'; + this._ctx.fillText('⭐', castle.x - 160, castle.y - 60); + this._ctx.fillText('⭐', castle.x + 160, castle.y - 60); + this._ctx.fillText('⭐', castle.x - 60, castle.y + 160); + this._ctx.fillText('⭐', castle.x + 60, castle.y + 160); + + // Reset text alignment + this._ctx.textAlign = 'left'; + this._ctx.textBaseline = 'top'; + } + + _renderUI() { + if (this._uiOverlay) { + this._uiOverlay.innerHTML = ` +
Score: ${this._score}
+
Level: ${this._currentLevel}/${this._config.maxLevels}
+
Questions: ${this._questionsAnswered}
+
+ Use Arrow Keys or WASD to move, Space/Up to jump +
+ `; + } + } + + _getRandomSentence() { + const availableSentences = this._sentences.filter(s => !this._usedSentences.includes(s)); + if (availableSentences.length === 0) { + // Reset used sentences if all are used + this._usedSentences = []; + return this._sentences[0]; + } + + const sentence = availableSentences[Math.floor(Math.random() * availableSentences.length)]; + this._usedSentences.push(sentence); + return sentence; + } + + _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; + } + + _renderDebugHitboxes() { + // Only render in debug mode (can be toggled) + if (!window.DEBUG_HITBOXES) return; + + this._ctx.strokeStyle = '#FF0000'; + this._ctx.lineWidth = 2; + + // Mario hitbox + this._ctx.strokeRect(this._mario.x, this._mario.y, this._mario.width, this._mario.height); + + // Platform hitboxes + this._ctx.strokeStyle = '#00FF00'; + this._platforms.forEach(platform => { + this._ctx.strokeRect(platform.x, platform.y, platform.width, platform.height); + }); + + // Wall hitboxes + this._ctx.strokeStyle = '#8B4513'; + this._walls.forEach(wall => { + this._ctx.strokeRect(wall.x, wall.y, wall.width, wall.height); + }); + + // Enemy hitboxes + this._ctx.strokeStyle = '#FF00FF'; + this._enemies.forEach(enemy => { + this._ctx.strokeRect(enemy.x, enemy.y, enemy.width, enemy.height); + }); + + // Question block hitboxes + this._ctx.strokeStyle = '#FFFF00'; + this._questionBlocks.forEach(block => { + this._ctx.strokeRect(block.x, block.y, block.width, block.height); + }); + } +} + +export default MarioEducational; \ No newline at end of file diff --git a/src/games/QuizGame.js b/src/games/QuizGame.js new file mode 100644 index 0000000..70b02b5 --- /dev/null +++ b/src/games/QuizGame.js @@ -0,0 +1,1058 @@ +import Module from '../core/Module.js'; + +/** + * QuizGame - Educational vocabulary quiz with multiple choice questions + * Supports bidirectional quizzing (English to translation or translation to English) + */ +class QuizGame extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); + + // Validate dependencies + if (!dependencies.eventBus || !dependencies.content) { + throw new Error('QuizGame requires eventBus and content dependencies'); + } + + this._eventBus = dependencies.eventBus; + this._content = dependencies.content; + this._config = { + container: null, + questionCount: 10, + optionsCount: 6, + timeLimit: 30, // seconds per question + ...config + }; + + // Game state + this._vocabulary = null; + this._currentQuestion = 0; + this._score = 0; + this._questions = []; + this._currentQuestionData = null; + this._isAnswering = false; + this._gameStartTime = null; + this._questionStartTime = null; + this._quizDirection = 'en-to-translation'; // or 'translation-to-en' + this._timeRemaining = 0; + this._timer = null; + + Object.seal(this); + } + + /** + * Get game metadata + * @returns {Object} Game metadata + */ + static getMetadata() { + return { + name: 'Vocabulary Quiz', + description: 'Multiple choice vocabulary quiz with bidirectional testing', + difficulty: 'intermediate', + category: 'quiz', + estimatedTime: 8, // minutes + skills: ['vocabulary', 'comprehension', 'recognition', 'speed'] + }; + } + + /** + * Calculate compatibility score with content + * @param {Object} content - Content to check compatibility with + * @returns {Object} Compatibility score and details + */ + static getCompatibilityScore(content) { + const vocab = content?.vocabulary || {}; + const vocabCount = Object.keys(vocab).length; + + if (vocabCount < 6) { + return { + score: 0, + reason: `Insufficient vocabulary (${vocabCount}/6 required)`, + requirements: ['vocabulary'], + minWords: 6, + details: 'Quiz Game needs at least 6 vocabulary words for multiple choice options' + }; + } + + // Perfect score at 20+ words, partial score for 6-19 + const score = Math.min(vocabCount / 20, 1); + + return { + score, + reason: `${vocabCount} vocabulary words available`, + requirements: ['vocabulary'], + minWords: 6, + optimalWords: 20, + details: `Can create quiz with ${Math.min(vocabCount, 10)} questions` + }; + } + + async init() { + this._validateNotDestroyed(); + + try { + // Validate container + if (!this._config.container) { + throw new Error('Game container is required'); + } + + // Extract and validate vocabulary + this._vocabulary = this._extractVocabulary(); + if (this._vocabulary.length < this._config.optionsCount) { + throw new Error(`Insufficient vocabulary: need ${this._config.optionsCount}, got ${this._vocabulary.length}`); + } + + // Set up event listeners + this._eventBus.on('game:pause', this._handlePause.bind(this), this.name); + this._eventBus.on('game:resume', this._handleResume.bind(this), this.name); + + // Inject CSS + this._injectCSS(); + + // Initialize game interface + this._createGameInterface(); + this._generateQuestions(); + this._setupEventListeners(); + + // Start the game + this._gameStartTime = Date.now(); + this._showQuizDirectionChoice(); + + // Emit game ready event + this._eventBus.emit('game:ready', { + gameId: 'quiz-game', + instanceId: this.name, + vocabulary: this._vocabulary.length, + questionsCount: this._questions.length + }, this.name); + + this._setInitialized(); + + } catch (error) { + this._showError(error.message); + throw error; + } + } + + async destroy() { + this._validateNotDestroyed(); + + // Clear timer + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + + // Remove CSS + this._removeCSS(); + + // Clean up event listeners + if (this._config.container) { + this._config.container.innerHTML = ''; + } + + // Emit game end event + this._eventBus.emit('game:ended', { + gameId: 'quiz-game', + instanceId: this.name, + score: this._score, + questionsAnswered: this._currentQuestion, + totalQuestions: this._questions.length, + duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0 + }, this.name); + + this._setDestroyed(); + } + + /** + * Get current game state + * @returns {Object} Current game state + */ + getGameState() { + this._validateInitialized(); + + return { + score: this._score, + currentQuestion: this._currentQuestion, + totalQuestions: this._questions.length, + isComplete: this._currentQuestion >= this._questions.length, + duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0, + timeRemaining: this._timeRemaining + }; + } + + // Private methods + _extractVocabulary() { + const vocab = this._content?.vocabulary || {}; + const vocabulary = []; + + for (const [word, data] of Object.entries(vocab)) { + if (data.user_language) { + vocabulary.push({ + english: word, + translation: data.user_language, + type: data.type || 'unknown' + }); + } + } + + return this._shuffleArray(vocabulary); + } + + _injectCSS() { + const cssId = `quiz-game-styles-${this.name}`; + if (document.getElementById(cssId)) return; + + const style = document.createElement('style'); + style.id = cssId; + style.textContent = ` + .quiz-game { + padding: 20px; + max-width: 800px; + margin: 0 auto; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + } + + .quiz-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 30px; + padding: 20px; + background: linear-gradient(135deg, #e2e8f0 0%, #cbd5e0 100%); + border-radius: 12px; + color: #2d3748; + border: 2px solid #4299e1; + font-weight: 600; + } + + .quiz-stats { + display: flex; + gap: 30px; + } + + .quiz-stat { + text-align: center; + } + + .stat-label { + display: block; + font-size: 0.8rem; + opacity: 0.9; + margin-bottom: 5px; + } + + .stat-value { + display: block; + font-size: 1.5rem; + font-weight: bold; + } + + .quiz-direction-choice { + text-align: center; + padding: 40px; + background: #f8f9fa; + border-radius: 12px; + margin-bottom: 30px; + } + + .quiz-direction-choice h3 { + margin-bottom: 20px; + color: #333; + } + + .direction-buttons { + display: flex; + gap: 20px; + justify-content: center; + flex-wrap: wrap; + } + + .direction-btn { + padding: 15px 30px; + border: none; + border-radius: 8px; + background: #007bff; + color: white; + font-size: 1rem; + cursor: pointer; + transition: all 0.3s ease; + min-width: 200px; + } + + .direction-btn:hover { + background: #0056b3; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 123, 255, 0.3); + } + + .question-container { + background: white; + border-radius: 12px; + padding: 30px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + margin-bottom: 20px; + } + + .question-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 25px; + padding-bottom: 15px; + border-bottom: 2px solid #e9ecef; + } + + .question-number { + font-size: 1.1rem; + color: #6c757d; + font-weight: 500; + } + + .question-timer { + display: flex; + align-items: center; + gap: 10px; + color: #dc3545; + font-weight: bold; + } + + .timer-circle { + width: 40px; + height: 40px; + border-radius: 50%; + background: #dc3545; + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9rem; + } + + .timer-circle.warning { + background: #ffc107; + color: #000; + } + + .timer-circle.safe { + background: #28a745; + } + + .question-text { + font-size: 1.5rem; + text-align: center; + margin-bottom: 30px; + padding: 20px; + background: #f8f9fa; + border-radius: 8px; + color: #333; + font-weight: 500; + } + + .quiz-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 15px; + margin-bottom: 30px; + } + + .quiz-option { + padding: 20px; + border: 2px solid #e9ecef; + border-radius: 8px; + background: white; + cursor: pointer; + transition: all 0.3s ease; + text-align: center; + font-size: 1.1rem; + position: relative; + } + + .quiz-option:hover { + border-color: #007bff; + background: #f8f9fa; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 123, 255, 0.2); + } + + .quiz-option.selected { + border-color: #007bff; + background: #e3f2fd; + } + + .quiz-option.correct { + border-color: #28a745; + background: #d4edda; + color: #155724; + } + + .quiz-option.incorrect { + border-color: #dc3545; + background: #f8d7da; + color: #721c24; + } + + .quiz-option.disabled { + pointer-events: none; + opacity: 0.7; + } + + .quiz-feedback { + text-align: center; + padding: 20px; + border-radius: 8px; + margin: 20px 0; + font-size: 1.1rem; + font-weight: 500; + } + + .quiz-feedback.correct { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; + } + + .quiz-feedback.incorrect { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; + } + + .next-question-btn { + display: block; + margin: 20px auto; + padding: 12px 30px; + background: #28a745; + color: white; + border: none; + border-radius: 8px; + font-size: 1rem; + cursor: pointer; + transition: all 0.3s ease; + } + + .next-question-btn:hover { + background: #218838; + transform: translateY(-2px); + } + + .quiz-complete { + text-align: center; + padding: 40px; + background: linear-gradient(135deg, #28a745 0%, #20c997 100%); + color: white; + border-radius: 12px; + } + + .quiz-complete h2 { + margin-bottom: 20px; + } + + .final-score { + font-size: 2rem; + font-weight: bold; + margin: 20px 0; + } + + .quiz-summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 20px; + margin: 30px 0; + background: rgba(255, 255, 255, 0.1); + padding: 20px; + border-radius: 8px; + } + + .summary-stat { + text-align: center; + } + + .summary-stat-value { + display: block; + font-size: 1.5rem; + font-weight: bold; + margin-bottom: 5px; + } + + .summary-stat-label { + font-size: 0.9rem; + opacity: 0.9; + } + + .restart-btn, .exit-btn { + margin: 10px; + padding: 12px 25px; + border: 2px solid white; + border-radius: 8px; + background: transparent; + color: white; + font-size: 1rem; + cursor: pointer; + transition: all 0.3s ease; + } + + .restart-btn:hover, .exit-btn:hover { + background: white; + color: #28a745; + } + + .quiz-error { + text-align: center; + padding: 40px; + background: #f8d7da; + border: 1px solid #f5c6cb; + border-radius: 12px; + color: #721c24; + } + + .error-icon { + font-size: 3rem; + margin-bottom: 20px; + } + + @media (max-width: 768px) { + .quiz-header { + flex-direction: column; + gap: 20px; + } + + .quiz-stats { + gap: 20px; + } + + .quiz-options { + grid-template-columns: 1fr; + } + + .direction-buttons { + flex-direction: column; + align-items: center; + } + } + `; + + document.head.appendChild(style); + } + + _removeCSS() { + const cssId = `quiz-game-styles-${this.name}`; + const existingStyle = document.getElementById(cssId); + if (existingStyle) { + existingStyle.remove(); + } + } + + _createGameInterface() { + this._config.container.innerHTML = ` +
+
+
+
+ Score + 0 +
+
+ Question + + 0/${this._config.questionCount} + +
+
+ Accuracy + 0% +
+
+ +
+
+
+ `; + } + + _generateQuestions() { + this._questions = []; + const questionCount = Math.min(this._config.questionCount, this._vocabulary.length); + const selectedVocab = this._shuffleArray([...this._vocabulary]).slice(0, questionCount); + + selectedVocab.forEach((vocab, index) => { + this._questions.push({ + id: index, + vocabulary: vocab, + options: this._generateOptions(vocab), + correctAnswer: null, // Will be set based on quiz direction + userAnswer: null, + timeSpent: 0, + answered: false + }); + }); + } + + _generateOptions(correctVocab) { + const options = []; + const otherVocab = this._vocabulary.filter(v => v !== correctVocab); + const shuffledOthers = this._shuffleArray(otherVocab); + + // Add correct answer + options.push(correctVocab); + + // Add incorrect options + for (let i = 0; i < this._config.optionsCount - 1 && i < shuffledOthers.length; i++) { + options.push(shuffledOthers[i]); + } + + return this._shuffleArray(options); + } + + _showQuizDirectionChoice() { + const content = document.getElementById('quiz-content'); + content.innerHTML = ` +
+

Choose Quiz Direction

+

How would you like to be tested?

+
+ + +
+
+ `; + } + + _startQuiz(direction) { + this._quizDirection = direction; + this._currentQuestion = 0; + this._score = 0; + + // Set correct answers based on direction + this._questions.forEach(question => { + if (direction === 'en-to-translation') { + question.correctAnswer = question.vocabulary.translation; + } else { + question.correctAnswer = question.vocabulary.english; + } + }); + + this._showQuestion(); + } + + _showQuestion() { + if (this._currentQuestion >= this._questions.length) { + this._showResults(); + return; + } + + const question = this._questions[this._currentQuestion]; + const content = document.getElementById('quiz-content'); + + // Determine question text based on direction + const questionText = this._quizDirection === 'en-to-translation' + ? question.vocabulary.english + : question.vocabulary.translation; + + content.innerHTML = ` +
+
+
+ Question ${this._currentQuestion + 1} of ${this._questions.length} +
+
+
+ ${this._config.timeLimit} +
+
+
+ +
+ ${questionText} +
+ +
+ ${this._generateOptionHTML(question)} +
+ +
+
+ `; + + this._startQuestionTimer(); + this._questionStartTime = Date.now(); + } + + _generateOptionHTML(question) { + return question.options.map((vocab, index) => { + const optionText = this._quizDirection === 'en-to-translation' + ? vocab.translation + : vocab.english; + + return ` +
+ ${optionText} +
+ `; + }).join(''); + } + + _startQuestionTimer() { + this._timeRemaining = this._config.timeLimit; + this._timer = setInterval(() => { + this._timeRemaining--; + this._updateTimer(); + + if (this._timeRemaining <= 0) { + this._handleTimeout(); + } + }, 1000); + } + + _updateTimer() { + const timerValue = document.getElementById('timer-value'); + const timerCircle = document.getElementById('timer-circle'); + + if (timerValue) { + timerValue.textContent = this._timeRemaining; + } + + if (timerCircle) { + timerCircle.className = 'timer-circle'; + if (this._timeRemaining <= 5) { + timerCircle.classList.add('warning'); + } else if (this._timeRemaining > 15) { + timerCircle.classList.add('safe'); + } + } + } + + _setupEventListeners() { + // Direction choice listeners + this._config.container.addEventListener('click', (event) => { + if (event.target.matches('.direction-btn')) { + const direction = event.target.dataset.direction; + this._startQuiz(direction); + } + + if (event.target.matches('.quiz-option')) { + this._handleAnswerClick(event.target); + } + + if (event.target.matches('.next-question-btn')) { + this._nextQuestion(); + } + + if (event.target.matches('.restart-btn')) { + this._restartQuiz(); + } + }); + + // Exit button + const exitButton = this._config.container.querySelector('#exit-quiz'); + if (exitButton) { + exitButton.addEventListener('click', () => { + this._eventBus.emit('game:exit-request', { instanceId: this.name }, this.name); + }); + } + } + + _handleAnswerClick(optionElement) { + if (this._isAnswering || optionElement.classList.contains('disabled')) { + return; + } + + this._isAnswering = true; + const question = this._questions[this._currentQuestion]; + const selectedAnswer = optionElement.dataset.value; + const isCorrect = selectedAnswer === question.correctAnswer; + + // Clear timer + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + + // Record answer + question.userAnswer = selectedAnswer; + question.answered = true; + question.timeSpent = this._questionStartTime ? Date.now() - this._questionStartTime : 0; + + // Update score + if (isCorrect) { + const timeBonus = Math.max(0, this._timeRemaining * 2); + this._score += 100 + timeBonus; + } + + // Show feedback + this._showAnswerFeedback(isCorrect, question); + this._updateStats(); + + // Emit answer event + this._eventBus.emit('quiz:answer', { + gameId: 'quiz-game', + instanceId: this.name, + questionNumber: this._currentQuestion + 1, + isCorrect, + score: this._score, + timeSpent: question.timeSpent + }, this.name); + } + + _handleTimeout() { + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + + const question = this._questions[this._currentQuestion]; + question.answered = true; + question.timeSpent = this._config.timeLimit * 1000; + + this._showAnswerFeedback(false, question, true); + this._updateStats(); + } + + _showAnswerFeedback(isCorrect, question, timeout = false) { + const optionsContainer = document.getElementById('quiz-options'); + const feedbackContainer = document.getElementById('quiz-feedback'); + + // Disable all options + optionsContainer.querySelectorAll('.quiz-option').forEach(option => { + option.classList.add('disabled'); + + const optionValue = option.dataset.value; + if (optionValue === question.correctAnswer) { + option.classList.add('correct'); + } else if (optionValue === question.userAnswer) { + option.classList.add('incorrect'); + } + }); + + // Show feedback message + let feedbackMessage; + if (timeout) { + feedbackMessage = `⏰ Time's up! The correct answer was: ${question.correctAnswer}`; + } else if (isCorrect) { + feedbackMessage = `🎉 Correct! +${100 + Math.max(0, this._timeRemaining * 2)} points`; + } else { + feedbackMessage = `❌ Incorrect. The correct answer was: ${question.correctAnswer}`; + } + + feedbackContainer.innerHTML = ` +
+ ${feedbackMessage} +
+ + `; + } + + _nextQuestion() { + this._currentQuestion++; + this._isAnswering = false; + this._showQuestion(); + } + + _showResults() { + const correctAnswers = this._questions.filter(q => q.userAnswer === q.correctAnswer).length; + const accuracy = Math.round((correctAnswers / this._questions.length) * 100); + const totalTime = this._gameStartTime ? Date.now() - this._gameStartTime : 0; + const avgTimePerQuestion = Math.round(totalTime / this._questions.length / 1000); + + // Store best score + const gameKey = 'quiz-game'; + const currentScore = this._score; + const bestScore = parseInt(localStorage.getItem(`${gameKey}-best-score`) || '0'); + const isNewBest = currentScore > bestScore; + + if (isNewBest) { + localStorage.setItem(`${gameKey}-best-score`, currentScore.toString()); + } + + // Show victory popup + this._showVictoryPopup({ + gameTitle: 'Vocabulary Quiz', + currentScore, + bestScore: isNewBest ? currentScore : bestScore, + isNewBest, + stats: { + 'Questions': `${correctAnswers}/${this._questions.length}`, + 'Accuracy': `${accuracy}%`, + 'Avg Time': `${avgTimePerQuestion}s`, + 'Total Time': `${Math.round(totalTime / 1000)}s` + } + }); + + // Emit completion event + this._eventBus.emit('game:completed', { + gameId: 'quiz-game', + instanceId: this.name, + score: this._score, + correctAnswers, + totalQuestions: this._questions.length, + accuracy, + duration: totalTime + }, this.name); + } + + _restartQuiz() { + this._currentQuestion = 0; + this._score = 0; + this._isAnswering = false; + this._generateQuestions(); + this._showQuizDirectionChoice(); + this._updateStats(); + } + + _updateStats() { + const scoreElement = document.getElementById('quiz-score'); + const questionElement = document.getElementById('current-question'); + const accuracyElement = document.getElementById('quiz-accuracy'); + + if (scoreElement) scoreElement.textContent = this._score; + if (questionElement) questionElement.textContent = this._currentQuestion + 1; + + if (accuracyElement && this._currentQuestion > 0) { + const answeredQuestions = this._questions.slice(0, this._currentQuestion + 1).filter(q => q.answered); + const correctAnswers = answeredQuestions.filter(q => q.userAnswer === q.correctAnswer).length; + const accuracy = answeredQuestions.length > 0 ? Math.round((correctAnswers / answeredQuestions.length) * 100) : 0; + accuracyElement.textContent = `${accuracy}%`; + } + } + + _showError(message) { + if (this._config.container) { + this._config.container.innerHTML = ` +
+
+

Quiz Error

+

${message}

+ +
+ `; + } + } + + _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; + } + + _handlePause() { + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + this._eventBus.emit('game:paused', { instanceId: this.name }, this.name); + } + + _handleResume() { + if (this._timeRemaining > 0 && !this._isAnswering) { + this._startQuestionTimer(); + } + this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name); + } + + _showVictoryPopup({ gameTitle, currentScore, bestScore, isNewBest, stats }) { + const popup = document.createElement('div'); + popup.className = 'victory-popup'; + popup.innerHTML = ` +
+
+
🏆
+

${gameTitle} Complete!

+ ${isNewBest ? '
🎉 New Best Score!
' : ''} +
+ +
+
+
Your Score
+
${currentScore}
+
+
+
Best Score
+
${bestScore}
+
+
+ +
+ ${Object.entries(stats).map(([key, value]) => ` +
+
${key}
+
${value}
+
+ `).join('')} +
+ +
+ + + +
+
+ `; + + document.body.appendChild(popup); + + // Animate in + requestAnimationFrame(() => { + popup.classList.add('show'); + }); + + // Add event listeners + popup.querySelector('#play-again-btn').addEventListener('click', () => { + popup.remove(); + this._restartQuiz(); + }); + + popup.querySelector('#different-game-btn').addEventListener('click', () => { + popup.remove(); + if (window.app && window.app.getCore().router) { + window.app.getCore().router.navigate('/games'); + } else { + window.location.href = '/#/games'; + } + }); + + popup.querySelector('#main-menu-btn').addEventListener('click', () => { + popup.remove(); + if (window.app && window.app.getCore().router) { + window.app.getCore().router.navigate('/'); + } else { + window.location.href = '/'; + } + }); + + // Close on backdrop click + popup.addEventListener('click', (e) => { + if (e.target === popup) { + popup.remove(); + if (window.app && window.app.getCore().router) { + window.app.getCore().router.navigate('/games'); + } else { + window.location.href = '/#/games'; + } + } + }); + } +} + +export default QuizGame; \ No newline at end of file diff --git a/src/games/RiverRun.js b/src/games/RiverRun.js new file mode 100644 index 0000000..37f5c3e --- /dev/null +++ b/src/games/RiverRun.js @@ -0,0 +1,1428 @@ +import Module from '../core/Module.js'; + +class RiverRun extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); + + if (!dependencies.eventBus || !dependencies.content) { + throw new Error('RiverRun requires eventBus and content dependencies'); + } + + this._eventBus = dependencies.eventBus; + this._content = dependencies.content; + this._config = { + container: null, + difficulty: 'medium', + initialSpeed: 2, + initialLives: 3, + spawnInterval: 1000, + ...config + }; + + this._isRunning = false; + this._score = 0; + this._lives = this._config.initialLives; + this._level = 1; + this._speed = this._config.initialSpeed; + this._wordsCollected = 0; + + this._player = { + x: 50, + y: 80, + targetX: 50, + targetY: 80, + size: 40 + }; + + this._floatingWords = []; + this._currentTarget = null; + this._targetQueue = []; + this._powerUps = []; + this._particles = []; + this._availableWords = []; + this._usedTargets = []; + + this._riverOffset = 0; + this._lastSpawn = 0; + this._gameStartTime = 0; + this._wordsSpawnedSinceTarget = 0; + this._maxWordsBeforeTarget = 10; + this._gameContainer = null; + this._animationFrame = null; + + Object.seal(this); + } + + static getMetadata() { + return { + id: 'river-run', + name: 'River Run', + description: 'Navigate down a river collecting target vocabulary words while avoiding obstacles', + version: '2.0.0', + author: 'Class Generator', + category: 'action', + tags: ['vocabulary', 'action', 'reflex', 'collection'], + difficulty: { + min: 1, + max: 4, + default: 2 + }, + estimatedDuration: 8, + requiredContent: ['vocabulary'] + }; + } + + static getCompatibilityScore(content) { + if (!content || !content.vocabulary) { + return 0; + } + + let score = 50; + + if (typeof content.vocabulary === 'object') { + const vocabCount = Object.keys(content.vocabulary).length; + if (vocabCount >= 10) score += 25; + if (vocabCount >= 20) score += 15; + if (vocabCount >= 30) score += 10; + } else if (content.letters) { + score += 20; + } + + return Math.min(score, 100); + } + + async init() { + this._validateNotDestroyed(); + + // Validate container + if (!this._config.container) { + throw new Error('Game container is required'); + } + + this._eventBus.on('game:start', this._handleGameStart.bind(this), this.name); + this._eventBus.on('game:stop', this._handleGameStop.bind(this), this.name); + this._eventBus.on('navigation:change', this._handleNavigationChange.bind(this), this.name); + + this._injectCSS(); + + // Start game immediately + try { + this._gameContainer = this._config.container; + const content = this._content; + + if (!content) { + throw new Error('No content available'); + } + + this._extractContent(content); + + if (this._availableWords.length === 0) { + throw new Error('No vocabulary found for River Run'); + } + + this._generateTargetQueue(); + this._createGameBoard(); + this._setupEventListeners(); + this._updateHUD(); + + // Emit game ready event + this._eventBus.emit('game:ready', { + gameId: 'river-run', + instanceId: this.name, + vocabulary: this._availableWords.length + }, this.name); + + } catch (error) { + console.error('Error starting River Run:', error); + this._showInitError(error.message); + } + + this._setInitialized(); + } + + async destroy() { + this._validateNotDestroyed(); + + this._cleanup(); + this._removeCSS(); + this._eventBus.off('game:start', this.name); + this._eventBus.off('game:stop', this.name); + this._eventBus.off('navigation:change', this.name); + + this._setDestroyed(); + } + + _handleGameStart(event) { + this._validateInitialized(); + if (event.gameId === 'river-run') { + this._startGame(); + } + } + + _handleGameStop(event) { + this._validateInitialized(); + if (event.gameId === 'river-run') { + this._stopGame(); + } + } + + _handleNavigationChange(event) { + this._validateInitialized(); + if (event.from === '/games/river-run') { + this._cleanup(); + } + } + + async _startGame() { + try { + this._gameContainer = document.getElementById('game-content'); + if (!this._gameContainer) { + throw new Error('Game container not found'); + } + + const content = await this._content.getCurrentContent(); + if (!content) { + throw new Error('No content available'); + } + + this._extractContent(content); + + if (this._availableWords.length === 0) { + throw new Error('No vocabulary found for River Run'); + } + + this._generateTargetQueue(); + this._createGameBoard(); + this._setupEventListeners(); + this._updateHUD(); + + } catch (error) { + console.error('Error starting River Run:', error); + this._showInitError(error.message); + } + } + + _stopGame() { + this._cleanup(); + } + + _cleanup() { + this._isRunning = false; + + if (this._animationFrame) { + cancelAnimationFrame(this._animationFrame); + this._animationFrame = null; + } + + if (this._gameContainer) { + this._gameContainer.innerHTML = ''; + } + + if (window.currentRiverGame === this) { + delete window.currentRiverGame; + } + } + + _showInitError(message) { + this._gameContainer.innerHTML = ` +
+

❌ Loading Error

+

${message}

+

The game requires vocabulary content with words and translations.

+ +
+ `; + } + + _extractContent(content) { + this._availableWords = []; + + if (content.vocabulary) { + Object.keys(content.vocabulary).forEach(word => { + const wordData = content.vocabulary[word]; + this._availableWords.push({ + french: word, + english: typeof wordData === 'string' ? wordData : + wordData.translation || wordData.user_language || 'unknown', + pronunciation: wordData.pronunciation || wordData.prononciation + }); + }); + } + + if (content.letters && this._availableWords.length === 0) { + Object.values(content.letters).forEach(letterWords => { + letterWords.forEach(wordData => { + this._availableWords.push({ + french: wordData.word, + english: wordData.translation, + pronunciation: wordData.pronunciation + }); + }); + }); + } + + console.log(`River Run: ${this._availableWords.length} words loaded`); + } + + _generateTargetQueue() { + this._targetQueue = this._shuffleArray([...this._availableWords]).slice(0, Math.min(10, this._availableWords.length)); + this._usedTargets = []; + } + + _createGameBoard() { + this._gameContainer.innerHTML = ` +
+
+
+
Score: ${this._score}
+
Lives: ${this._lives}
+
Words: ${this._wordsCollected}
+
+
+ Click to Start! +
+
+
Level: ${this._level}
+
Speed: ${this._speed.toFixed(1)}x
+
+
+ +
+
+
+
+
+ `; + + window.currentRiverGame = this; + } + + _setupEventListeners() { + const riverGame = document.getElementById('river-game'); + + riverGame.addEventListener('click', (e) => { + if (!this._isRunning) { + this._start(); + return; + } + + const rect = riverGame.getBoundingClientRect(); + const clickX = ((e.clientX - rect.left) / rect.width) * 100; + const clickY = ((e.clientY - rect.top) / rect.height) * 100; + + this._movePlayer(clickX, clickY); + }); + + riverGame.addEventListener('click', (e) => { + if (e.target.classList.contains('floating-word')) { + e.stopPropagation(); + this._handleWordClick(e.target); + } + }); + } + + _start() { + if (this._isRunning) return; + + this._isRunning = true; + this._gameStartTime = Date.now(); + this._setNextTarget(); + + this._gameLoop(); + console.log('River Run started!'); + } + + _gameLoop() { + if (!this._isRunning) return; + + const now = Date.now(); + + if (now - this._lastSpawn > this._config.spawnInterval) { + this._spawnFloatingWord(); + this._lastSpawn = now; + } + + this._updateFloatingWords(); + this._updatePlayer(); + this._updateParticles(); + this._checkCollisions(); + this._updateDifficulty(); + this._updateHUD(); + + this._animationFrame = requestAnimationFrame(() => this._gameLoop()); + } + + _setNextTarget() { + if (this._targetQueue.length === 0) { + this._generateTargetQueue(); + } + + this._currentTarget = this._targetQueue.shift(); + this._usedTargets.push(this._currentTarget); + + this._wordsSpawnedSinceTarget = 0; + + const targetDisplay = document.getElementById('target-display'); + if (targetDisplay) { + targetDisplay.innerHTML = `Find: ${this._currentTarget.english}`; + } + } + + _spawnFloatingWord() { + const riverCanvas = document.getElementById('river-canvas'); + if (!riverCanvas) return; + + let word; + if (this._wordsSpawnedSinceTarget >= this._maxWordsBeforeTarget) { + word = this._currentTarget; + this._wordsSpawnedSinceTarget = 0; + } else { + word = this._getRandomWord(); + this._wordsSpawnedSinceTarget++; + } + + const wordElement = document.createElement('div'); + wordElement.className = 'floating-word'; + + const spacePadding = ' '.repeat(this._level * 2); + wordElement.textContent = spacePadding + word.french + spacePadding; + + wordElement.style.left = `${Math.random() * 80 + 10}%`; + wordElement.style.top = '-60px'; + + wordElement.wordData = word; + + riverCanvas.appendChild(wordElement); + this._floatingWords.push({ + element: wordElement, + y: -60, + x: parseFloat(wordElement.style.left), + wordData: word + }); + + if (Math.random() < 0.1) { + this._spawnPowerUp(); + } + } + + _getRandomWord() { + return this._availableWords[Math.floor(Math.random() * this._availableWords.length)]; + } + + _spawnPowerUp() { + const riverCanvas = document.getElementById('river-canvas'); + if (!riverCanvas) return; + + const powerUpElement = document.createElement('div'); + powerUpElement.className = 'power-up'; + powerUpElement.innerHTML = '⚡'; + powerUpElement.style.left = `${Math.random() * 80 + 10}%`; + powerUpElement.style.top = '-40px'; + + riverCanvas.appendChild(powerUpElement); + this._powerUps.push({ + element: powerUpElement, + y: -40, + x: parseFloat(powerUpElement.style.left), + type: 'slowTime' + }); + } + + _updateFloatingWords() { + 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(); + } + word.element.remove(); + return false; + } + + return true; + }); + + this._powerUps = this._powerUps.filter(powerUp => { + powerUp.y += this._speed; + powerUp.element.style.top = `${powerUp.y}px`; + + if (powerUp.y > window.innerHeight + 40) { + powerUp.element.remove(); + return false; + } + + return true; + }); + } + + _movePlayer(targetX, targetY) { + this._player.targetX = Math.max(5, Math.min(95, targetX)); + this._player.targetY = Math.max(10, Math.min(90, targetY)); + + const playerElement = document.getElementById('player'); + if (playerElement) { + playerElement.classList.add('moving'); + setTimeout(() => { + playerElement.classList.remove('moving'); + }, 500); + } + + this._createRippleEffect(targetX, targetY); + } + + _updatePlayer() { + const speed = 0.1; + this._player.x += (this._player.targetX - this._player.x) * speed; + this._player.y += (this._player.targetY - this._player.y) * speed; + + const playerElement = document.getElementById('player'); + if (playerElement) { + playerElement.style.left = `calc(${this._player.x}% - 20px)`; + playerElement.style.top = `calc(${this._player.y}% - 20px)`; + } + } + + _createRippleEffect(x, y) { + for (let i = 0; i < 5; i++) { + setTimeout(() => { + const particle = document.createElement('div'); + particle.className = 'particle'; + particle.style.left = `${x}%`; + particle.style.top = `${y}%`; + particle.style.animation = `particleSpread 1s ease-out forwards`; + + const riverCanvas = document.getElementById('river-canvas'); + if (riverCanvas) { + riverCanvas.appendChild(particle); + + setTimeout(() => { + particle.remove(); + }, 1000); + } + }, i * 100); + } + } + + _updateParticles() { + if (Math.random() < 0.1) { + const particle = document.createElement('div'); + particle.className = 'particle'; + particle.style.left = `${Math.random() * 100}%`; + particle.style.top = '-5px'; + particle.style.animation = `particleFlow 3s linear forwards`; + + const riverCanvas = document.getElementById('river-canvas'); + if (riverCanvas) { + riverCanvas.appendChild(particle); + + setTimeout(() => { + particle.remove(); + }, 3000); + } + } + } + + _checkCollisions() { + const playerRect = this._getPlayerRect(); + + this._floatingWords.forEach((word, index) => { + const wordRect = this._getElementRect(word.element); + + if (this._isColliding(playerRect, wordRect)) { + this._handleWordCollision(word, index); + } + }); + + this._powerUps.forEach((powerUp, index) => { + const powerUpRect = this._getElementRect(powerUp.element); + + if (this._isColliding(playerRect, powerUpRect)) { + this._handlePowerUpCollision(powerUp, index); + } + }); + } + + _getPlayerRect() { + const playerElement = document.getElementById('player'); + if (!playerElement) return { x: 0, y: 0, width: 0, height: 0 }; + + const rect = playerElement.getBoundingClientRect(); + const canvas = document.getElementById('river-canvas').getBoundingClientRect(); + + return { + x: rect.left - canvas.left, + y: rect.top - canvas.top, + width: rect.width, + height: rect.height + }; + } + + _getElementRect(element) { + const rect = element.getBoundingClientRect(); + const canvas = document.getElementById('river-canvas').getBoundingClientRect(); + + return { + x: rect.left - canvas.left, + y: rect.top - canvas.top, + width: rect.width, + height: rect.height + }; + } + + _isColliding(rect1, rect2) { + return rect1.x < rect2.x + rect2.width && + rect1.x + rect1.width > rect2.x && + rect1.y < rect2.y + rect2.height && + rect1.y + rect1.height > rect2.y; + } + + _handleWordClick(wordElement) { + const wordData = wordElement.wordData; + + if (wordData.french === this._currentTarget.french) { + this._collectWord(wordElement, true); + } else { + this._missWord(wordElement); + } + } + + _handleWordCollision(word, index) { + if (word.wordData.french === this._currentTarget.french) { + this._collectWord(word.element, true); + } else { + this._missWord(word.element); + } + + this._floatingWords.splice(index, 1); + } + + _collectWord(wordElement, isCorrect) { + wordElement.classList.add('collected'); + + if (isCorrect) { + this._score += 10 + (this._level * 2); + this._wordsCollected++; + + this._eventBus.emit('game:score-update', { + gameId: 'river-run', + score: this._score, + module: this.name + }); + + this._setNextTarget(); + this._playSuccessSound(wordElement.textContent); + } + + setTimeout(() => { + wordElement.remove(); + }, 800); + } + + _missWord(wordElement) { + wordElement.classList.add('missed'); + this._loseLife(); + + setTimeout(() => { + wordElement.remove(); + }, 600); + } + + _handlePowerUpCollision(powerUp, index) { + this._activatePowerUp(powerUp.type); + powerUp.element.remove(); + this._powerUps.splice(index, 1); + } + + _activatePowerUp(type) { + switch (type) { + case 'slowTime': + this._speed *= 0.5; + setTimeout(() => { + this._speed *= 2; + }, 3000); + break; + } + } + + _updateDifficulty() { + const timeElapsed = Date.now() - this._gameStartTime; + 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); + } + } + + _playSuccessSound(word) { + if ('speechSynthesis' in window) { + const utterance = new SpeechSynthesisUtterance(word.trim()); + utterance.lang = 'fr-FR'; + utterance.rate = 1.0; + speechSynthesis.speak(utterance); + } + } + + _loseLife() { + this._lives--; + + if (this._lives <= 0) { + this._gameOver(); + } + } + + _gameOver() { + this._isRunning = false; + + const accuracy = this._wordsCollected > 0 ? Math.round((this._wordsCollected / (this._wordsCollected + (3 - this._lives))) * 100) : 0; + + // Handle localStorage best score + const currentScore = this._score; + const bestScore = parseInt(localStorage.getItem('river-run-best-score') || '0'); + const isNewBest = currentScore > bestScore; + + if (isNewBest) { + localStorage.setItem('river-run-best-score', currentScore.toString()); + } + + this._showVictoryPopup({ + gameTitle: 'River Run', + currentScore, + bestScore: isNewBest ? currentScore : bestScore, + isNewBest, + stats: { + 'Words Collected': this._wordsCollected, + 'Level Reached': this._level, + 'Accuracy': `${accuracy}%`, + 'Lives Remaining': this._lives + } + }); + } + + _endGame() { + this._eventBus.emit('game:end', { + gameId: 'river-run', + score: this._score, + module: this.name + }); + } + + _updateHUD() { + const scoreDisplay = document.getElementById('score-display'); + const livesDisplay = document.getElementById('lives-display'); + const wordsDisplay = document.getElementById('words-display'); + const levelDisplay = document.getElementById('level-display'); + const speedDisplay = document.getElementById('speed-display'); + + if (scoreDisplay) scoreDisplay.textContent = this._score; + if (livesDisplay) livesDisplay.textContent = this._lives; + if (wordsDisplay) wordsDisplay.textContent = this._wordsCollected; + if (levelDisplay) levelDisplay.textContent = this._level; + if (speedDisplay) speedDisplay.textContent = this._speed.toFixed(1) + 'x'; + } + + _restart() { + this._isRunning = false; + this._score = 0; + this._lives = this._config.initialLives; + this._level = 1; + this._speed = this._config.initialSpeed; + this._wordsCollected = 0; + this._riverOffset = 0; + + this._player.x = 50; + this._player.y = 80; + this._player.targetX = 50; + this._player.targetY = 80; + + this._floatingWords = []; + this._powerUps = []; + this._particles = []; + + this._lastSpawn = 0; + this._config.spawnInterval = 1000; + this._gameStartTime = Date.now(); + + this._wordsSpawnedSinceTarget = 0; + this._generateTargetQueue(); + + const riverCanvas = document.getElementById('river-canvas'); + if (riverCanvas) { + const words = riverCanvas.querySelectorAll('.floating-word'); + const powerUps = riverCanvas.querySelectorAll('.power-up'); + const particles = riverCanvas.querySelectorAll('.particle'); + + words.forEach(word => word.remove()); + powerUps.forEach(powerUp => powerUp.remove()); + particles.forEach(particle => particle.remove()); + } + + const gameOverModal = document.querySelector('.game-over-modal'); + if (gameOverModal) { + gameOverModal.remove(); + } + + const targetDisplay = document.getElementById('target-display'); + if (targetDisplay) { + targetDisplay.textContent = 'Click to Start!'; + } + + this._updateHUD(); + console.log('River Run restarted'); + } + + _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; + } + + _injectCSS() { + const cssId = 'river-run-styles'; + if (document.getElementById(cssId)) return; + + const style = document.createElement('style'); + style.id = cssId; + style.textContent = ` + .river-run-wrapper { + background: linear-gradient(180deg, #87CEEB 0%, #4682B4 50%, #2F4F4F 100%); + position: relative; + overflow: hidden; + height: 100vh; + cursor: crosshair; + } + + .river-run-hud { + position: absolute; + top: 20px; + left: 20px; + right: 20px; + display: flex; + justify-content: space-between; + z-index: 100; + color: white; + font-weight: bold; + text-shadow: 0 2px 4px rgba(0,0,0,0.5); + } + + .hud-left, .hud-right { + display: flex; + gap: 20px; + align-items: center; + } + + .target-display { + background: rgba(255,255,255,0.9); + color: #333; + padding: 10px 20px; + border-radius: 25px; + font-size: 1.2em; + font-weight: bold; + box-shadow: 0 4px 15px rgba(0,0,0,0.2); + animation: targetGlow 2s ease-in-out infinite alternate; + } + + @keyframes targetGlow { + from { box-shadow: 0 4px 15px rgba(0,0,0,0.2); } + to { box-shadow: 0 4px 20px rgba(255,215,0,0.6); } + } + + .river-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: + radial-gradient(ellipse at center top, rgba(135,206,235,0.3) 0%, transparent 70%), + linear-gradient(0deg, + rgba(70,130,180,0.1) 0%, + rgba(135,206,235,0.05) 50%, + rgba(173,216,230,0.1) 100% + ); + } + + .river-waves { + position: absolute; + width: 120%; + height: 100%; + background: + repeating-linear-gradient( + 0deg, + transparent 0px, + rgba(255,255,255,0.1) 2px, + transparent 4px, + transparent 20px + ); + animation: riverFlow 3s linear infinite; + } + + @keyframes riverFlow { + from { transform: translateY(-20px); } + to { transform: translateY(0px); } + } + + .player { + position: absolute; + width: 40px; + height: 40px; + background: linear-gradient(45deg, #8B4513, #A0522D); + border-radius: 50% 50% 50% 50% / 60% 60% 40% 40%; + box-shadow: + 0 2px 10px rgba(0,0,0,0.3), + inset 0 2px 5px rgba(255,255,255,0.3); + transition: all 0.3s ease-out; + z-index: 50; + transform-origin: center; + } + + .player::before { + content: '🛶'; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 20px; + filter: drop-shadow(0 1px 2px rgba(0,0,0,0.3)); + } + + .player.moving { + animation: playerRipple 0.5s ease-out; + } + + @keyframes playerRipple { + 0% { transform: scale(1); } + 50% { transform: scale(1.1); } + 100% { transform: scale(1); } + } + + .floating-word { + position: absolute; + background: rgba(255,255,255,0.95); + border: 3px solid #4682B4; + border-radius: 15px; + padding: 8px 15px; + font-size: 1.1em; + font-weight: bold; + color: #333; + cursor: pointer; + transition: all 0.2s ease; + z-index: 40; + box-shadow: + 0 4px 15px rgba(0,0,0,0.2), + 0 0 0 0 rgba(70,130,180,0.4); + animation: wordFloat 3s ease-in-out infinite alternate; + } + + @keyframes wordFloat { + from { transform: translateY(0px) rotate(-1deg); } + to { transform: translateY(-5px) rotate(1deg); } + } + + .floating-word:hover { + transform: scale(1.1) translateY(-3px); + box-shadow: + 0 6px 20px rgba(0,0,0,0.3), + 0 0 20px rgba(70,130,180,0.6); + } + + .floating-word.collected { + animation: wordCollected 0.8s ease-out forwards; + } + + @keyframes wordCollected { + 0% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.3); + opacity: 0.8; + } + 100% { + transform: scale(0) translateY(-50px); + opacity: 0; + } + } + + .floating-word.missed { + animation: wordMissed 0.6s ease-out forwards; + } + + @keyframes wordMissed { + 0% { + transform: scale(1); + opacity: 1; + background: rgba(255,255,255,0.95); + } + 100% { + transform: scale(0.8); + opacity: 0; + background: rgba(220,20,60,0.8); + } + } + + .power-up { + position: absolute; + width: 35px; + height: 35px; + border-radius: 50%; + background: linear-gradient(45deg, #FF6B35, #F7931E); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.2em; + cursor: pointer; + z-index: 45; + animation: powerUpFloat 2s ease-in-out infinite alternate; + box-shadow: 0 4px 15px rgba(255,107,53,0.4); + } + + @keyframes powerUpFloat { + from { transform: translateY(0px) scale(1); } + to { transform: translateY(-8px) scale(1.05); } + } + + .game-over-modal { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(255,255,255,0.95); + padding: 40px; + border-radius: 20px; + text-align: center; + z-index: 200; + box-shadow: 0 10px 30px rgba(0,0,0,0.3); + backdrop-filter: blur(10px); + } + + .game-over-title { + font-size: 2.5em; + margin-bottom: 20px; + color: #4682B4; + text-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + + .game-over-stats { + font-size: 1.3em; + margin-bottom: 30px; + line-height: 1.6; + color: #333; + } + + .river-btn { + background: linear-gradient(45deg, #4682B4, #5F9EA0); + color: white; + border: none; + padding: 15px 30px; + border-radius: 25px; + font-size: 1.1em; + font-weight: bold; + cursor: pointer; + margin: 0 10px; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(70,130,180,0.3); + } + + .river-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(70,130,180,0.4); + } + + .particle { + position: absolute; + width: 4px; + height: 4px; + background: rgba(255,255,255,0.7); + border-radius: 50%; + pointer-events: none; + z-index: 30; + } + + .game-error { + background: rgba(239, 68, 68, 0.1); + border: 2px solid #ef4444; + border-radius: 15px; + padding: 30px; + text-align: center; + color: #374151; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-width: 500px; + } + + .game-error h3 { + color: #ef4444; + margin-bottom: 15px; + } + + .back-btn { + background: linear-gradient(135deg, #6b7280, #4b5563); + color: white; + border: none; + padding: 12px 25px; + border-radius: 25px; + font-size: 1.1em; + font-weight: bold; + cursor: pointer; + margin-top: 20px; + transition: all 0.3s ease; + } + + .back-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(107, 114, 128, 0.4); + } + + @keyframes particleSpread { + 0% { + transform: scale(1) translate(0, 0); + opacity: 1; + } + 100% { + transform: scale(0) translate(50px, 50px); + opacity: 0; + } + } + + @keyframes particleFlow { + 0% { + transform: translateY(0); + opacity: 0.7; + } + 100% { + transform: translateY(100vh); + opacity: 0; + } + } + + @media (max-width: 768px) { + .river-run-hud { + flex-direction: column; + gap: 10px; + } + + .floating-word { + font-size: 1em; + padding: 6px 12px; + } + + .target-display { + font-size: 1em; + padding: 8px 15px; + } + + .hud-left, .hud-right { + justify-content: center; + } + } + + /* Victory Popup Styles */ + .victory-popup { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: fadeIn 0.3s ease-out; + } + + .victory-content { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 20px; + padding: 40px; + text-align: center; + color: white; + max-width: 500px; + width: 90%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: slideUp 0.4s ease-out; + } + + .victory-header { + margin-bottom: 30px; + } + + .victory-icon { + font-size: 4rem; + margin-bottom: 15px; + animation: bounce 0.6s ease-out; + } + + .victory-title { + font-size: 2rem; + font-weight: bold; + margin: 0 0 10px 0; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + + .new-best-badge { + background: linear-gradient(45deg, #f093fb 0%, #f5576c 100%); + color: white; + padding: 8px 20px; + border-radius: 25px; + font-size: 0.9rem; + font-weight: bold; + display: inline-block; + margin-top: 10px; + animation: glow 1s ease-in-out infinite alternate; + } + + .victory-scores { + display: flex; + justify-content: space-around; + margin: 30px 0; + gap: 20px; + } + + .score-display { + background: rgba(255, 255, 255, 0.1); + border-radius: 15px; + padding: 20px; + flex: 1; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + } + + .score-label { + font-size: 0.9rem; + opacity: 0.9; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 1px; + } + + .score-value { + font-size: 2rem; + font-weight: bold; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + + .victory-stats { + background: rgba(255, 255, 255, 0.1); + border-radius: 15px; + padding: 20px; + margin: 30px 0; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + } + + .stat-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + + .stat-row:last-child { + border-bottom: none; + } + + .stat-name { + font-size: 0.95rem; + opacity: 0.9; + } + + .stat-value { + font-weight: bold; + font-size: 1rem; + } + + .victory-buttons { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 30px; + } + + .victory-btn { + padding: 15px 30px; + border: none; + border-radius: 25px; + font-size: 1rem; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; + text-transform: uppercase; + letter-spacing: 1px; + } + + .victory-btn.primary { + background: linear-gradient(45deg, #4facfe 0%, #00f2fe 100%); + color: white; + box-shadow: 0 8px 25px rgba(79, 172, 254, 0.3); + } + + .victory-btn.primary:hover { + transform: translateY(-2px); + box-shadow: 0 12px 35px rgba(79, 172, 254, 0.4); + } + + .victory-btn.secondary { + background: linear-gradient(45deg, #a8edea 0%, #fed6e3 100%); + color: #333; + box-shadow: 0 8px 25px rgba(168, 237, 234, 0.3); + } + + .victory-btn.secondary:hover { + transform: translateY(-2px); + box-shadow: 0 12px 35px rgba(168, 237, 234, 0.4); + } + + .victory-btn.tertiary { + background: rgba(255, 255, 255, 0.2); + color: white; + border: 2px solid rgba(255, 255, 255, 0.3); + backdrop-filter: blur(10px); + } + + .victory-btn.tertiary:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); + } + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px) scale(0.9); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes bounce { + 0%, 20%, 50%, 80%, 100% { + transform: translateY(0); + } + 40% { + transform: translateY(-10px); + } + 60% { + transform: translateY(-5px); + } + } + + @keyframes glow { + from { + box-shadow: 0 0 20px rgba(245, 87, 108, 0.5); + } + to { + box-shadow: 0 0 30px rgba(245, 87, 108, 0.8); + } + } + + @media (max-width: 768px) { + .victory-content { + padding: 30px 20px; + width: 95%; + } + + .victory-scores { + flex-direction: column; + gap: 15px; + } + + .victory-icon { + font-size: 3rem; + } + + .victory-title { + font-size: 1.5rem; + } + + .victory-buttons { + gap: 10px; + } + + .victory-btn { + padding: 12px 25px; + font-size: 0.9rem; + } + } + `; + + document.head.appendChild(style); + } + + _showVictoryPopup({ gameTitle, currentScore, bestScore, isNewBest, stats }) { + const popup = document.createElement('div'); + popup.className = 'victory-popup'; + popup.innerHTML = ` +
+
+
🌊
+

${gameTitle} Complete!

+ ${isNewBest ? '
🎉 New Best Score!
' : ''} +
+ +
+
+
Your Score
+
${currentScore}
+
+
+
Best Score
+
${bestScore}
+
+
+ +
+ ${Object.entries(stats).map(([key, value]) => ` +
+ ${key} + ${value} +
+ `).join('')} +
+ +
+ + + +
+
+ `; + + document.body.appendChild(popup); + + // Emit completion event after showing popup + setTimeout(() => { + this._endGame(); + }, 1000); + } + + _removeCSS() { + const cssElement = document.getElementById('river-run-styles'); + if (cssElement) { + cssElement.remove(); + } + + if (window.currentRiverGame === this) { + delete window.currentRiverGame; + } + } +} + +export default RiverRun; \ No newline at end of file diff --git a/src/games/StoryBuilder.js b/src/games/StoryBuilder.js new file mode 100644 index 0000000..fbdda3a --- /dev/null +++ b/src/games/StoryBuilder.js @@ -0,0 +1,1264 @@ +import Module from '../core/Module.js'; + +class StoryBuilder extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); + + if (!dependencies.eventBus || !dependencies.content) { + throw new Error('StoryBuilder requires eventBus and content dependencies'); + } + + this._eventBus = dependencies.eventBus; + this._content = dependencies.content; + this._config = { + container: null, + difficulty: 'medium', + maxElements: 6, + timeLimit: 180, + ...config + }; + + this._score = 0; + this._currentStory = []; + this._availableElements = []; + this._storyTarget = null; + this._gameMode = 'vocabulary'; + this._vocabulary = []; + this._wordsByType = {}; + this._timeLeft = this._config.timeLimit; + this._isRunning = false; + this._gameTimer = null; + this._gameContainer = null; + this._draggedElement = null; + + Object.seal(this); + } + + static getMetadata() { + return { + id: 'story-builder', + name: 'Story Builder', + description: 'Build coherent stories by dragging and arranging words', + version: '2.0.0', + author: 'Class Generator', + category: 'creative', + tags: ['story', 'building', 'vocabulary', 'creativity'], + difficulty: { + min: 1, + max: 4, + default: 2 + }, + estimatedDuration: 10, + requiredContent: ['vocabulary'] + }; + } + + static getCompatibilityScore(content) { + if (!content || !content.vocabulary) { + return 0; + } + + let score = 40; + + if (typeof content.vocabulary === 'object') { + const vocabCount = Object.keys(content.vocabulary).length; + if (vocabCount >= 6) score += 30; + if (vocabCount >= 12) score += 15; + if (vocabCount >= 20) score += 10; + + const hasTypes = Object.values(content.vocabulary).some(word => + typeof word === 'object' && word.type + ); + if (hasTypes) score += 5; + } + + return Math.min(score, 100); + } + + async init() { + this._validateNotDestroyed(); + + // Validate container + if (!this._config.container) { + throw new Error('Game container is required'); + } + + this._eventBus.on('game:start', this._handleGameStart.bind(this), this.name); + this._eventBus.on('game:stop', this._handleGameStop.bind(this), this.name); + this._eventBus.on('navigation:change', this._handleNavigationChange.bind(this), this.name); + + this._injectCSS(); + + // Start game immediately + try { + this._gameContainer = this._config.container; + const content = this._content; + + if (!content) { + throw new Error('No content available - please select a book or chapter first'); + } + + this._extractVocabulary(content); + + if (!this._vocabulary || this._vocabulary.length < 6) { + const vocabCount = this._vocabulary ? this._vocabulary.length : 0; + throw new Error(`Not enough vocabulary for Story Builder (found ${vocabCount}, minimum 6 required). Please select content with more vocabulary words.`); + } + + this._createGameBoard(); + this._setupEventListeners(); + this._loadStoryContent(); + + // Emit game ready event + this._eventBus.emit('game:ready', { + gameId: 'story-builder', + instanceId: this.name, + vocabulary: this._vocabulary.length + }, this.name); + + } catch (error) { + console.error('Error starting Story Builder:', error); + this._showInitError(error.message); + } + + this._setInitialized(); + } + + async destroy() { + this._validateNotDestroyed(); + + this._cleanup(); + this._removeCSS(); + this._eventBus.off('game:start', this.name); + this._eventBus.off('game:stop', this.name); + this._eventBus.off('navigation:change', this.name); + + this._setDestroyed(); + } + + _handleGameStart(event) { + this._validateInitialized(); + if (event.gameId === 'story-builder') { + this._startGame(); + } + } + + _handleGameStop(event) { + this._validateInitialized(); + if (event.gameId === 'story-builder') { + this._stopGame(); + } + } + + _handleNavigationChange(event) { + this._validateInitialized(); + if (event.from === '/games/story-builder') { + this._cleanup(); + } + } + + async _startGame() { + try { + this._gameContainer = document.getElementById('game-content'); + if (!this._gameContainer) { + throw new Error('Game container not found'); + } + + const content = await this._content.getCurrentContent(); + if (!content) { + throw new Error('No content available'); + } + + this._extractVocabulary(content); + + if (this._vocabulary.length < 6) { + throw new Error('Not enough vocabulary for Story Builder (minimum 6 words required)'); + } + + this._createGameBoard(); + this._setupEventListeners(); + this._loadStoryContent(); + + } catch (error) { + console.error('Error starting Story Builder:', error); + this._showInitError(error.message); + } + } + + _stopGame() { + this._cleanup(); + } + + _cleanup() { + this._isRunning = false; + if (this._gameTimer) { + clearInterval(this._gameTimer); + this._gameTimer = null; + } + if (this._gameContainer) { + this._gameContainer.innerHTML = ''; + } + } + + _showInitError(message) { + this._gameContainer.innerHTML = ` +
+

❌ Loading Error

+

${message}

+

The game requires vocabulary with word types (noun, verb, adjective, etc.).

+ +
+ `; + } + + _extractVocabulary(content) { + this._vocabulary = []; + + console.log('StoryBuilder: Extracting vocabulary from content:', content); + + if (!content) { + console.warn('StoryBuilder: No content provided to _extractVocabulary'); + return; + } + + if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) { + this._vocabulary = Object.entries(content.vocabulary).map(([word, data]) => { + if (typeof data === 'object' && data.translation && typeof data.translation === 'string') { + return { + original: word, + translation: data.translation.split(';')[0], + fullTranslation: data.translation, + type: data.type || 'general', + audio: data.audio, + image: data.image, + examples: data.examples, + pronunciation: data.pronunciation, + category: data.type || 'general' + }; + } else if (typeof data === 'string' && data.length > 0) { + return { + original: word, + translation: data.split(';')[0], + fullTranslation: data, + type: 'general', + category: 'general' + }; + } + return null; + }).filter(Boolean); + } + + this._vocabulary = this._vocabulary.filter(item => + item && + typeof item.original === 'string' && + typeof item.translation === 'string' && + item.original.trim() !== '' && + item.translation.trim() !== '' + ); + + if (!content.vocabulary) { + console.warn('StoryBuilder: Content has no vocabulary property'); + } else if (Object.keys(content.vocabulary).length === 0) { + console.warn('StoryBuilder: Content vocabulary is empty'); + } else { + console.log('StoryBuilder: Content vocabulary keys:', Object.keys(content.vocabulary)); + } + + this._wordsByType = this._groupVocabularyByType(this._vocabulary); + console.log(`StoryBuilder: ${this._vocabulary.length} words loaded`, this._vocabulary.slice(0, 3)); + } + + _groupVocabularyByType(vocabulary) { + const grouped = {}; + + vocabulary.forEach(word => { + const type = word.type || 'general'; + if (!grouped[type]) { + grouped[type] = []; + } + grouped[type].push(word); + }); + + return grouped; + } + + _createGameBoard() { + this._gameContainer.innerHTML = ` +
+
+ + + + +
+ +
+
+

Objective:

+

Choose a mode and let's start!

+
+
+
+ ${this._timeLeft} + Time +
+
+ 0/${this._config.maxElements} + Progress +
+
+
+ +
+
+ +
+ +
+
Drag elements here to build your story
+
+
+ +
+ +
+ +
+ + + + +
+ + +
+ `; + } + + _setupEventListeners() { + document.querySelectorAll('.mode-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + if (this._isRunning) return; + + document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + this._gameMode = btn.dataset.mode; + + this._loadStoryContent(); + }); + }); + + document.getElementById('start-btn').addEventListener('click', () => this._start()); + document.getElementById('check-btn').addEventListener('click', () => this._checkStory()); + document.getElementById('hint-btn').addEventListener('click', () => this._showHint()); + document.getElementById('restart-btn').addEventListener('click', () => this._restart()); + + this._setupDragAndDrop(); + } + + _loadStoryContent() { + switch (this._gameMode) { + case 'vocabulary': + this._setupVocabularyMode(); + break; + case 'sequence': + this._setupSequenceMode(); + break; + case 'dialogue': + this._setupDialogueMode(); + break; + case 'scenario': + this._setupScenarioMode(); + break; + default: + this._setupVocabularyMode(); + } + } + + _setupVocabularyMode() { + if (Object.keys(this._wordsByType).length === 0) { + this._setupFallbackContent(); + return; + } + + this._storyTarget = this._createStoryTemplate(); + this._availableElements = this._selectWordsForStory(); + + document.getElementById('objective-text').textContent = + 'Build a coherent story using these words! Use different types: nouns, verbs, adjectives...'; + } + + _createStoryTemplate() { + const types = Object.keys(this._wordsByType); + + const templates = [ + { pattern: ['noun', 'verb', 'adjective', 'noun'], name: 'Simple Story' }, + { pattern: ['adjective', 'noun', 'verb', 'noun'], name: 'Descriptive Story' }, + { pattern: ['noun', 'verb', 'adjective', 'noun', 'verb'], name: 'Action Story' }, + { pattern: ['article', 'adjective', 'noun', 'verb', 'adverb'], name: 'Rich Story' } + ]; + + const availableTemplate = templates.find(template => + template.pattern.every(type => + types.includes(type) && this._wordsByType[type].length > 0 + ) + ); + + if (availableTemplate) { + return { + template: availableTemplate, + requiredTypes: availableTemplate.pattern + }; + } + + return { + template: { pattern: types.slice(0, 4), name: 'Custom Story' }, + requiredTypes: types.slice(0, 4) + }; + } + + _selectWordsForStory() { + const words = []; + + if (this._storyTarget && this._storyTarget.requiredTypes) { + this._storyTarget.requiredTypes.forEach(type => { + if (this._wordsByType[type] && this._wordsByType[type].length > 0) { + const typeWords = this._shuffleArray([...this._wordsByType[type]]).slice(0, 3); + words.push(...typeWords); + } + }); + } + + const allTypes = Object.keys(this._wordsByType); + allTypes.forEach(type => { + if (this._wordsByType[type] && this._wordsByType[type].length > 0) { + const extraWords = this._shuffleArray([...this._wordsByType[type]]).slice(0, 1); + words.push(...extraWords); + } + }); + + const uniqueWords = words.filter((word, index, self) => + self.findIndex(w => w.original === word.original) === index + ); + + return this._shuffleArray(uniqueWords).slice(0, this._config.maxElements); + } + + _setupSequenceMode() { + const actionWords = this._wordsByType.verb || []; + const objectWords = this._wordsByType.noun || []; + + if (actionWords.length >= 2 && objectWords.length >= 2) { + this._storyTarget = { + type: 'sequence', + steps: [ + { order: 1, text: `First: ${actionWords[0].original}`, word: actionWords[0] }, + { order: 2, text: `Then: ${actionWords[1].original}`, word: actionWords[1] }, + { order: 3, text: `With: ${objectWords[0].original}`, word: objectWords[0] }, + { order: 4, text: `Finally: ${objectWords[1].original}`, word: objectWords[1] } + ] + }; + + this._availableElements = this._shuffleArray([...this._storyTarget.steps]); + document.getElementById('objective-text').textContent = + 'Put these actions in logical order!'; + } else { + this._setupVocabularyMode(); + } + } + + _setupDialogueMode() { + const greetings = this._wordsByType.greeting || []; + const nouns = this._wordsByType.noun || []; + const verbs = this._wordsByType.verb || []; + + if (greetings.length >= 1 && (nouns.length >= 2 || verbs.length >= 2)) { + const dialogue = [ + { speaker: 'A', text: greetings[0].original, word: greetings[0] }, + { speaker: 'B', text: greetings[0].translation, word: greetings[0] } + ]; + + if (verbs.length >= 1) { + dialogue.push({ speaker: 'A', text: verbs[0].original, word: verbs[0] }); + } + if (nouns.length >= 1) { + dialogue.push({ speaker: 'B', text: nouns[0].original, word: nouns[0] }); + } + + this._storyTarget = { type: 'dialogue', conversation: dialogue }; + this._availableElements = this._shuffleArray([...dialogue]); + + document.getElementById('objective-text').textContent = + 'Reconstruct this dialogue in the right order!'; + } else { + this._setupVocabularyMode(); + } + } + + _setupScenarioMode() { + const allWords = Object.values(this._wordsByType).flat(); + + if (allWords.length >= 4) { + const scenario = { + context: 'Daily Life', + elements: this._shuffleArray(allWords).slice(0, 6) + }; + + this._storyTarget = { type: 'scenario', scenario }; + this._availableElements = [...scenario.elements]; + + document.getElementById('objective-text').textContent = + `Create a story about: "${scenario.context}" using these words!`; + } else { + this._setupVocabularyMode(); + } + } + + _setupFallbackContent() { + if (this._vocabulary.length >= 4) { + this._availableElements = this._shuffleArray([...this._vocabulary]).slice(0, 6); + this._gameMode = 'vocabulary'; + + document.getElementById('objective-text').textContent = + 'Build a story with these words!'; + } else { + document.getElementById('objective-text').textContent = + 'Not enough vocabulary available. Please select different content.'; + } + } + + _start() { + if (this._isRunning || this._availableElements.length === 0) return; + + this._isRunning = true; + this._score = 0; + this._currentStory = []; + this._timeLeft = this._config.timeLimit; + + this._renderElements(); + this._startTimer(); + this._updateUI(); + + document.getElementById('start-btn').disabled = true; + document.getElementById('check-btn').disabled = false; + document.getElementById('hint-btn').disabled = false; + + this._showFeedback('Drag the elements in order to build your story!', 'info'); + } + + _renderElements() { + const elementsBank = document.getElementById('elements-bank'); + elementsBank.innerHTML = '

Available elements:

'; + + this._availableElements.forEach((element, index) => { + const elementDiv = this._createElement(element, index); + elementsBank.appendChild(elementDiv); + }); + } + + _createElement(element, index) { + const div = document.createElement('div'); + div.className = 'story-element'; + div.draggable = true; + div.dataset.index = index; + + if (element.original && element.translation) { + div.innerHTML = ` +
+
${element.original}
+
${element.translation}
+ ${element.type ? `
${element.type}
` : ''} +
+ `; + } else if (element.text || element.original) { + div.innerHTML = ` +
+
${element.text || element.original}
+ ${element.translation ? `
${element.translation}
` : ''} + ${element.speaker ? `
${element.speaker}:
` : ''} +
+ `; + } else if (element.word) { + div.innerHTML = ` +
+
${element.word.original}
+
${element.word.translation}
+ ${element.word.type ? `
${element.word.type}
` : ''} +
+ `; + } else if (typeof element === 'string') { + div.innerHTML = `
${element}
`; + } + + if (element.type) { + div.classList.add(`type-${element.type}`); + } + + return div; + } + + _setupDragAndDrop() { + document.addEventListener('dragstart', (e) => { + if (e.target.classList.contains('story-element')) { + this._draggedElement = e.target; + e.target.style.opacity = '0.5'; + } + }); + + document.addEventListener('dragend', (e) => { + if (e.target.classList.contains('story-element')) { + e.target.style.opacity = '1'; + this._draggedElement = null; + } + }); + + const dropZone = document.getElementById('drop-zone'); + + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('drag-over'); + }); + + dropZone.addEventListener('dragleave', () => { + dropZone.classList.remove('drag-over'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('drag-over'); + + if (this._draggedElement && this._isRunning) { + this._addToStory(this._draggedElement); + } + }); + } + + _addToStory(elementDiv) { + const index = parseInt(elementDiv.dataset.index); + const element = this._availableElements[index]; + + this._currentStory.push({ element, originalIndex: index }); + + const storyElement = elementDiv.cloneNode(true); + storyElement.classList.add('in-story'); + storyElement.draggable = false; + + const removeBtn = document.createElement('button'); + removeBtn.className = 'remove-element'; + removeBtn.innerHTML = '×'; + removeBtn.onclick = () => this._removeFromStory(storyElement, element); + storyElement.appendChild(removeBtn); + + document.getElementById('drop-zone').appendChild(storyElement); + elementDiv.style.display = 'none'; + + this._updateProgress(); + } + + _removeFromStory(storyElement, element) { + this._currentStory = this._currentStory.filter(item => item.element !== element); + storyElement.remove(); + + const originalElement = document.querySelector(`[data-index="${this._availableElements.indexOf(element)}"]`); + if (originalElement) { + originalElement.style.display = 'block'; + } + + this._updateProgress(); + } + + _checkStory() { + if (this._currentStory.length === 0) { + this._showFeedback('Add at least one element to your story!', 'error'); + return; + } + + const isCorrect = this._validateStory(); + + if (isCorrect) { + this._score += this._currentStory.length * 10; + this._showFeedback('Bravo! Perfect story! 🎉', 'success'); + + this._eventBus.emit('game:score-update', { + gameId: 'story-builder', + score: this._score, + module: this.name + }); + + setTimeout(() => { + this._nextChallenge(); + }, 2000); + } else { + this._score = Math.max(0, this._score - 5); + this._showFeedback('Almost! Check the order of your story 🤔', 'warning'); + + this._eventBus.emit('game:score-update', { + gameId: 'story-builder', + score: this._score, + module: this.name + }); + } + } + + _validateStory() { + switch (this._gameMode) { + case 'vocabulary': + return this._validateVocabularyStory(); + case 'sequence': + return this._validateSequence(); + case 'dialogue': + return this._validateDialogue(); + case 'scenario': + return this._validateScenario(); + default: + return true; + } + } + + _validateVocabularyStory() { + if (this._currentStory.length < 3) return false; + + const typesUsed = new Set(); + this._currentStory.forEach(item => { + const element = item.element; + if (element.type) { + typesUsed.add(element.type); + } + }); + + return typesUsed.size >= 2; + } + + _validateSequence() { + if (!this._storyTarget?.steps) return true; + + const expectedOrder = this._storyTarget.steps.sort((a, b) => a.order - b.order); + + if (this._currentStory.length !== expectedOrder.length) return false; + + return this._currentStory.every((item, index) => { + const expected = expectedOrder[index]; + return item.element.order === expected.order; + }); + } + + _validateDialogue() { + return this._currentStory.length >= 2; + } + + _validateScenario() { + return this._currentStory.length >= 3; + } + + _showHint() { + switch (this._gameMode) { + case 'vocabulary': + const typesAvailable = Object.keys(this._wordsByType); + this._showFeedback(`Tip: Try using different word types: ${typesAvailable.join(', ')}`, 'info'); + break; + case 'sequence': + if (this._storyTarget?.steps) { + const nextStep = this._storyTarget.steps.find(step => + !this._currentStory.some(item => item.element.order === step.order) + ); + if (nextStep) { + this._showFeedback(`Next step: "${nextStep.text}"`, 'info'); + } + } + break; + case 'dialogue': + this._showFeedback('Think about the natural order of a conversation!', 'info'); + break; + case 'scenario': + this._showFeedback('Create a coherent story in this context!', 'info'); + break; + default: + this._showFeedback('Tip: Think about the logical order of events!', 'info'); + } + } + + _nextChallenge() { + this._loadStoryContent(); + this._currentStory = []; + document.getElementById('drop-zone').innerHTML = '
Drag elements here to build your story
'; + this._renderElements(); + this._updateProgress(); + } + + _startTimer() { + this._gameTimer = setInterval(() => { + this._timeLeft--; + this._updateUI(); + + if (this._timeLeft <= 0) { + this._endGame(); + } + }, 1000); + } + + _endGame() { + this._isRunning = false; + if (this._gameTimer) { + clearInterval(this._gameTimer); + this._gameTimer = null; + } + + document.getElementById('start-btn').disabled = false; + document.getElementById('check-btn').disabled = true; + document.getElementById('hint-btn').disabled = true; + + this._eventBus.emit('game:end', { + gameId: 'story-builder', + score: this._score, + module: this.name + }); + } + + _restart() { + this._endGame(); + this._score = 0; + this._currentStory = []; + this._timeLeft = this._config.timeLimit; + + document.getElementById('drop-zone').innerHTML = '
Drag elements here to build your story
'; + this._loadStoryContent(); + this._updateUI(); + } + + _updateProgress() { + document.getElementById('story-progress').textContent = + `${this._currentStory.length}/${this._config.maxElements}`; + } + + _updateUI() { + document.getElementById('time-left').textContent = this._timeLeft; + } + + _showFeedback(message, type = 'info') { + const feedbackArea = document.getElementById('feedback-area'); + feedbackArea.innerHTML = `
${message}
`; + } + + _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; + } + + _injectCSS() { + const cssId = 'story-builder-styles'; + if (document.getElementById(cssId)) return; + + const style = document.createElement('style'); + style.id = cssId; + style.textContent = ` + .story-builder-wrapper { + max-width: 1000px; + margin: 0 auto; + padding: 20px; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + } + + .mode-selector { + display: flex; + gap: 10px; + margin-bottom: 20px; + justify-content: center; + flex-wrap: wrap; + } + + .mode-btn { + padding: 10px 20px; + border: 2px solid #e5e7eb; + border-radius: 25px; + background: white; + cursor: pointer; + transition: all 0.3s ease; + font-weight: 500; + } + + .mode-btn.active { + background: linear-gradient(135deg, #3b82f6, #1d4ed8); + color: white; + border-color: #3b82f6; + } + + .mode-btn:hover:not(.active) { + border-color: #3b82f6; + background: #f0f9ff; + } + + .game-info { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 20px; + margin-bottom: 25px; + } + + .story-objective { + background: linear-gradient(135deg, #f0f9ff, #dbeafe); + border-radius: 12px; + padding: 20px; + border-left: 4px solid #3b82f6; + } + + .story-objective h3 { + color: #3b82f6; + margin: 0 0 8px 0; + } + + .story-objective p { + margin: 0; + color: #374151; + } + + .game-stats { + display: flex; + justify-content: space-around; + align-items: center; + background: white; + border-radius: 12px; + padding: 20px; + border: 2px solid #e5e7eb; + } + + .stat-item { + text-align: center; + } + + .stat-value { + font-size: 1.5em; + font-weight: bold; + display: block; + color: #3b82f6; + } + + .stat-label { + font-size: 0.9em; + color: #6b7280; + } + + .story-construction { + background: #f8f9fa; + border-radius: 12px; + padding: 20px; + margin: 20px 0; + } + + .story-target { + background: white; + border-radius: 8px; + padding: 15px; + margin-bottom: 15px; + border-left: 4px solid #3b82f6; + } + + .drop-zone { + min-height: 120px; + border: 3px dashed #ddd; + border-radius: 12px; + padding: 20px; + text-align: center; + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + justify-content: center; + transition: all 0.3s ease; + } + + .drop-zone.drag-over { + border-color: #3b82f6; + background: rgba(59, 130, 246, 0.1); + } + + .drop-hint { + color: #6b7280; + font-style: italic; + } + + .elements-bank { + background: white; + border-radius: 12px; + padding: 20px; + margin: 20px 0; + border: 2px solid #e5e7eb; + } + + .elements-bank h4 { + margin-bottom: 15px; + color: #3b82f6; + } + + .story-element { + display: inline-block; + background: white; + border: 2px solid #e5e7eb; + border-radius: 8px; + padding: 12px; + margin: 8px; + cursor: grab; + transition: all 0.3s ease; + position: relative; + min-width: 150px; + } + + .story-element:hover { + border-color: #3b82f6; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0,0,0,0.1); + } + + .story-element:active { + cursor: grabbing; + } + + .story-element.in-story { + background: #10b981; + color: white; + border-color: #10b981; + cursor: default; + margin: 5px; + } + + .element-content { + text-align: center; + } + + .original { + font-weight: 600; + margin-bottom: 4px; + } + + .translation { + font-size: 0.9rem; + color: #6b7280; + } + + .word-type { + font-size: 0.8rem; + color: #9ca3af; + font-style: italic; + margin-top: 2px; + } + + .speaker { + font-size: 0.8rem; + color: #ef4444; + font-weight: bold; + margin-bottom: 2px; + } + + .story-element.in-story .translation { + color: rgba(255,255,255,0.8); + } + + .story-element.in-story .word-type { + color: rgba(255,255,255,0.6); + } + + .story-element.type-noun { + border-left: 4px solid #3b82f6; + } + + .story-element.type-verb { + border-left: 4px solid #10b981; + } + + .story-element.type-adjective { + border-left: 4px solid #f59e0b; + } + + .story-element.type-adverb { + border-left: 4px solid #8b5cf6; + } + + .story-element.type-greeting { + border-left: 4px solid #ef4444; + } + + .remove-element { + position: absolute; + top: -5px; + right: -5px; + width: 20px; + height: 20px; + background: #ef4444; + color: white; + border: none; + border-radius: 50%; + font-size: 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + } + + .game-controls { + display: flex; + justify-content: center; + gap: 15px; + margin: 25px 0; + } + + .control-btn { + padding: 12px 25px; + border: none; + border-radius: 25px; + font-size: 1.1em; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; + background: linear-gradient(135deg, #3b82f6, #1d4ed8); + color: white; + } + + .control-btn:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(59, 130, 246, 0.4); + } + + .control-btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + } + + .feedback-area { + background: #f3f4f6; + border-radius: 12px; + padding: 20px; + text-align: center; + min-height: 60px; + display: flex; + align-items: center; + justify-content: center; + } + + .instruction { + font-size: 1.1em; + font-weight: 500; + color: #374151; + } + + .instruction.success { + color: #10b981; + font-weight: bold; + } + + .instruction.error { + color: #ef4444; + font-weight: bold; + } + + .instruction.warning { + color: #f59e0b; + font-weight: bold; + } + + .instruction.info { + color: #3b82f6; + font-weight: bold; + } + + .game-error { + background: rgba(239, 68, 68, 0.1); + border: 2px solid #ef4444; + border-radius: 15px; + padding: 30px; + text-align: center; + color: #374151; + } + + .game-error h3 { + color: #ef4444; + margin-bottom: 15px; + } + + .back-btn { + background: linear-gradient(135deg, #6b7280, #4b5563); + color: white; + border: none; + padding: 12px 25px; + border-radius: 25px; + font-size: 1.1em; + font-weight: bold; + cursor: pointer; + margin-top: 20px; + transition: all 0.3s ease; + } + + .back-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 15px rgba(107, 114, 128, 0.4); + } + + @media (max-width: 768px) { + .story-builder-wrapper { + padding: 15px; + } + + .game-info { + grid-template-columns: 1fr; + gap: 15px; + } + + .game-stats { + flex-direction: column; + gap: 15px; + } + + .story-element { + min-width: 120px; + padding: 8px; + margin: 5px; + } + + .drop-zone { + min-height: 100px; + padding: 15px; + } + + .elements-bank { + padding: 15px; + } + + .game-controls { + flex-direction: column; + align-items: center; + } + + .control-btn { + width: 100%; + max-width: 200px; + } + } + `; + + document.head.appendChild(style); + } + + _removeCSS() { + const cssElement = document.getElementById('story-builder-styles'); + if (cssElement) { + cssElement.remove(); + } + } +} + +export default StoryBuilder; \ No newline at end of file diff --git a/src/games/StoryReader.js b/src/games/StoryReader.js new file mode 100644 index 0000000..61d5e83 --- /dev/null +++ b/src/games/StoryReader.js @@ -0,0 +1,1168 @@ +import Module from '../core/Module.js'; + +/** + * StoryReader - Interactive story reading game with vocabulary support + * Allows reading stories sentence by sentence with word translations and TTS + */ +class StoryReader extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); + + // Validate dependencies + if (!dependencies.eventBus || !dependencies.content) { + throw new Error('StoryReader requires eventBus and content dependencies'); + } + + this._eventBus = dependencies.eventBus; + this._content = dependencies.content; + this._config = { + container: null, + autoPlayTTS: true, + fontSize: 'medium', + readingMode: 'sentence', + ttsSpeed: 0.8, + ...config + }; + + // Reading state + this._currentStory = null; + this._availableStories = []; + this._currentStoryIndex = 0; + this._currentSentence = 0; + this._totalSentences = 0; + this._wordsRead = 0; + this._vocabulary = {}; + + // UI state + this._showTranslations = false; + this._showPronunciations = false; + this._readingTimer = null; + this._startTime = null; + this._totalReadingTime = 0; + + Object.seal(this); + } + + /** + * Get game metadata + * @returns {Object} Game metadata + */ + static getMetadata() { + return { + name: 'Story Reader', + description: 'Read stories with interactive vocabulary and translations', + difficulty: 'beginner', + category: 'reading', + estimatedTime: 15, // minutes + skills: ['reading', 'vocabulary', 'comprehension'] + }; + } + + /** + * Calculate compatibility score with content + * @param {Object} content - Content to check compatibility with + * @returns {Object} Compatibility score and details + */ + static getCompatibilityScore(content) { + let storyCount = 0; + let hasMainStory = !!(content?.story?.title || content?.rawContent?.story?.title); + let hasAdditionalStories = !!(content?.additionalStories?.length || content?.rawContent?.additionalStories?.length); + let hasTexts = !!(content?.texts?.length || content?.rawContent?.texts?.length); + let hasSentences = !!(content?.sentences?.length || content?.rawContent?.sentences?.length); + + if (hasMainStory) storyCount++; + if (hasAdditionalStories) storyCount += (content?.additionalStories?.length || content?.rawContent?.additionalStories?.length); + if (hasTexts) storyCount += (content?.texts?.length || content?.rawContent?.texts?.length); + if (hasSentences) storyCount++; + + if (storyCount === 0) { + return { + score: 0, + reason: 'No story content found', + requirements: ['story', 'texts', 'or sentences'], + details: 'Story Reader needs stories, texts, or sentences to read' + }; + } + + // Perfect score for multiple stories, good score for single story + const score = Math.min(storyCount / 3, 1); + + return { + score, + reason: `${storyCount} story/text sources available`, + requirements: ['story content'], + optimalSources: 3, + details: `Can create reading experience from ${storyCount} content sources` + }; + } + + async init() { + this._validateNotDestroyed(); + + try { + // Validate container + if (!this._config.container) { + throw new Error('Game container is required'); + } + + // Discover and prepare story content + this._discoverAvailableStories(); + + if (this._availableStories.length === 0) { + throw new Error('No story content found for reading'); + } + + // Select initial story + this._selectStory(0); + this._vocabulary = this._content?.vocabulary || this._content?.rawContent?.vocabulary || {}; + + // Set up event listeners + this._eventBus.on('game:pause', this._handlePause.bind(this), this.name); + this._eventBus.on('game:resume', this._handleResume.bind(this), this.name); + + // Initialize game interface + this._injectCSS(); + this._createGameInterface(); + this._setupEventListeners(); + this._loadProgress(); + + // Start reading session + this._startTime = Date.now(); + this._startReadingTimer(); + + // Render initial content + this._renderCurrentSentence(); + + // Emit game ready event + this._eventBus.emit('game:ready', { + gameId: 'story-reader', + instanceId: this.name, + stories: this._availableStories.length, + sentences: this._totalSentences + }, this.name); + + this._setInitialized(); + + } catch (error) { + this._showError(error.message); + throw error; + } + } + + async destroy() { + this._validateNotDestroyed(); + + // Save progress before cleanup + this._saveProgress(); + + // Clean up timer + if (this._readingTimer) { + clearInterval(this._readingTimer); + this._readingTimer = null; + } + + // Clean up container + if (this._config.container) { + this._config.container.innerHTML = ''; + } + + // Remove injected CSS + this._removeInjectedCSS(); + + // Emit game end event + this._eventBus.emit('game:ended', { + gameId: 'story-reader', + instanceId: this.name, + wordsRead: this._wordsRead, + readingTime: this._totalReadingTime, + sentencesRead: this._currentSentence + }, this.name); + + this._setDestroyed(); + } + + /** + * Get current game state + * @returns {Object} Current game state + */ + getGameState() { + this._validateInitialized(); + + return { + currentStory: this._currentStoryIndex, + currentSentence: this._currentSentence, + totalSentences: this._totalSentences, + wordsRead: this._wordsRead, + readingTime: this._totalReadingTime, + progress: this._totalSentences > 0 ? (this._currentSentence / this._totalSentences) * 100 : 0, + isComplete: this._currentSentence >= this._totalSentences - 1 + }; + } + + // Private methods + _discoverAvailableStories() { + this._availableStories = []; + + // Check main story field + const mainStory = this._content.rawContent?.story || this._content.story; + if (mainStory && mainStory.title) { + this._availableStories.push({ + id: 'main', + title: mainStory.title, + data: mainStory, + source: 'main' + }); + } + + // Check additional stories + const additionalStories = this._content.rawContent?.additionalStories || this._content.additionalStories; + if (additionalStories && Array.isArray(additionalStories)) { + additionalStories.forEach((story, index) => { + if (story && story.title) { + this._availableStories.push({ + id: `additional_${index}`, + title: story.title, + data: story, + source: 'additional' + }); + } + }); + } + + // Check texts and convert to stories + const texts = this._content.rawContent?.texts || this._content.texts; + if (texts && Array.isArray(texts)) { + texts.forEach((text, index) => { + if (text && (text.title || text.original_language)) { + const convertedStory = this._convertTextToStory(text, index); + this._availableStories.push({ + id: `text_${index}`, + title: text.title || `Text ${index + 1}`, + data: convertedStory, + source: 'text' + }); + } + }); + } + + // Check sentences and create story from them + const sentences = this._content.rawContent?.sentences || this._content.sentences; + if (sentences && Array.isArray(sentences) && sentences.length > 0 && this._availableStories.length === 0) { + const sentencesStory = this._convertSentencesToStory(sentences); + this._availableStories.push({ + id: 'sentences', + title: 'Reading Practice', + data: sentencesStory, + source: 'sentences' + }); + } + } + + _selectStory(storyIndex) { + if (storyIndex >= 0 && storyIndex < this._availableStories.length) { + this._currentStoryIndex = storyIndex; + this._currentStory = this._availableStories[storyIndex].data; + this._calculateTotalSentences(); + + // Reset reading position for new story + this._currentSentence = 0; + this._wordsRead = 0; + } + } + + _calculateTotalSentences() { + this._totalSentences = 0; + if (this._currentStory && this._currentStory.chapters) { + this._currentStory.chapters.forEach(chapter => { + this._totalSentences += chapter.sentences.length; + }); + } + } + + _convertTextToStory(text, index) { + const sentences = this._splitTextIntoSentences(text.original_language, text.user_language); + return { + title: text.title || `Text ${index + 1}`, + totalSentences: sentences.length, + chapters: [{ + title: "Reading Text", + sentences: sentences + }] + }; + } + + _convertSentencesToStory(sentences) { + const storyTitle = this._content.name || "Reading Practice"; + const convertedSentences = sentences.map((sentence, index) => ({ + id: index + 1, + original: sentence.original_language || sentence.english || sentence.original || '', + translation: sentence.user_language || sentence.chinese || sentence.french || sentence.translation || '' + })); + + return { + title: storyTitle, + totalSentences: convertedSentences.length, + chapters: [{ + title: "Reading Sentences", + sentences: convertedSentences + }] + }; + } + + _splitTextIntoSentences(originalText, translationText) { + const originalSentences = originalText.split(/[.!?]+/).filter(s => s.trim().length > 0); + const translationSentences = translationText.split(/[.!?]+/).filter(s => s.trim().length > 0); + const sentences = []; + const maxSentences = Math.max(originalSentences.length, translationSentences.length); + + for (let i = 0; i < maxSentences; i++) { + const original = (originalSentences[i] || '').trim(); + const translation = (translationSentences[i] || '').trim(); + + if (original || translation) { + sentences.push({ + id: i + 1, + original: original + (original && !original.match(/[.!?]$/) ? '.' : ''), + translation: translation + (translation && !translation.match(/[.!?]$/) ? '.' : '') + }); + } + } + + return sentences; + } + + _injectCSS() { + if (document.getElementById('story-reader-styles')) return; + + const styleSheet = document.createElement('style'); + styleSheet.id = 'story-reader-styles'; + styleSheet.textContent = ` + .story-reader-wrapper { + max-width: 800px; + margin: 0 auto; + padding: 20px; + font-family: 'Georgia', serif; + line-height: 1.6; + height: 100vh; + overflow-y: auto; + box-sizing: border-box; + } + + .story-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 2px solid #e2e8f0; + } + + .story-title h2 { + margin: 0 0 10px 0; + color: #2d3748; + font-size: 1.6em; + } + + .reading-progress { + display: flex; + align-items: center; + gap: 10px; + font-size: 0.9em; + color: #718096; + } + + .progress-bar { + width: 150px; + height: 6px; + background: #e2e8f0; + border-radius: 3px; + overflow: hidden; + } + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, #3b82f6, #10b981); + width: 0%; + transition: width 0.3s ease; + } + + .story-controls { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + .control-btn { + padding: 6px 12px; + border: 1px solid #e2e8f0; + background: white; + border-radius: 6px; + cursor: pointer; + font-size: 0.85em; + transition: all 0.2s; + white-space: nowrap; + } + + .control-btn:hover { + background: #f7fafc; + border-color: #cbd5e0; + } + + .control-btn.active { + background: #3b82f6; + color: white; + border-color: #3b82f6; + } + + .reading-area { + background: white; + border: 2px solid #e2e8f0; + border-radius: 12px; + padding: 30px; + margin-bottom: 20px; + min-height: 200px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + } + + .sentence-display { + text-align: center; + margin-bottom: 20px; + } + + .original-text { + font-size: 1.2em; + color: #2d3748; + margin-bottom: 15px; + line-height: 1.8; + cursor: pointer; + padding: 15px; + border-radius: 8px; + transition: background-color 0.2s; + } + + .original-text:hover { + background-color: #f7fafc; + } + + .original-text.small { font-size: 1em; } + .original-text.medium { font-size: 1.2em; } + .original-text.large { font-size: 1.4em; } + .original-text.extra-large { font-size: 1.6em; } + + .translation-text { + font-style: italic; + color: #718096; + font-size: 1em; + padding: 10px; + background: #f0fff4; + border-radius: 6px; + border-left: 4px solid #10b981; + display: none; + } + + .translation-text.show { + display: block; + } + + .clickable-word { + cursor: pointer; + padding: 2px 4px; + border-radius: 3px; + transition: background-color 0.2s; + position: relative; + display: inline-block; + } + + .clickable-word:hover { + background-color: #fef5e7; + color: #d69e2e; + } + + .word-popup { + position: fixed; + background: white; + border: 2px solid #3b82f6; + border-radius: 6px; + padding: 8px 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 9999; + max-width: 200px; + min-width: 120px; + font-size: 0.9em; + line-height: 1.3; + display: none; + } + + .word-original { + font-weight: bold; + color: #2d3748; + margin-bottom: 3px; + } + + .word-translation { + color: #10b981; + font-size: 0.9em; + margin-bottom: 2px; + } + + .word-type { + font-size: 0.75em; + color: #718096; + font-style: italic; + } + + .word-tts-btn { + position: absolute; + top: 5px; + right: 5px; + background: #3b82f6; + color: white; + border: none; + border-radius: 50%; + width: 20px; + height: 20px; + cursor: pointer; + font-size: 10px; + display: flex; + align-items: center; + justify-content: center; + } + + .story-navigation { + display: flex; + justify-content: center; + gap: 15px; + margin-bottom: 20px; + } + + .nav-btn { + padding: 10px 20px; + border: 2px solid #e2e8f0; + background: white; + border-radius: 8px; + cursor: pointer; + font-size: 0.95em; + transition: all 0.2s; + min-width: 100px; + } + + .nav-btn:hover:not(:disabled) { + background: #f7fafc; + border-color: #cbd5e0; + } + + .nav-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .nav-btn.primary { + background: #3b82f6; + color: white; + border-color: #3b82f6; + } + + .nav-btn.primary:hover { + background: #2563eb; + } + + .reading-stats { + display: flex; + justify-content: space-around; + background: #f7fafc; + padding: 15px; + border-radius: 8px; + border: 1px solid #e2e8f0; + } + + .stat { + text-align: center; + } + + .stat-label { + display: block; + font-size: 0.8em; + color: #718096; + margin-bottom: 5px; + } + + .stat-value { + display: block; + font-weight: bold; + font-size: 1em; + color: #2d3748; + } + + .story-selector { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 10px 15px; + margin-bottom: 20px; + display: flex; + align-items: center; + gap: 10px; + } + + .story-selector select { + flex: 1; + padding: 6px 10px; + border: 1px solid #cbd5e0; + border-radius: 4px; + background: white; + font-size: 0.9em; + } + + @media (max-width: 768px) { + .story-reader-wrapper { + padding: 15px; + } + + .story-header { + flex-direction: column; + gap: 15px; + } + + .story-controls { + justify-content: center; + } + + .reading-stats { + flex-direction: column; + gap: 10px; + } + + .story-navigation { + flex-wrap: wrap; + } + + .nav-btn { + min-width: auto; + padding: 8px 16px; + } + } + `; + document.head.appendChild(styleSheet); + } + + _removeInjectedCSS() { + const styleSheet = document.getElementById('story-reader-styles'); + if (styleSheet) { + styleSheet.remove(); + } + } + + _createGameInterface() { + // Create story selector if multiple stories available + const storySelector = this._availableStories.length > 1 ? ` +
+ + +
+ ` : ''; + + this._config.container.innerHTML = ` +
+ ${storySelector} + + +
+
+

${this._currentStory.title}

+
+ Sentence 1 of ${this._totalSentences} +
+
+
+
+
+ +
+ + + + +
+
+ + +
+
+
+ Loading story... +
+
+ Translation will appear here... +
+
+
+ + +
+ + + + +
+ + +
+ + +
+ + +
+
+ Progress + 0% +
+
+ Words Read + 0 +
+
+ Time + 00:00 +
+
+
+ `; + } + + _setupEventListeners() { + // Story selector + const storySelect = document.getElementById('story-select'); + if (storySelect) { + storySelect.addEventListener('change', (e) => { + this._changeStory(parseInt(e.target.value)); + }); + } + + // Navigation + document.getElementById('prev-btn').addEventListener('click', () => this._previousSentence()); + document.getElementById('next-btn').addEventListener('click', () => this._nextSentence()); + + // Controls + document.getElementById('play-btn').addEventListener('click', () => this._playSentenceTTS()); + document.getElementById('translation-btn').addEventListener('click', () => this._toggleTranslations()); + document.getElementById('bookmark-btn').addEventListener('click', () => this._saveBookmark()); + document.getElementById('exit-btn').addEventListener('click', () => { + this._eventBus.emit('game:exit-request', { instanceId: this.name }, this.name); + }); + + // TTS button in popup + document.getElementById('popup-tts-btn').addEventListener('click', () => this._speakWordFromPopup()); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return; + + switch (e.key) { + case 'ArrowLeft': + this._previousSentence(); + break; + case 'ArrowRight': + case ' ': + e.preventDefault(); + this._nextSentence(); + break; + case 't': + case 'T': + this._toggleTranslations(); + break; + case 's': + case 'S': + this._playSentenceTTS(); + break; + } + }); + + // Click outside to close popup + document.addEventListener('click', (e) => { + if (!e.target.closest('.word-popup') && !e.target.closest('.clickable-word')) { + this._hideWordPopup(); + } + }); + } + + _getCurrentSentenceData() { + let sentenceCount = 0; + for (let chapterIndex = 0; chapterIndex < this._currentStory.chapters.length; chapterIndex++) { + const chapter = this._currentStory.chapters[chapterIndex]; + if (sentenceCount + chapter.sentences.length > this._currentSentence) { + const sentenceInChapter = this._currentSentence - sentenceCount; + return { + chapter: chapterIndex, + sentence: sentenceInChapter, + data: chapter.sentences[sentenceInChapter], + chapterTitle: chapter.title + }; + } + sentenceCount += chapter.sentences.length; + } + return null; + } + + _matchWordsWithVocabulary(sentence) { + const words = sentence.split(/(\s+|[.,!?;:"'()[\]{}\-–—])/); + const matchedWords = []; + + words.forEach(token => { + if (/^\s+$/.test(token)) { + matchedWords.push({ original: token, hasVocab: false, isWhitespace: true }); + return; + } + + if (/^[.,!?;:"'()[\]{}\-–—]+$/.test(token)) { + matchedWords.push({ original: token, hasVocab: false, isPunctuation: true }); + return; + } + + const cleanWord = token.toLowerCase().replace(/[.,!?;:"'()[\]{}\-–—]/g, ''); + if (!cleanWord) return; + + let vocabEntry = this._vocabulary[cleanWord]; + + // Try variations if exact match not found + if (!vocabEntry) { + if (cleanWord.endsWith('s')) vocabEntry = this._vocabulary[cleanWord.slice(0, -1)]; + if (!vocabEntry && cleanWord.endsWith('ed')) vocabEntry = this._vocabulary[cleanWord.slice(0, -2)]; + if (!vocabEntry && cleanWord.endsWith('ing')) vocabEntry = this._vocabulary[cleanWord.slice(0, -3)]; + } + + if (vocabEntry) { + matchedWords.push({ + original: token, + hasVocab: true, + word: cleanWord, + translation: vocabEntry.user_language || vocabEntry.translation, + pronunciation: vocabEntry.pronunciation, + type: vocabEntry.type || 'unknown' + }); + } else { + matchedWords.push({ original: token, hasVocab: false }); + } + }); + + return matchedWords; + } + + _renderCurrentSentence() { + const sentenceData = this._getCurrentSentenceData(); + if (!sentenceData) { + console.warn('StoryReader: No sentence data found for current sentence index', this._currentSentence); + return; + } + + const { data } = sentenceData; + if (!data || !data.original) { + console.warn('StoryReader: Invalid sentence data - missing original text', data); + return; + } + + // Update progress + const progress = ((this._currentSentence + 1) / this._totalSentences) * 100; + const progressFill = document.getElementById('progress-fill'); + const progressText = document.getElementById('progress-text'); + const progressPercent = document.getElementById('progress-percent'); + + if (progressFill) progressFill.style.width = `${progress}%`; + if (progressText) progressText.textContent = `Sentence ${this._currentSentence + 1} of ${this._totalSentences}`; + if (progressPercent) progressPercent.textContent = `${Math.round(progress)}%`; + + // Render sentence with vocabulary matching + const matchedWords = this._matchWordsWithVocabulary(data.original); + const wordsHtml = matchedWords.map(wordInfo => { + if (wordInfo.isWhitespace) return wordInfo.original; + if (wordInfo.isPunctuation) return `${wordInfo.original}`; + if (wordInfo.hasVocab) { + return `${wordInfo.original}`; + } + return wordInfo.original; + }).join(''); + + const originalTextEl = document.getElementById('original-text'); + const translationTextEl = document.getElementById('translation-text'); + const prevBtn = document.getElementById('prev-btn'); + const nextBtn = document.getElementById('next-btn'); + + if (originalTextEl) originalTextEl.innerHTML = wordsHtml; + if (translationTextEl) translationTextEl.textContent = data.translation || ''; + + // Add word click listeners + document.querySelectorAll('.clickable-word').forEach(word => { + word.addEventListener('click', (e) => this._showWordPopup(e)); + }); + + // Update navigation buttons + if (prevBtn) prevBtn.disabled = this._currentSentence === 0; + if (nextBtn) nextBtn.disabled = this._currentSentence >= this._totalSentences - 1; + + // Update stats + this._updateStats(); + + // Auto-play TTS if enabled + if (this._config.autoPlayTTS) { + setTimeout(() => this._playSentenceTTS(), 300); + } + } + + _showWordPopup(event) { + const word = event.target.dataset.word; + const translation = event.target.dataset.translation; + const type = event.target.dataset.type; + const pronunciation = event.target.dataset.pronunciation; + + const popup = document.getElementById('word-popup'); + popup.currentWord = word; + + document.getElementById('popup-word').textContent = word; + document.getElementById('popup-translation').textContent = translation; + document.getElementById('popup-type').textContent = pronunciation ? `${pronunciation} (${type})` : `(${type})`; + + // Position popup + const rect = event.target.getBoundingClientRect(); + popup.style.display = 'block'; + + const popupLeft = Math.min(rect.left + (rect.width / 2) - 100, window.innerWidth - 210); + const popupTop = rect.top - 10; + + popup.style.left = `${Math.max(10, popupLeft)}px`; + popup.style.top = `${popupTop}px`; + popup.style.transform = popupTop > 80 ? 'translateY(-100%)' : 'translateY(10px)'; + } + + _hideWordPopup() { + document.getElementById('word-popup').style.display = 'none'; + } + + _previousSentence() { + if (this._currentSentence > 0) { + this._currentSentence--; + this._renderCurrentSentence(); + this._saveProgress(); + } + } + + _nextSentence() { + if (this._currentSentence < this._totalSentences - 1) { + this._currentSentence++; + this._renderCurrentSentence(); + this._saveProgress(); + } else { + this._completeReading(); + } + } + + _toggleTranslations() { + this._showTranslations = !this._showTranslations; + const translationElement = document.getElementById('translation-text'); + const btn = document.getElementById('translation-btn'); + + if (this._showTranslations) { + translationElement.classList.add('show'); + btn.classList.add('active'); + btn.textContent = '🌐 Hide'; + } else { + translationElement.classList.remove('show'); + btn.classList.remove('active'); + btn.textContent = '🌐 Translation'; + } + } + + _playSentenceTTS() { + const sentenceData = this._getCurrentSentenceData(); + if (!sentenceData) return; + + this._speakText(sentenceData.data.original); + } + + _speakText(text, options = {}) { + if (!text) return; + + try { + if ('speechSynthesis' in window) { + speechSynthesis.cancel(); + + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = this._getContentLanguage(); + utterance.rate = options.rate || this._config.ttsSpeed; + utterance.volume = 1.0; + + speechSynthesis.speak(utterance); + } + } catch (error) { + console.warn('TTS error:', error); + } + } + + _speakWordFromPopup() { + const popup = document.getElementById('word-popup'); + if (popup && popup.currentWord) { + this._speakText(popup.currentWord, { rate: 0.7 }); + } + } + + _getContentLanguage() { + if (this._content.language) { + const langMap = { + 'chinese': 'zh-CN', + 'english': 'en-US', + 'french': 'fr-FR', + 'spanish': 'es-ES' + }; + return langMap[this._content.language] || this._content.language; + } + return 'en-US'; + } + + _changeStory(storyIndex) { + if (storyIndex !== this._currentStoryIndex) { + this._saveProgress(); + this._selectStory(storyIndex); + this._loadProgress(); + + // Update interface + document.querySelector('.story-title h2').textContent = this._currentStory.title; + this._renderCurrentSentence(); + } + } + + _startReadingTimer() { + this._readingTimer = setInterval(() => { + this._updateReadingTime(); + }, 1000); + } + + _updateReadingTime() { + const currentTime = Date.now(); + this._totalReadingTime = Math.floor((currentTime - this._startTime) / 1000); + + const minutes = Math.floor(this._totalReadingTime / 60); + const seconds = this._totalReadingTime % 60; + const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + + document.getElementById('time-count').textContent = timeString; + } + + _updateStats() { + const sentenceData = this._getCurrentSentenceData(); + if (sentenceData && sentenceData.data.original) { + // Count words in current sentence + const wordCount = sentenceData.data.original.split(/\s+/).length; + this._wordsRead += wordCount; + document.getElementById('words-count').textContent = this._wordsRead; + } + } + + _saveProgress() { + const progressData = { + currentSentence: this._currentSentence, + wordsRead: this._wordsRead, + timestamp: Date.now() + }; + const progressKey = this._getProgressKey(); + localStorage.setItem(progressKey, JSON.stringify(progressData)); + } + + _loadProgress() { + const progressKey = this._getProgressKey(); + const saved = localStorage.getItem(progressKey); + if (saved) { + try { + const data = JSON.parse(saved); + this._currentSentence = data.currentSentence || 0; + this._wordsRead = data.wordsRead || 0; + } catch (error) { + this._currentSentence = 0; + this._wordsRead = 0; + } + } else { + this._currentSentence = 0; + this._wordsRead = 0; + } + } + + _getProgressKey() { + const storyId = this._availableStories[this._currentStoryIndex]?.id || 'main'; + return `story_progress_${this._content.name}_${storyId}`; + } + + _saveBookmark() { + this._saveProgress(); + + // Show toast notification + const toast = document.createElement('div'); + toast.textContent = '🔖 Bookmark saved!'; + toast.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: #10b981; + color: white; + padding: 10px 20px; + border-radius: 6px; + z-index: 1000; + font-size: 0.9em; + `; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 2000); + } + + _completeReading() { + // Emit completion event + this._eventBus.emit('game:completed', { + gameId: 'story-reader', + instanceId: this.name, + wordsRead: this._wordsRead, + readingTime: this._totalReadingTime, + sentencesRead: this._totalSentences + }, this.name); + + // Show completion message + const completionMessage = ` +
+

🎉 Story Complete!

+

You've finished reading "${this._currentStory.title}"

+

Words read: ${this._wordsRead}

+

Reading time: ${Math.floor(this._totalReadingTime / 60)}:${(this._totalReadingTime % 60).toString().padStart(2, '0')}

+ +
+ `; + + document.querySelector('.reading-area').innerHTML = completionMessage; + } + + _showError(message) { + if (this._config.container) { + this._config.container.innerHTML = ` +
+
+

Story Reader Error

+

${message}

+ +
+ `; + } + } + + _handlePause() { + if (this._readingTimer) { + clearInterval(this._readingTimer); + this._readingTimer = null; + } + this._eventBus.emit('game:paused', { instanceId: this.name }, this.name); + } + + _handleResume() { + this._startReadingTimer(); + this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name); + } +} + +export default StoryReader; \ No newline at end of file diff --git a/src/games/WhackAMole.js b/src/games/WhackAMole.js new file mode 100644 index 0000000..675530c --- /dev/null +++ b/src/games/WhackAMole.js @@ -0,0 +1,1254 @@ +import Module from '../core/Module.js'; + +/** + * WhackAMole - Classic whack-a-mole game with vocabulary learning + * Players must hit moles showing the target word translation + */ +class WhackAMole extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); + + // Validate dependencies + if (!dependencies.eventBus || !dependencies.content) { + throw new Error('WhackAMole requires eventBus and content dependencies'); + } + + this._eventBus = dependencies.eventBus; + this._content = dependencies.content; + this._config = { + container: null, + gameTime: 60, // seconds + maxErrors: 3, + moleAppearTime: 2000, // ms + spawnRate: 1500, // ms between spawns + maxSpawnsWithoutTarget: 3, + ...config + }; + + // Game state + this._score = 0; + this._errors = 0; + this._timeLeft = this._config.gameTime; + this._isRunning = false; + this._gameStartTime = null; + this._showPronunciation = false; + + // Mole configuration + this._holes = []; + this._activeMoles = []; + this._targetWord = null; + this._spawnsSinceTarget = 0; + + // Timers + this._gameTimer = null; + this._spawnTimer = null; + + // Content + this._vocabulary = null; + + Object.seal(this); + } + + /** + * Get game metadata + * @returns {Object} Game metadata + */ + static getMetadata() { + return { + name: 'Whack A Mole', + description: 'Classic whack-a-mole game with vocabulary learning and quick reflexes', + difficulty: 'beginner', + category: 'action', + estimatedTime: 5, // minutes + skills: ['vocabulary', 'reflexes', 'speed', 'recognition'] + }; + } + + /** + * Calculate compatibility score with content + * @param {Object} content - Content to check compatibility with + * @returns {Object} Compatibility score and details + */ + static getCompatibilityScore(content) { + const vocab = content?.vocabulary || {}; + const vocabCount = Object.keys(vocab).length; + + if (vocabCount < 5) { + return { + score: 0, + reason: `Insufficient vocabulary (${vocabCount}/5 required)`, + requirements: ['vocabulary'], + minWords: 5, + details: 'Whack A Mole needs at least 5 vocabulary words for gameplay variety' + }; + } + + // Perfect score at 20+ words, partial score for 5-19 + const score = Math.min(vocabCount / 20, 1); + + return { + score, + reason: `${vocabCount} vocabulary words available`, + requirements: ['vocabulary'], + minWords: 5, + optimalWords: 20, + details: `Can create engaging gameplay with ${vocabCount} vocabulary words` + }; + } + + async init() { + this._validateNotDestroyed(); + + try { + // Validate container + if (!this._config.container) { + throw new Error('Game container is required'); + } + + // Extract and validate vocabulary + this._vocabulary = this._extractVocabulary(); + if (this._vocabulary.length < 5) { + throw new Error(`Insufficient vocabulary: need 5, got ${this._vocabulary.length}`); + } + + // Set up event listeners + this._eventBus.on('game:pause', this._handlePause.bind(this), this.name); + this._eventBus.on('game:resume', this._handleResume.bind(this), this.name); + + // Inject CSS + this._injectCSS(); + + // Initialize game interface + this._createGameInterface(); + this._createHoles(); + this._setupEventListeners(); + + // Emit game ready event + this._eventBus.emit('game:ready', { + gameId: 'whack-a-mole', + instanceId: this.name, + vocabulary: this._vocabulary.length + }, this.name); + + this._setInitialized(); + + } catch (error) { + this._showError(error.message); + throw error; + } + } + + async destroy() { + this._validateNotDestroyed(); + + // Stop timers + this._stopTimers(); + + // Remove CSS + this._removeCSS(); + + // Clean up event listeners + if (this._config.container) { + this._config.container.innerHTML = ''; + } + + // Emit game end event + this._eventBus.emit('game:ended', { + gameId: 'whack-a-mole', + instanceId: this.name, + score: this._score, + errors: this._errors, + duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0 + }, this.name); + + this._setDestroyed(); + } + + /** + * Get current game state + * @returns {Object} Current game state + */ + getGameState() { + this._validateInitialized(); + + return { + score: this._score, + errors: this._errors, + timeLeft: this._timeLeft, + maxErrors: this._config.maxErrors, + isRunning: this._isRunning, + isComplete: this._timeLeft <= 0 || this._errors >= this._config.maxErrors, + duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0 + }; + } + + // Private methods + _extractVocabulary() { + const vocab = this._content?.vocabulary || {}; + const vocabulary = []; + + for (const [word, data] of Object.entries(vocab)) { + if (data.user_language || (typeof data === 'string')) { + const translation = data.user_language || data; + vocabulary.push({ + original: word, + translation: translation.split(';')[0], // First translation if multiple + fullTranslation: translation, + type: data.type || 'general', + pronunciation: data.pronunciation + }); + } + } + + return this._shuffleArray(vocabulary); + } + + _injectCSS() { + const cssId = `whack-a-mole-styles-${this.name}`; + if (document.getElementById(cssId)) return; + + const style = document.createElement('style'); + style.id = cssId; + style.textContent = ` + .whack-game-wrapper { + padding: 20px; + max-width: 800px; + margin: 0 auto; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 15px; + color: white; + min-height: 600px; + } + + .whack-game-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 25px; + padding: 20px; + background: rgba(255, 255, 255, 0.1); + border-radius: 12px; + backdrop-filter: blur(10px); + } + + .game-stats { + display: flex; + gap: 30px; + align-items: center; + } + + .stat-item { + text-align: center; + background: rgba(255, 255, 255, 0.1); + padding: 12px 20px; + border-radius: 10px; + min-width: 80px; + } + + .stat-value { + display: block; + font-size: 1.8rem; + font-weight: bold; + margin-bottom: 5px; + } + + .stat-label { + font-size: 0.9rem; + opacity: 0.9; + } + + .target-display { + background: rgba(255, 255, 255, 0.2); + padding: 15px 25px; + border-radius: 12px; + text-align: center; + border: 2px solid rgba(255, 255, 255, 0.3); + } + + .target-label { + font-size: 0.9rem; + opacity: 0.9; + margin-bottom: 5px; + } + + .target-word { + font-size: 1.5rem; + font-weight: bold; + } + + .game-controls { + display: flex; + gap: 15px; + align-items: center; + flex-wrap: wrap; + } + + .control-btn { + padding: 10px 18px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 8px; + background: rgba(255, 255, 255, 0.1); + color: white; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + backdrop-filter: blur(5px); + } + + .control-btn:hover { + background: rgba(255, 255, 255, 0.2); + transform: translateY(-2px); + } + + .control-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } + + .control-btn.active { + background: rgba(255, 255, 255, 0.3); + border-color: rgba(255, 255, 255, 0.5); + } + + .whack-game-board { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 15px; + margin: 30px 0; + padding: 20px; + background: rgba(0, 0, 0, 0.2); + border-radius: 15px; + min-height: 300px; + } + + .whack-hole { + position: relative; + aspect-ratio: 1; + background: radial-gradient(circle at center, #8b5cf6 0%, #7c3aed 100%); + border-radius: 50%; + border: 4px solid rgba(255, 255, 255, 0.3); + overflow: hidden; + cursor: pointer; + transition: all 0.3s ease; + } + + .whack-hole:hover { + transform: scale(1.05); + box-shadow: 0 5px 20px rgba(139, 92, 246, 0.4); + } + + .whack-mole { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0); + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + border-radius: 12px; + padding: 15px; + color: white; + text-align: center; + font-weight: 600; + font-size: 1.1rem; + transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); + cursor: pointer; + max-width: 80%; + word-wrap: break-word; + } + + .whack-mole.active { + transform: translate(-50%, -50%) scale(1); + } + + .whack-mole.hit { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + animation: moleHit 0.5s ease-out; + } + + .whack-mole:hover { + transform: translate(-50%, -50%) scale(1.1); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); + } + + .pronunciation { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.8); + font-style: italic; + margin-bottom: 5px; + font-weight: 400; + } + + .feedback-area { + text-align: center; + padding: 20px; + background: rgba(255, 255, 255, 0.1); + border-radius: 12px; + margin-top: 20px; + backdrop-filter: blur(10px); + } + + .instruction { + font-size: 1.1rem; + font-weight: 500; + padding: 15px; + border-radius: 8px; + transition: all 0.3s ease; + } + + .instruction.info { + background: rgba(59, 130, 246, 0.2); + border: 1px solid rgba(59, 130, 246, 0.3); + } + + .instruction.success { + background: rgba(16, 185, 129, 0.2); + border: 1px solid rgba(16, 185, 129, 0.3); + animation: successPulse 0.6s ease-out; + } + + .instruction.error { + background: rgba(239, 68, 68, 0.2); + border: 1px solid rgba(239, 68, 68, 0.3); + animation: errorShake 0.6s ease-out; + } + + .score-popup { + position: fixed; + font-size: 1.5rem; + font-weight: bold; + pointer-events: none; + z-index: 1000; + animation: scoreFloat 1s ease-out forwards; + } + + .score-popup.correct-answer { + color: #10b981; + text-shadow: 0 2px 4px rgba(16, 185, 129, 0.5); + } + + .score-popup.wrong-answer { + color: #ef4444; + text-shadow: 0 2px 4px rgba(239, 68, 68, 0.5); + } + + .game-over-screen { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.9); + display: flex; + align-items: center; + justify-content: center; + border-radius: 15px; + z-index: 1000; + } + + .game-over-content { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + color: white; + padding: 40px; + border-radius: 15px; + text-align: center; + max-width: 400px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + } + + .game-over-content h2 { + margin: 0 0 20px 0; + font-size: 2.5rem; + } + + .final-stats { + margin: 20px 0; + padding: 20px; + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; + backdrop-filter: blur(10px); + } + + .stat-row { + display: flex; + justify-content: space-between; + margin: 10px 0; + font-size: 1.1rem; + } + + .game-over-btn { + margin: 10px; + padding: 12px 25px; + border: 2px solid white; + border-radius: 8px; + background: transparent; + color: white; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + } + + .game-over-btn:hover { + background: white; + color: #ef4444; + transform: translateY(-2px); + } + + .game-error { + text-align: center; + padding: 40px; + background: linear-gradient(135deg, #f87171 0%, #ef4444 100%); + color: white; + border-radius: 15px; + height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + + .game-error h3 { + font-size: 2rem; + margin-bottom: 20px; + } + + .back-btn { + padding: 12px 25px; + background: white; + color: #ef4444; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + margin-top: 20px; + } + + .back-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(255, 255, 255, 0.3); + } + + /* Animations */ + @keyframes moleHit { + 0% { transform: translate(-50%, -50%) scale(1); } + 50% { transform: translate(-50%, -50%) scale(1.2); } + 100% { transform: translate(-50%, -50%) scale(1); } + } + + @keyframes successPulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.05); } + 100% { transform: scale(1); } + } + + @keyframes errorShake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-5px); } + 75% { transform: translateX(5px); } + } + + @keyframes scoreFloat { + 0% { + transform: translateY(0) scale(1); + opacity: 1; + } + 50% { + transform: translateY(-30px) scale(1.2); + opacity: 1; + } + 100% { + transform: translateY(-60px) scale(0.8); + opacity: 0; + } + } + + @media (max-width: 768px) { + .whack-game-wrapper { + padding: 15px; + } + + .whack-game-header { + flex-direction: column; + gap: 20px; + padding: 15px; + } + + .game-stats { + gap: 20px; + } + + .whack-game-board { + gap: 10px; + padding: 15px; + } + + .whack-mole { + font-size: 0.9rem; + padding: 10px; + } + + .game-controls { + justify-content: center; + } + + .control-btn { + padding: 8px 15px; + font-size: 0.8rem; + } + } + `; + + document.head.appendChild(style); + } + + _removeCSS() { + const cssId = `whack-a-mole-styles-${this.name}`; + const existingStyle = document.getElementById(cssId); + if (existingStyle) { + existingStyle.remove(); + } + } + + _createGameInterface() { + this._config.container.innerHTML = ` +
+ +
+
+
+ ${this._config.gameTime} + Time +
+
+ 0 + Errors +
+
+ 0 + Score +
+
+ +
+
Find the word:
+
---
+
+ +
+ + + + + +
+
+ + +
+ +
+ + + +
+ `; + } + + _createHoles() { + const gameBoard = document.getElementById('game-board'); + gameBoard.innerHTML = ''; + this._holes = []; + + for (let i = 0; i < 9; i++) { + const hole = document.createElement('div'); + hole.className = 'whack-hole'; + hole.dataset.holeId = i; + + hole.innerHTML = ` +
+ +
+
+ `; + + gameBoard.appendChild(hole); + this._holes.push({ + element: hole, + mole: hole.querySelector('.whack-mole'), + wordElement: hole.querySelector('.word'), + pronunciationElement: hole.querySelector('.pronunciation'), + isActive: false, + word: null, + timer: null + }); + } + } + + _setupEventListeners() { + // Control buttons + document.getElementById('pronunciation-btn').addEventListener('click', () => this._togglePronunciation()); + document.getElementById('start-btn').addEventListener('click', () => this._startGame()); + document.getElementById('pause-btn').addEventListener('click', () => this._pauseGame()); + document.getElementById('restart-btn').addEventListener('click', () => this._restartGame()); + + // Exit button + const exitButton = document.getElementById('exit-whack'); + if (exitButton) { + exitButton.addEventListener('click', () => { + this._eventBus.emit('game:exit-request', { instanceId: this.name }, this.name); + }); + } + + // Mole clicks + this._holes.forEach((hole, index) => { + hole.mole.addEventListener('click', () => this._hitMole(index)); + }); + } + + _startGame() { + if (this._isRunning) return; + + this._isRunning = true; + this._score = 0; + this._errors = 0; + this._timeLeft = this._config.gameTime; + this._gameStartTime = Date.now(); + this._spawnsSinceTarget = 0; + + this._updateUI(); + this._setNewTarget(); + this._startTimers(); + + document.getElementById('start-btn').disabled = true; + document.getElementById('pause-btn').disabled = false; + + this._showFeedback(`Find the word: "${this._targetWord.translation}"`, 'info'); + + // Emit game start event + this._eventBus.emit('whack-a-mole:game-started', { + gameId: 'whack-a-mole', + instanceId: this.name, + vocabulary: this._vocabulary.length + }, this.name); + } + + _pauseGame() { + if (!this._isRunning) return; + + this._isRunning = false; + this._stopTimers(); + this._hideAllMoles(); + + document.getElementById('start-btn').disabled = false; + document.getElementById('pause-btn').disabled = true; + + this._showFeedback('Game paused', 'info'); + } + + _restartGame() { + this._stopGameWithoutEnd(); + this._resetGame(); + setTimeout(() => this._startGame(), 100); + } + + _togglePronunciation() { + this._showPronunciation = !this._showPronunciation; + const btn = document.getElementById('pronunciation-btn'); + + if (this._showPronunciation) { + btn.textContent = '🔊 Pronunciation ON'; + btn.classList.add('active'); + } else { + btn.textContent = '🔊 Pronunciation OFF'; + btn.classList.remove('active'); + } + + this._updateMoleDisplay(); + } + + _updateMoleDisplay() { + this._holes.forEach(hole => { + if (hole.isActive && hole.word) { + if (this._showPronunciation && hole.word.pronunciation) { + hole.pronunciationElement.textContent = hole.word.pronunciation; + hole.pronunciationElement.style.display = 'block'; + } else { + hole.pronunciationElement.style.display = 'none'; + } + } + }); + } + + _startTimers() { + // Main game timer + this._gameTimer = setInterval(() => { + this._timeLeft--; + this._updateUI(); + + if (this._timeLeft <= 0 && this._isRunning) { + this._endGame(); + } + }, 1000); + + // Mole spawn timer + this._spawnTimer = setInterval(() => { + if (this._isRunning) { + this._spawnMole(); + } + }, this._config.spawnRate); + + // First immediate mole + setTimeout(() => this._spawnMole(), 500); + } + + _stopTimers() { + if (this._gameTimer) { + clearInterval(this._gameTimer); + this._gameTimer = null; + } + if (this._spawnTimer) { + clearInterval(this._spawnTimer); + this._spawnTimer = null; + } + } + + _spawnMole() { + // Find a free hole + const availableHoles = this._holes.filter(hole => !hole.isActive); + if (availableHoles.length === 0) return; + + const randomHole = availableHoles[Math.floor(Math.random() * availableHoles.length)]; + const holeIndex = this._holes.indexOf(randomHole); + + // Choose a word according to guarantee strategy + const word = this._getWordWithTargetGuarantee(); + + // Activate the mole + this._activateMole(holeIndex, word); + } + + _getWordWithTargetGuarantee() { + this._spawnsSinceTarget++; + + // If we've reached the limit, force the target word + if (this._spawnsSinceTarget >= this._config.maxSpawnsWithoutTarget) { + this._spawnsSinceTarget = 0; + return this._targetWord; + } + + // Otherwise, 50% chance for target word, 50% random word + if (Math.random() < 0.5) { + this._spawnsSinceTarget = 0; + return this._targetWord; + } else { + return this._getRandomWord(); + } + } + + _activateMole(holeIndex, word) { + const hole = this._holes[holeIndex]; + if (hole.isActive) return; + + hole.isActive = true; + hole.word = word; + hole.wordElement.textContent = word.original; + + // Show pronunciation if enabled and available + if (this._showPronunciation && word.pronunciation) { + hole.pronunciationElement.textContent = word.pronunciation; + hole.pronunciationElement.style.display = 'block'; + } else { + hole.pronunciationElement.style.display = 'none'; + } + + hole.mole.classList.add('active'); + this._activeMoles.push(holeIndex); + + // Timer to make the mole disappear + hole.timer = setTimeout(() => { + this._deactivateMole(holeIndex); + }, this._config.moleAppearTime); + } + + _deactivateMole(holeIndex) { + const hole = this._holes[holeIndex]; + if (!hole.isActive) return; + + hole.isActive = false; + hole.word = null; + hole.wordElement.textContent = ''; + hole.pronunciationElement.textContent = ''; + hole.pronunciationElement.style.display = 'none'; + hole.mole.classList.remove('active'); + + if (hole.timer) { + clearTimeout(hole.timer); + hole.timer = null; + } + + // Remove from active moles list + const activeIndex = this._activeMoles.indexOf(holeIndex); + if (activeIndex > -1) { + this._activeMoles.splice(activeIndex, 1); + } + } + + _hitMole(holeIndex) { + if (!this._isRunning) return; + + const hole = this._holes[holeIndex]; + if (!hole.isActive || !hole.word) return; + + const isCorrect = hole.word.translation === this._targetWord.translation; + + if (isCorrect) { + // Correct answer + this._score += 10; + this._deactivateMole(holeIndex); + this._setNewTarget(); + this._showScorePopup(holeIndex, '+10', true); + this._showFeedback(`Well done! Now find: "${this._targetWord.translation}"`, 'success'); + + // Success animation + hole.mole.classList.add('hit'); + setTimeout(() => hole.mole.classList.remove('hit'), 500); + + // Emit correct hit event + this._eventBus.emit('whack-a-mole:correct-hit', { + gameId: 'whack-a-mole', + instanceId: this.name, + word: hole.word, + score: this._score + }, this.name); + + } else { + // Wrong answer + this._errors++; + this._score = Math.max(0, this._score - 2); + this._showScorePopup(holeIndex, '-2', false); + this._showFeedback(`Oops! "${hole.word.translation}" ≠ "${this._targetWord.translation}"`, 'error'); + + // Emit wrong hit event + this._eventBus.emit('whack-a-mole:wrong-hit', { + gameId: 'whack-a-mole', + instanceId: this.name, + word: hole.word, + targetWord: this._targetWord, + score: this._score, + errors: this._errors + }, this.name); + } + + this._updateUI(); + + // Check game end by errors + if (this._errors >= this._config.maxErrors) { + this._showFeedback('Too many errors! Game over.', 'error'); + setTimeout(() => { + if (this._isRunning) { + this._endGame(); + } + }, 1500); + } + } + + _setNewTarget() { + // Choose a new target word + const availableWords = this._vocabulary.filter(word => + !this._activeMoles.some(moleIndex => + this._holes[moleIndex].word && + this._holes[moleIndex].word.original === word.original + ) + ); + + if (availableWords.length > 0) { + this._targetWord = availableWords[Math.floor(Math.random() * availableWords.length)]; + } else { + this._targetWord = this._vocabulary[Math.floor(Math.random() * this._vocabulary.length)]; + } + + this._spawnsSinceTarget = 0; + document.getElementById('target-word').textContent = this._targetWord.translation; + } + + _getRandomWord() { + return this._vocabulary[Math.floor(Math.random() * this._vocabulary.length)]; + } + + _hideAllMoles() { + this._holes.forEach((hole, index) => { + if (hole.isActive) { + this._deactivateMole(index); + } + }); + this._activeMoles = []; + } + + _showScorePopup(holeIndex, scoreText, isPositive) { + const hole = this._holes[holeIndex]; + const popup = document.createElement('div'); + popup.className = `score-popup ${isPositive ? 'correct-answer' : 'wrong-answer'}`; + popup.textContent = scoreText; + + const rect = hole.element.getBoundingClientRect(); + popup.style.left = rect.left + rect.width / 2 + 'px'; + popup.style.top = rect.top + 'px'; + + document.body.appendChild(popup); + + setTimeout(() => { + if (popup.parentNode) { + popup.parentNode.removeChild(popup); + } + }, 1000); + } + + _showFeedback(message, type = 'info') { + const feedbackArea = document.getElementById('feedback-area'); + feedbackArea.innerHTML = `
${message}
`; + } + + _updateUI() { + document.getElementById('time-left').textContent = this._timeLeft; + document.getElementById('errors-count').textContent = this._errors; + document.getElementById('score-display').textContent = this._score; + } + + _endGame() { + this._stopGameWithoutEnd(); + this._showGameOverScreen(); + + // Emit game completion event + this._eventBus.emit('game:completed', { + gameId: 'whack-a-mole', + instanceId: this.name, + score: this._score, + errors: this._errors, + timeLeft: this._timeLeft, + duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0 + }, this.name); + } + + _stopGameWithoutEnd() { + this._isRunning = false; + this._stopTimers(); + this._hideAllMoles(); + + document.getElementById('start-btn').disabled = false; + document.getElementById('pause-btn').disabled = true; + } + + _resetGame() { + this._stopGameWithoutEnd(); + + this._score = 0; + this._errors = 0; + this._timeLeft = this._config.gameTime; + this._isRunning = false; + this._targetWord = null; + this._activeMoles = []; + this._spawnsSinceTarget = 0; + + this._stopTimers(); + this._updateUI(); + + document.getElementById('target-word').textContent = '---'; + this._showFeedback('Click Start to begin the game!', 'info'); + + document.getElementById('start-btn').disabled = false; + document.getElementById('pause-btn').disabled = true; + + // Clear all holes + this._holes.forEach(hole => { + if (hole.timer) { + clearTimeout(hole.timer); + hole.timer = null; + } + hole.isActive = false; + hole.word = null; + if (hole.wordElement) { + hole.wordElement.textContent = ''; + } + if (hole.pronunciationElement) { + hole.pronunciationElement.textContent = ''; + hole.pronunciationElement.style.display = 'none'; + } + if (hole.mole) { + hole.mole.classList.remove('active', 'hit'); + } + }); + } + + _showGameOverScreen() { + const duration = this._gameStartTime ? Math.round((Date.now() - this._gameStartTime) / 1000) : 0; + const accuracy = this._errors > 0 ? Math.round((this._score / (this._score + this._errors * 2)) * 100) : 100; + + // Store best score + const gameKey = 'whack-a-mole'; + const currentScore = this._score; + const bestScore = parseInt(localStorage.getItem(`${gameKey}-best-score`) || '0'); + const isNewBest = currentScore > bestScore; + + if (isNewBest) { + localStorage.setItem(`${gameKey}-best-score`, currentScore.toString()); + } + + // Show victory popup + this._showVictoryPopup({ + gameTitle: 'Whack-A-Mole', + currentScore, + bestScore: isNewBest ? currentScore : bestScore, + isNewBest, + stats: { + 'Accuracy': `${accuracy}%`, + 'Errors': `${this._errors}/${this._config.maxErrors}`, + 'Duration': `${duration}s`, + 'Hits': this._score + } + }); + } + + _showError(message) { + if (this._config.container) { + this._config.container.innerHTML = ` +
+

❌ Whack A Mole Error

+

${message}

+

This game requires vocabulary with translations.

+ +
+ `; + } + } + + _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; + } + + _handlePause() { + if (this._isRunning) { + this._pauseGame(); + } + this._eventBus.emit('game:paused', { instanceId: this.name }, this.name); + } + + _handleResume() { + if (!this._isRunning) { + this._startGame(); + } + this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name); + } + + _showVictoryPopup({ gameTitle, currentScore, bestScore, isNewBest, stats }) { + const popup = document.createElement('div'); + popup.className = 'victory-popup'; + popup.innerHTML = ` +
+
+
🎯
+

${gameTitle} Complete!

+ ${isNewBest ? '
🎉 New Best Score!
' : ''} +
+ +
+
+
Your Score
+
${currentScore}
+
+
+
Best Score
+
${bestScore}
+
+
+ +
+ ${Object.entries(stats).map(([key, value]) => ` +
+
${key}
+
${value}
+
+ `).join('')} +
+ +
+ + + +
+
+ `; + + document.body.appendChild(popup); + + // Animate in + requestAnimationFrame(() => { + popup.classList.add('show'); + }); + + // Add event listeners + popup.querySelector('#play-again-btn').addEventListener('click', () => { + popup.remove(); + this._restartGame(); + }); + + popup.querySelector('#different-game-btn').addEventListener('click', () => { + popup.remove(); + if (window.app && window.app.getCore().router) { + window.app.getCore().router.navigate('/games'); + } else { + window.location.href = '/#/games'; + } + }); + + popup.querySelector('#main-menu-btn').addEventListener('click', () => { + popup.remove(); + if (window.app && window.app.getCore().router) { + window.app.getCore().router.navigate('/'); + } else { + window.location.href = '/'; + } + }); + + // Close on backdrop click + popup.addEventListener('click', (e) => { + if (e.target === popup) { + popup.remove(); + if (window.app && window.app.getCore().router) { + window.app.getCore().router.navigate('/games'); + } else { + window.location.href = '/#/games'; + } + } + }); + } +} + +export default WhackAMole; \ No newline at end of file diff --git a/src/games/WhackAMoleHard.js b/src/games/WhackAMoleHard.js new file mode 100644 index 0000000..30938f7 --- /dev/null +++ b/src/games/WhackAMoleHard.js @@ -0,0 +1,1484 @@ +import Module from '../core/Module.js'; + +/** + * WhackAMoleHard - Advanced version with multiple moles per wave + * Hard mode features: + * - 5x3 grid (15 holes) + * - 3 moles per wave instead of 1 + * - Faster spawn rate + * - Shorter display time + * - Target word guarantee system (appears within 10 spawns) + */ +class WhackAMoleHard extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); + + // Validate dependencies + if (!dependencies.eventBus) { + throw new Error('WhackAMoleHard requires EventBus dependency'); + } + + this._eventBus = dependencies.eventBus; + this._content = dependencies.content; + this._config = { + container: null, + ...config + }; + + // Game state + this._score = 0; + this._errors = 0; + this._maxErrors = 3; + this._gameTime = 60; // 60 seconds + this._timeLeft = this._gameTime; + this._isRunning = false; + this._gameMode = 'translation'; + this._showPronunciation = false; + + // Mole configuration (HARD MODE) + this._holes = []; + this._activeMoles = []; + this._moleAppearTime = 3000; // 3 seconds display time (longer for hard mode) + this._spawnRate = 2000; // New wave every 2 seconds + this._molesPerWave = 3; // 3 moles per wave (HARD MODE) + + // Timers + this._gameTimer = null; + this._spawnTimer = null; + + // Vocabulary and game content + this._vocabulary = []; + this._currentWords = []; + this._targetWord = null; + + // Target word guarantee system + this._spawnsSinceTarget = 0; + this._maxSpawnsWithoutTarget = 10; // Target word must appear in the next 10 moles + + // DOM references + this._container = null; + this._gameBoard = null; + this._feedbackArea = null; + + // CSS injection + this._cssInjected = false; + + Object.seal(this); + } + + async init() { + this._validateNotDestroyed(); + + // Validate container + if (!this._config.container) { + throw new Error('Game container is required'); + } + + // Set up event listeners + this._eventBus.on('whack-hard:start', this._handleStart.bind(this), this.name); + this._eventBus.on('whack-hard:pause', this._handlePause.bind(this), this.name); + this._eventBus.on('whack-hard:restart', this._handleRestart.bind(this), this.name); + this._eventBus.on('whack-hard:toggle-pronunciation', this._handleTogglePronunciation.bind(this), this.name); + + // Start game immediately + try { + this._container = this._config.container; + const content = this._content; + + // Extract vocabulary from content + this._vocabulary = this._extractVocabulary(content); + + if (this._vocabulary.length === 0) { + this._showInitError(); + return; + } + + // Inject CSS + this._injectCSS(); + + // Create game interface + this._createGameBoard(); + this._setupEventListeners(); + + // Emit game ready event + this._eventBus.emit('game:ready', { + gameId: 'whack-a-mole-hard', + instanceId: this.name, + vocabulary: this._vocabulary.length + }, this.name); + + } catch (error) { + console.error('Error starting Whack A Mole Hard:', error); + this._showInitError(); + } + + this._setInitialized(); + } + + async destroy() { + this._validateNotDestroyed(); + + // Stop game and cleanup + this._stopGame(); + + // Remove injected CSS + this._removeCSS(); + + // Clear DOM + if (this._container) { + this._container.innerHTML = ''; + } + + this._setDestroyed(); + } + + // Public interface methods + render(container, content) { + this._validateInitialized(); + + this._container = container; + this._content = content; + + // Extract vocabulary from content + this._vocabulary = this._extractVocabulary(content); + + if (this._vocabulary.length === 0) { + this._showInitError(); + return; + } + + // Inject CSS + this._injectCSS(); + + // Create game interface + this._createGameBoard(); + this._setupEventListeners(); + } + + startGame() { + this._validateInitialized(); + this._start(); + } + + pauseGame() { + this._validateInitialized(); + this._pause(); + } + + restartGame() { + this._validateInitialized(); + this._restart(); + } + + // Private implementation methods + _injectCSS() { + if (this._cssInjected) return; + + const styleSheet = document.createElement('style'); + styleSheet.id = 'whack-hard-styles'; + styleSheet.textContent = ` + .whack-game-wrapper { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; + max-width: 1200px; + margin: 0 auto; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + } + + .mode-selector { + display: flex; + gap: 10px; + margin-bottom: 20px; + } + + .mode-btn { + padding: 10px 20px; + border: 2px solid #e5e7eb; + border-radius: 8px; + background: white; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s; + } + + .mode-btn:hover { + border-color: #3b82f6; + transform: translateY(-2px); + } + + .mode-btn.active { + background: #3b82f6; + color: white; + border-color: #3b82f6; + } + + .game-info { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + max-width: 800px; + margin-bottom: 30px; + padding: 20px; + background: #f8fafc; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + .game-stats { + display: flex; + gap: 30px; + } + + .stat-item { + text-align: center; + } + + .stat-value { + display: block; + font-size: 24px; + font-weight: bold; + color: #1f2937; + margin-bottom: 5px; + } + + .stat-label { + font-size: 12px; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .game-controls { + display: flex; + gap: 10px; + } + + .control-btn { + padding: 8px 16px; + border: none; + border-radius: 6px; + background: #3b82f6; + color: white; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s; + } + + .control-btn:hover:not(:disabled) { + background: #2563eb; + transform: translateY(-1px); + } + + .control-btn:disabled { + background: #9ca3af; + cursor: not-allowed; + transform: none; + } + + .control-btn.active { + background: #10b981; + } + + .whack-game-board.hard-mode { + display: grid; + grid-template-columns: repeat(5, 1fr); + grid-template-rows: repeat(3, 1fr); + gap: 15px; + padding: 30px; + background: #1f2937; + border-radius: 20px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + margin-bottom: 30px; + width: 100%; + max-width: 800px; + aspect-ratio: 5/3; + } + + .whack-hole { + position: relative; + background: #374151; + border-radius: 50%; + box-shadow: inset 0 4px 8px rgba(0, 0, 0, 0.3); + cursor: pointer; + overflow: hidden; + transition: all 0.2s; + } + + .whack-hole:hover { + transform: scale(1.05); + } + + .whack-mole { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: linear-gradient(45deg, #3b82f6, #1d4ed8); + color: white; + padding: 8px 12px; + border-radius: 12px; + font-size: 14px; + font-weight: bold; + text-align: center; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); + opacity: 0; + transform: translate(-50%, -50%) scale(0); + transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + cursor: pointer; + max-width: 90%; + word-wrap: break-word; + } + + .whack-mole.active { + opacity: 1; + transform: translate(-50%, -50%) scale(1); + } + + .whack-mole.hit { + background: linear-gradient(45deg, #10b981, #059669); + animation: hitAnimation 0.5s ease; + } + + @keyframes hitAnimation { + 0% { transform: translate(-50%, -50%) scale(1); } + 50% { transform: translate(-50%, -50%) scale(1.2); } + 100% { transform: translate(-50%, -50%) scale(1); } + } + + .whack-mole .pronunciation { + font-size: 0.8em; + color: #93c5fd; + font-style: italic; + margin-bottom: 5px; + font-weight: 500; + } + + .whack-mole .word { + font-size: 1em; + font-weight: bold; + } + + .feedback-area { + width: 100%; + max-width: 800px; + min-height: 60px; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + border-radius: 12px; + background: #f8fafc; + border: 2px solid #e5e7eb; + } + + .instruction { + font-size: 16px; + font-weight: 500; + text-align: center; + color: #374151; + } + + .instruction.info { + color: #3b82f6; + border-color: #3b82f6; + } + + .instruction.success { + color: #10b981; + background: #ecfdf5; + border-color: #10b981; + } + + .instruction.error { + color: #ef4444; + background: #fef2f2; + border-color: #ef4444; + } + + .score-popup { + position: fixed; + pointer-events: none; + font-size: 18px; + font-weight: bold; + padding: 8px 16px; + border-radius: 8px; + color: white; + z-index: 1000; + animation: scorePopup 1s ease-out forwards; + } + + .score-popup.correct-answer { + background: #10b981; + } + + .score-popup.wrong-answer { + background: #ef4444; + } + + @keyframes scorePopup { + 0% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0; + transform: translateY(-50px); + } + } + + .game-error { + text-align: center; + padding: 40px; + background: #fef2f2; + border: 2px solid #ef4444; + border-radius: 12px; + color: #dc2626; + } + + .game-error h3 { + margin: 0 0 16px 0; + font-size: 20px; + } + + .game-error p { + margin: 8px 0; + color: #7f1d1d; + } + + .back-btn { + padding: 12px 24px; + background: #3b82f6; + color: white; + border: none; + border-radius: 8px; + font-weight: 500; + cursor: pointer; + margin-top: 20px; + } + + .back-btn:hover { + background: #2563eb; + } + + /* Responsive design */ + @media (max-width: 768px) { + .whack-game-board.hard-mode { + max-width: 95%; + gap: 10px; + padding: 20px; + } + + .game-info { + flex-direction: column; + gap: 20px; + text-align: center; + } + + .game-controls { + justify-content: center; + } + + .whack-mole { + font-size: 12px; + padding: 6px 10px; + } + } + + /* Victory Popup Styles */ + .victory-popup { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: fadeIn 0.3s ease-out; + } + + .victory-content { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 20px; + padding: 40px; + text-align: center; + color: white; + max-width: 500px; + width: 90%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: slideUp 0.4s ease-out; + } + + .victory-header { + margin-bottom: 30px; + } + + .victory-icon { + font-size: 4rem; + margin-bottom: 15px; + animation: bounce 0.6s ease-out; + } + + .victory-title { + font-size: 2rem; + font-weight: bold; + margin: 0 0 10px 0; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + + .new-best-badge { + background: linear-gradient(45deg, #f093fb 0%, #f5576c 100%); + color: white; + padding: 8px 20px; + border-radius: 25px; + font-size: 0.9rem; + font-weight: bold; + display: inline-block; + margin-top: 10px; + animation: glow 1s ease-in-out infinite alternate; + } + + .victory-scores { + display: flex; + justify-content: space-around; + margin: 30px 0; + gap: 20px; + } + + .score-display { + background: rgba(255, 255, 255, 0.1); + border-radius: 15px; + padding: 20px; + flex: 1; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + } + + .score-label { + font-size: 0.9rem; + opacity: 0.9; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 1px; + } + + .score-value { + font-size: 2rem; + font-weight: bold; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + } + + .victory-stats { + background: rgba(255, 255, 255, 0.1); + border-radius: 15px; + padding: 20px; + margin: 30px 0; + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + } + + .stat-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + } + + .stat-row:last-child { + border-bottom: none; + } + + .stat-name { + font-size: 0.95rem; + opacity: 0.9; + } + + .stat-value { + font-weight: bold; + font-size: 1rem; + } + + .victory-buttons { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 30px; + } + + .victory-btn { + padding: 15px 30px; + border: none; + border-radius: 25px; + font-size: 1rem; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; + text-transform: uppercase; + letter-spacing: 1px; + } + + .victory-btn.primary { + background: linear-gradient(45deg, #4facfe 0%, #00f2fe 100%); + color: white; + box-shadow: 0 8px 25px rgba(79, 172, 254, 0.3); + } + + .victory-btn.primary:hover { + transform: translateY(-2px); + box-shadow: 0 12px 35px rgba(79, 172, 254, 0.4); + } + + .victory-btn.secondary { + background: linear-gradient(45deg, #a8edea 0%, #fed6e3 100%); + color: #333; + box-shadow: 0 8px 25px rgba(168, 237, 234, 0.3); + } + + .victory-btn.secondary:hover { + transform: translateY(-2px); + box-shadow: 0 12px 35px rgba(168, 237, 234, 0.4); + } + + .victory-btn.tertiary { + background: rgba(255, 255, 255, 0.2); + color: white; + border: 2px solid rgba(255, 255, 255, 0.3); + backdrop-filter: blur(10px); + } + + .victory-btn.tertiary:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); + } + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + + @keyframes slideUp { + from { + opacity: 0; + transform: translateY(30px) scale(0.9); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes bounce { + 0%, 20%, 50%, 80%, 100% { + transform: translateY(0); + } + 40% { + transform: translateY(-10px); + } + 60% { + transform: translateY(-5px); + } + } + + @keyframes glow { + from { + box-shadow: 0 0 20px rgba(245, 87, 108, 0.5); + } + to { + box-shadow: 0 0 30px rgba(245, 87, 108, 0.8); + } + } + + @media (max-width: 768px) { + .victory-content { + padding: 30px 20px; + width: 95%; + } + + .victory-scores { + flex-direction: column; + gap: 15px; + } + + .victory-icon { + font-size: 3rem; + } + + .victory-title { + font-size: 1.5rem; + } + + .victory-buttons { + gap: 10px; + } + + .victory-btn { + padding: 12px 25px; + font-size: 0.9rem; + } + } + `; + document.head.appendChild(styleSheet); + this._cssInjected = true; + } + + _removeCSS() { + const styleSheet = document.getElementById('whack-hard-styles'); + if (styleSheet) { + styleSheet.remove(); + this._cssInjected = false; + } + } + + _createGameBoard() { + this._container.innerHTML = ` +
+ +
+ + + +
+ + +
+
+
+ ${this._timeLeft} + Time +
+
+ ${this._errors} + Errors +
+
+ --- + Find +
+
+
+ + + + +
+
+ + +
+ +
+ + + +
+ `; + + this._gameBoard = this._container.querySelector('#game-board'); + this._feedbackArea = this._container.querySelector('#feedback-area'); + this._createHoles(); + } + + _createHoles() { + this._gameBoard.innerHTML = ''; + this._holes = []; + + for (let i = 0; i < 15; i++) { // 5x3 = 15 holes for hard mode + const hole = document.createElement('div'); + hole.className = 'whack-hole'; + hole.dataset.holeId = i; + + hole.innerHTML = ` +
+ +
+
+ `; + + this._gameBoard.appendChild(hole); + this._holes.push({ + element: hole, + mole: hole.querySelector('.whack-mole'), + wordElement: hole.querySelector('.word'), + pronunciationElement: hole.querySelector('.pronunciation'), + isActive: false, + word: null, + timer: null + }); + } + } + + _setupEventListeners() { + // Mode selection + this._container.querySelectorAll('.mode-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + if (this._isRunning) return; + + this._container.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + this._gameMode = btn.dataset.mode; + + if (this._gameMode !== 'translation') { + this._showFeedback('This mode will be available soon!', 'info'); + // Return to translation mode + this._container.querySelector('.mode-btn[data-mode="translation"]').classList.add('active'); + btn.classList.remove('active'); + this._gameMode = 'translation'; + } + }); + }); + + // Game controls + this._container.querySelector('#pronunciation-btn').addEventListener('click', () => this._togglePronunciation()); + this._container.querySelector('#start-btn').addEventListener('click', () => this._start()); + this._container.querySelector('#pause-btn').addEventListener('click', () => this._pause()); + this._container.querySelector('#restart-btn').addEventListener('click', () => this._restart()); + + // Mole clicks + this._holes.forEach((hole, index) => { + hole.mole.addEventListener('click', () => this._hitMole(index)); + }); + } + + _start() { + if (this._isRunning) return; + + this._isRunning = true; + this._score = 0; + this._errors = 0; + this._timeLeft = this._gameTime; + + this._updateUI(); + this._setNewTarget(); + this._startTimers(); + + this._container.querySelector('#start-btn').disabled = true; + this._container.querySelector('#pause-btn').disabled = false; + + this._showFeedback(`Find the word: "${this._targetWord.translation}"`, 'info'); + + // Notify EventBus + this._eventBus.emit('whack-hard:game-started', { + mode: this._gameMode, + vocabulary: this._vocabulary.length, + difficulty: 'hard' + }); + } + + _pause() { + if (!this._isRunning) return; + + this._isRunning = false; + this._stopTimers(); + this._hideAllMoles(); + + this._container.querySelector('#start-btn').disabled = false; + this._container.querySelector('#pause-btn').disabled = true; + + this._showFeedback('Game paused', 'info'); + + this._eventBus.emit('whack-hard:game-paused', { score: this._score }); + } + + _restart() { + this._stopGame(); + this._resetGame(); + setTimeout(() => this._start(), 100); + } + + _togglePronunciation() { + this._showPronunciation = !this._showPronunciation; + const btn = this._container.querySelector('#pronunciation-btn'); + + if (this._showPronunciation) { + btn.textContent = '🔊 Pronunciation ON'; + btn.classList.add('active'); + } else { + btn.textContent = '🔊 Pronunciation OFF'; + btn.classList.remove('active'); + } + + this._updateMoleDisplay(); + } + + _updateMoleDisplay() { + this._holes.forEach(hole => { + if (hole.isActive && hole.word) { + if (this._showPronunciation && hole.word.pronunciation) { + hole.pronunciationElement.textContent = hole.word.pronunciation; + hole.pronunciationElement.style.display = 'block'; + } else { + hole.pronunciationElement.style.display = 'none'; + } + } + }); + } + + _stopGame() { + this._isRunning = false; + this._stopTimers(); + this._hideAllMoles(); + + this._container.querySelector('#start-btn').disabled = false; + this._container.querySelector('#pause-btn').disabled = true; + } + + _resetGame() { + this._stopGame(); + + this._score = 0; + this._errors = 0; + this._timeLeft = this._gameTime; + this._targetWord = null; + this._activeMoles = []; + this._spawnsSinceTarget = 0; + + this._updateUI(); + this._container.querySelector('#target-word').textContent = '---'; + this._showFeedback('Select a mode and click Start!', 'info'); + + // Clear all holes + this._holes.forEach(hole => { + if (hole.timer) { + clearTimeout(hole.timer); + hole.timer = null; + } + hole.isActive = false; + hole.word = null; + if (hole.wordElement) hole.wordElement.textContent = ''; + if (hole.pronunciationElement) { + hole.pronunciationElement.textContent = ''; + hole.pronunciationElement.style.display = 'none'; + } + if (hole.mole) hole.mole.classList.remove('active', 'hit'); + }); + } + + _startTimers() { + // Main game timer + this._gameTimer = setInterval(() => { + this._timeLeft--; + this._updateUI(); + + if (this._timeLeft <= 0 && this._isRunning) { + this._endGame(); + } + }, 1000); + + // Mole spawn timer + this._spawnTimer = setInterval(() => { + if (this._isRunning) { + this._spawnMole(); + } + }, this._spawnRate); + + // First immediate mole wave + setTimeout(() => this._spawnMole(), 500); + } + + _stopTimers() { + if (this._gameTimer) { + clearInterval(this._gameTimer); + this._gameTimer = null; + } + if (this._spawnTimer) { + clearInterval(this._spawnTimer); + this._spawnTimer = null; + } + } + + _spawnMole() { + // Hard mode: Spawn multiple moles at once + this._spawnMultipleMoles(); + } + + _spawnMultipleMoles() { + // Find all free holes + const availableHoles = this._holes.filter(hole => !hole.isActive); + + // Spawn up to molesPerWave moles + const molesToSpawn = Math.min(this._molesPerWave, availableHoles.length); + + if (molesToSpawn === 0) return; + + // Shuffle available holes + const shuffledHoles = this._shuffleArray(availableHoles); + + // Spawn the moles + for (let i = 0; i < molesToSpawn; i++) { + const hole = shuffledHoles[i]; + const holeIndex = this._holes.indexOf(hole); + + // Choose a word according to guarantee strategy + const word = this._getWordWithTargetGuarantee(); + + // Activate the mole with a small delay for visual effect + setTimeout(() => { + if (this._isRunning && !hole.isActive) { + this._activateMole(holeIndex, word); + } + }, i * 200); // 200ms delay between each mole + } + } + + _getWordWithTargetGuarantee() { + // Increment spawn counter since last target word + this._spawnsSinceTarget++; + + // If we've reached the limit, force the target word + if (this._spawnsSinceTarget >= this._maxSpawnsWithoutTarget) { + this._spawnsSinceTarget = 0; + return this._targetWord; + } + + // Otherwise, 10% chance for target word (1/10 instead of 1/2) + if (Math.random() < 0.1) { + this._spawnsSinceTarget = 0; + return this._targetWord; + } else { + return this._getRandomWord(); + } + } + + _activateMole(holeIndex, word) { + const hole = this._holes[holeIndex]; + if (hole.isActive) return; + + hole.isActive = true; + hole.word = word; + hole.wordElement.textContent = word.original; + + // Show pronunciation if enabled and available + if (this._showPronunciation && word.pronunciation) { + hole.pronunciationElement.textContent = word.pronunciation; + hole.pronunciationElement.style.display = 'block'; + } else { + hole.pronunciationElement.style.display = 'none'; + } + + hole.mole.classList.add('active'); + this._activeMoles.push(holeIndex); + + // Timer to make the mole disappear + hole.timer = setTimeout(() => { + this._deactivateMole(holeIndex); + }, this._moleAppearTime); + } + + _deactivateMole(holeIndex) { + const hole = this._holes[holeIndex]; + if (!hole.isActive) return; + + hole.isActive = false; + hole.word = null; + hole.wordElement.textContent = ''; + hole.pronunciationElement.textContent = ''; + hole.pronunciationElement.style.display = 'none'; + hole.mole.classList.remove('active'); + + if (hole.timer) { + clearTimeout(hole.timer); + hole.timer = null; + } + + // Remove from active moles list + const activeIndex = this._activeMoles.indexOf(holeIndex); + if (activeIndex > -1) { + this._activeMoles.splice(activeIndex, 1); + } + } + + _hitMole(holeIndex) { + if (!this._isRunning) return; + + const hole = this._holes[holeIndex]; + if (!hole.isActive || !hole.word) return; + + const isCorrect = hole.word.translation === this._targetWord.translation; + + if (isCorrect) { + // Correct answer + this._score += 10; + this._deactivateMole(holeIndex); + this._setNewTarget(); + this._showScorePopup(holeIndex, '+10', true); + this._showFeedback(`Well done! Now find: "${this._targetWord.translation}"`, 'success'); + + // Success animation + hole.mole.classList.add('hit'); + setTimeout(() => hole.mole.classList.remove('hit'), 500); + + this._eventBus.emit('whack-hard:correct-hit', { + word: hole.word, + score: this._score + }); + + } else { + // Wrong answer + this._errors++; + this._score = Math.max(0, this._score - 2); + this._showScorePopup(holeIndex, '-2', false); + this._showFeedback(`Oops! "${hole.word.translation}" ≠ "${this._targetWord.translation}"`, 'error'); + + this._eventBus.emit('whack-hard:wrong-hit', { + expected: this._targetWord, + actual: hole.word, + errors: this._errors + }); + } + + this._updateUI(); + + // Check game end by errors + if (this._errors >= this._maxErrors) { + this._showFeedback('Too many errors! Game over.', 'error'); + setTimeout(() => { + if (this._isRunning) { + this._endGame(); + } + }, 1500); + } + } + + _setNewTarget() { + // Choose a new target word + const availableWords = this._vocabulary.filter(word => + !this._activeMoles.some(moleIndex => + this._holes[moleIndex].word && + this._holes[moleIndex].word.original === word.original + ) + ); + + if (availableWords.length > 0) { + this._targetWord = availableWords[Math.floor(Math.random() * availableWords.length)]; + } else { + this._targetWord = this._vocabulary[Math.floor(Math.random() * this._vocabulary.length)]; + } + + // Reset counter for new target word + this._spawnsSinceTarget = 0; + + this._container.querySelector('#target-word').textContent = this._targetWord.translation; + + this._eventBus.emit('whack-hard:new-target', { target: this._targetWord }); + } + + _getRandomWord() { + return this._vocabulary[Math.floor(Math.random() * this._vocabulary.length)]; + } + + _hideAllMoles() { + this._holes.forEach((hole, index) => { + if (hole.isActive) { + this._deactivateMole(index); + } + }); + this._activeMoles = []; + } + + _showScorePopup(holeIndex, scoreText, isPositive) { + const hole = this._holes[holeIndex]; + const popup = document.createElement('div'); + popup.className = `score-popup ${isPositive ? 'correct-answer' : 'wrong-answer'}`; + popup.textContent = scoreText; + + const rect = hole.element.getBoundingClientRect(); + popup.style.left = rect.left + rect.width / 2 + 'px'; + popup.style.top = rect.top + 'px'; + + document.body.appendChild(popup); + + setTimeout(() => { + if (popup.parentNode) { + popup.parentNode.removeChild(popup); + } + }, 1000); + } + + _showFeedback(message, type = 'info') { + this._feedbackArea.innerHTML = `
${message}
`; + } + + _updateUI() { + this._container.querySelector('#time-left').textContent = this._timeLeft; + this._container.querySelector('#errors-count').textContent = this._errors; + } + + _endGame() { + this._stopGame(); + + // Calculate stats for victory popup + const duration = this._gameTime - this._timeLeft; + const accuracy = this._score > 0 ? Math.round(((this._score / 10) / (this._score / 10 + this._errors)) * 100) : 0; + const hitRate = Math.round((this._score / 10) || 0); // since each hit = 10 points + + // Handle localStorage best score + const currentScore = this._score; + const bestScore = parseInt(localStorage.getItem('whack-hard-best-score') || '0'); + const isNewBest = currentScore > bestScore; + + if (isNewBest) { + localStorage.setItem('whack-hard-best-score', currentScore.toString()); + } + + this._showVictoryPopup({ + gameTitle: 'Whack-A-Mole Hard', + currentScore, + bestScore: isNewBest ? currentScore : bestScore, + isNewBest, + stats: { + 'Accuracy': `${accuracy}%`, + 'Successful Hits': hitRate, + 'Errors': `${this._errors}/${this._maxErrors}`, + 'Duration': `${duration}s` + } + }); + } + + _showInitError() { + this._container.innerHTML = ` +
+

❌ Loading Error

+

This content does not contain vocabulary compatible with Whack-a-Mole Hard.

+

The game requires words with their translations.

+ +
+ `; + } + + _extractVocabulary(content) { + let vocabulary = []; + + // Use content from dependency injection + if (!content) { + return this._getDemoVocabulary(); + } + + // Priority 1: Ultra-modular format (vocabulary object) + if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) { + vocabulary = Object.entries(content.vocabulary).map(([word, data]) => { + if (typeof data === 'object' && data.user_language) { + return { + original: word, + translation: data.user_language.split(';')[0], + fullTranslation: data.user_language, + type: data.type || 'general', + audio: data.audio, + image: data.image, + examples: data.examples, + pronunciation: data.pronunciation, + category: data.type || 'general' + }; + } else if (typeof data === 'string') { + return { + original: word, + translation: data.split(';')[0], + fullTranslation: data, + type: 'general', + category: 'general' + }; + } + return null; + }).filter(Boolean); + } + + // Priority 2: Legacy formats support + if (vocabulary.length === 0 && content.sentences) { + vocabulary = content.sentences.map(sentence => ({ + original: sentence.english || sentence.chinese || '', + translation: sentence.chinese || sentence.english || '', + pronunciation: sentence.prononciation || sentence.pronunciation + })).filter(word => word.original && word.translation); + } + + return this._finalizeVocabulary(vocabulary); + } + + _finalizeVocabulary(vocabulary) { + // Validation and cleanup + vocabulary = vocabulary.filter(word => + word && + typeof word.original === 'string' && + typeof word.translation === 'string' && + word.original.trim() !== '' && + word.translation.trim() !== '' + ); + + if (vocabulary.length === 0) { + vocabulary = this._getDemoVocabulary(); + } + + return this._shuffleArray(vocabulary); + } + + _getDemoVocabulary() { + return [ + { original: 'hello', translation: 'bonjour', category: 'greetings' }, + { original: 'goodbye', translation: 'au revoir', category: 'greetings' }, + { original: 'thank you', translation: 'merci', category: 'greetings' }, + { original: 'cat', translation: 'chat', category: 'animals' }, + { original: 'dog', translation: 'chien', category: 'animals' }, + { original: 'book', translation: 'livre', category: 'objects' }, + { original: 'water', translation: 'eau', category: 'nature' }, + { original: 'sun', translation: 'soleil', category: 'nature' } + ]; + } + + _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; + } + + _showVictoryPopup({ gameTitle, currentScore, bestScore, isNewBest, stats }) { + const popup = document.createElement('div'); + popup.className = 'victory-popup'; + popup.innerHTML = ` +
+
+
🔨
+

${gameTitle} Complete!

+ ${isNewBest ? '
🎉 New Best Score!
' : ''} +
+ +
+
+
Your Score
+
${currentScore}
+
+
+
Best Score
+
${bestScore}
+
+
+ +
+ ${Object.entries(stats).map(([key, value]) => ` +
+ ${key} + ${value} +
+ `).join('')} +
+ +
+ + + +
+
+ `; + + document.body.appendChild(popup); + + // Emit completion event after showing popup + this._eventBus.emit('whack-hard:game-ended', { + score: currentScore, + errors: this._errors, + timeLeft: this._timeLeft, + mode: this._gameMode + }); + } + + // Event handlers + _handleStart(event) { + this._validateInitialized(); + this.startGame(); + } + + _handlePause(event) { + this._validateInitialized(); + this.pauseGame(); + } + + _handleRestart(event) { + this._validateInitialized(); + this.restartGame(); + } + + _handleTogglePronunciation(event) { + this._validateInitialized(); + this._togglePronunciation(); + } + + // Static metadata methods + static getMetadata() { + return { + name: 'WhackAMoleHard', + version: '1.0.0', + description: 'Advanced Whack-a-Mole game with multiple moles per wave and increased difficulty', + author: 'Class Generator', + difficulty: 'hard', + category: 'action', + tags: ['vocabulary', 'reaction', 'translation', 'hard', 'multiple-targets'], + contentRequirements: ['vocabulary'], + supportedModes: ['translation'], + features: [ + 'Multiple moles per wave', + '15-hole grid (5x3)', + 'Target word guarantee system', + 'Pronunciation support', + 'Score tracking', + 'Time pressure', + 'Error limits' + ] + }; + } + + static getCompatibilityScore(content) { + let score = 0; + + if (!content) return 0; + + // Check vocabulary availability (required) + if (content.vocabulary && Object.keys(content.vocabulary).length > 0) { + score += 40; + + // Bonus for rich vocabulary data + const sampleEntry = Object.values(content.vocabulary)[0]; + if (typeof sampleEntry === 'object' && sampleEntry.user_language) { + score += 20; + } + + // Pronunciation bonus + const haspronounciation = Object.values(content.vocabulary).some(entry => + (typeof entry === 'object' && entry.pronunciation) || + (typeof entry === 'object' && entry.prononciation) + ); + if (haspronounciation) score += 15; + + // Volume bonus + const vocabCount = Object.keys(content.vocabulary).length; + if (vocabCount >= 20) score += 10; + if (vocabCount >= 50) score += 10; + + } else if (content.sentences && content.sentences.length > 0) { + // Fallback support for legacy format + score += 25; + } + + return Math.min(score, 100); + } +} + +export default WhackAMoleHard; \ No newline at end of file diff --git a/src/games/wizard-spell-caster.js b/src/games/WizardSpellCaster.js similarity index 51% rename from src/games/wizard-spell-caster.js rename to src/games/WizardSpellCaster.js index b6ec306..c364374 100644 --- a/src/games/wizard-spell-caster.js +++ b/src/games/WizardSpellCaster.js @@ -1,39 +1,374 @@ -// === WIZARD SPELL CASTER GAME === -// Advanced game for 11+ years old - Form sentences to cast magical spells +import Module from '../core/Module.js'; -class WizardSpellCaster { - constructor({ container, content, onScoreUpdate, onGameEnd }) { - this.container = container; - this.content = content; - this.onScoreUpdate = onScoreUpdate; - this.onGameEnd = onGameEnd; +/** + * WizardSpellCaster - Advanced RPG-style spell casting game + * Players construct sentences to cast magical spells and defeat enemies + */ +class WizardSpellCaster extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); - this.score = 0; - this.enemyHP = 100; - this.playerHP = 100; - this.currentSpells = []; - this.selectedWords = []; + // Validate dependencies + if (!dependencies.eventBus || !dependencies.content) { + throw new Error('WizardSpellCaster requires eventBus and content dependencies'); + } - // Timer invisible pour bonus de vitesse - this.spellStartTime = null; - this.averageSpellTime = 0; - this.spellCount = 0; + this._eventBus = dependencies.eventBus; + this._content = dependencies.content; + this._config = { + container: null, + enemyAttackInterval: { min: 8000, max: 15000 }, + enemyDamage: { min: 12, max: 20 }, + maxPlayerHP: 100, + maxEnemyHP: 100, + ...config + }; + + // Game state + this._score = 0; + this._enemyHP = this._config.maxEnemyHP; + this._playerHP = this._config.maxPlayerHP; + this._gameStartTime = null; + + // Spell system + this._spells = { short: [], medium: [], long: [] }; + this._currentSpells = []; + this._selectedSpell = null; + this._selectedWords = []; + this._spellStartTime = null; + this._averageSpellTime = 0; + this._spellCount = 0; // Enemy attack system - this.enemyAttackTimer = null; - this.nextEnemyAttack = this.getRandomAttackTime(); + this._enemyAttackTimer = null; + this._nextEnemyAttack = 0; - this.injectCSS(); - this.extractSpells(); - this.init(); + Object.seal(this); } - injectCSS() { - if (document.getElementById('wizard-spell-caster-styles')) return; + /** + * Get game metadata + * @returns {Object} Game metadata + */ + static getMetadata() { + return { + name: 'Wizard Spell Caster', + description: 'Advanced RPG spell casting game with sentence construction and magical combat', + difficulty: 'advanced', + category: 'rpg', + estimatedTime: 10, // minutes + skills: ['grammar', 'sentences', 'vocabulary', 'strategy', 'speed'] + }; + } - const styleSheet = document.createElement('style'); - styleSheet.id = 'wizard-spell-caster-styles'; - styleSheet.textContent = ` + /** + * Calculate compatibility score with content + * @param {Object} content - Content to check compatibility with + * @returns {Object} Compatibility score and details + */ + static getCompatibilityScore(content) { + const sentences = content?.sentences || []; + const storyChapters = content?.story?.chapters || []; + const dialogues = content?.dialogues || []; + + let totalSentences = sentences.length; + + // Count sentences from story chapters + storyChapters.forEach(chapter => { + if (chapter.sentences) { + totalSentences += chapter.sentences.length; + } + }); + + // Count sentences from dialogues + dialogues.forEach(dialogue => { + if (dialogue.conversation) { + totalSentences += dialogue.conversation.length; + } + }); + + // 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'], + minSentences: 9, + optimalSentences: 30, + details: `Can create engaging spell combat with ${totalSentences} sentences` + }; + } + + // Fallback: Check vocabulary for creating basic spell phrases + let vocabCount = 0; + let hasVocabulary = false; + + if (Array.isArray(content?.vocabulary)) { + vocabCount = content.vocabulary.length; + hasVocabulary = vocabCount > 0; + } else if (content?.vocabulary && typeof content.vocabulary === 'object') { + vocabCount = Object.keys(content.vocabulary).length; + hasVocabulary = vocabCount > 0; + } + + if (hasVocabulary && vocabCount >= 15) { + // Can create basic spell phrases from vocabulary + let score = 0.3; // Base score for vocabulary-based spells + + if (vocabCount >= 20) score += 0.1; + if (vocabCount >= 30) score += 0.1; + if (vocabCount >= 40) score += 0.1; + + return { + score: Math.min(score, 0.6), // Cap at 60% for vocabulary-only content + reason: `${vocabCount} vocabulary words available for spell creation`, + requirements: ['vocabulary'], + minWords: 15, + optimalWords: 40, + details: `Can create basic spell combat using ${vocabCount} vocabulary words` + }; + } + + return { + score: 0, + reason: `Insufficient content (${totalSentences} sentences, ${vocabCount} vocabulary words)`, + requirements: ['sentences', 'story', 'dialogues', 'vocabulary'], + minSentences: 9, + minWords: 15, + details: 'Wizard Spell Caster needs at least 9 sentences or 15 vocabulary words' + }; + } + + async init() { + this._validateNotDestroyed(); + + try { + // Validate container + if (!this._config.container) { + throw new Error('Game container is required'); + } + + // Extract and validate spells + this._extractSpells(); + if (this._getTotalSpellCount() < 9) { + throw new Error(`Insufficient spells: need 9, got ${this._getTotalSpellCount()}`); + } + + // Set up event listeners + this._eventBus.on('game:pause', this._handlePause.bind(this), this.name); + this._eventBus.on('game:resume', this._handleResume.bind(this), this.name); + + // Inject CSS + this._injectCSS(); + + // Initialize game interface + this._createGameInterface(); + this._setupEventListeners(); + this._generateNewSpells(); + this._startEnemyAttackSystem(); + + // Start the game + this._gameStartTime = Date.now(); + + // Emit game ready event + this._eventBus.emit('game:ready', { + gameId: 'wizard-spell-caster', + instanceId: this.name, + spells: { + short: this._spells.short.length, + medium: this._spells.medium.length, + long: this._spells.long.length + } + }, this.name); + + this._setInitialized(); + + } catch (error) { + this._showError(error.message); + throw error; + } + } + + async destroy() { + this._validateNotDestroyed(); + + // Clear timers + if (this._enemyAttackTimer) { + clearTimeout(this._enemyAttackTimer); + this._enemyAttackTimer = null; + } + + // Remove CSS + this._removeCSS(); + + // Clean up event listeners + if (this._config.container) { + this._config.container.innerHTML = ''; + } + + // Emit game end event + this._eventBus.emit('game:ended', { + gameId: 'wizard-spell-caster', + instanceId: this.name, + score: this._score, + playerHP: this._playerHP, + enemyHP: this._enemyHP, + spellsCast: this._spellCount, + duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0 + }, this.name); + + this._setDestroyed(); + } + + /** + * Get current game state + * @returns {Object} Current game state + */ + getGameState() { + this._validateInitialized(); + + return { + score: this._score, + playerHP: this._playerHP, + enemyHP: this._enemyHP, + maxPlayerHP: this._config.maxPlayerHP, + maxEnemyHP: this._config.maxEnemyHP, + spellsCast: this._spellCount, + averageSpellTime: this._averageSpellTime, + isComplete: this._playerHP <= 0 || this._enemyHP <= 0, + duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0 + }; + } + + // Private methods + _extractSpells() { + this._spells = { short: [], medium: [], long: [] }; + + // Extract from sentences + if (this._content.sentences) { + this._content.sentences.forEach(sentence => { + const originalText = sentence.english || sentence.original_language; + if (originalText) { + this._processSentence({ + original: originalText, + translation: sentence.chinese || sentence.french || sentence.user_language || sentence.translation, + words: this._extractWordsFromSentence(originalText) + }); + } + }); + } + + // Extract from story chapters + if (this._content.story?.chapters) { + this._content.story.chapters.forEach(chapter => { + if (chapter.sentences) { + chapter.sentences.forEach(sentence => { + if (sentence.original) { + this._processSentence({ + original: sentence.original, + translation: sentence.translation, + words: sentence.words || this._extractWordsFromSentence(sentence.original) + }); + } + }); + } + }); + } + + // Extract from dialogues + if (this._content.dialogues) { + this._content.dialogues.forEach(dialogue => { + if (dialogue.conversation) { + dialogue.conversation.forEach(line => { + if (line.english && line.chinese) { + this._processSentence({ + original: line.english, + translation: line.chinese, + words: this._extractWordsFromSentence(line.english) + }); + } + }); + } + }); + } + } + + _processSentence(sentenceData) { + if (!sentenceData.original || !sentenceData.translation) return; + + const wordCount = sentenceData.words.length; + const spellData = { + english: sentenceData.original, + translation: sentenceData.translation, + words: sentenceData.words, + damage: this._calculateDamage(wordCount), + castTime: this._calculateCastTime(wordCount) + }; + + if (wordCount <= 4) { + this._spells.short.push(spellData); + } else if (wordCount <= 6) { + this._spells.medium.push(spellData); + } else { + this._spells.long.push(spellData); + } + } + + _extractWordsFromSentence(sentence) { + // Validate input sentence + if (!sentence || typeof sentence !== 'string') { + console.warn('WizardSpellCaster: Invalid sentence provided to _extractWordsFromSentence:', sentence); + return []; + } + + // Simple word extraction with punctuation handling + const words = sentence.split(/\s+/).map(word => { + return { + word: word.replace(/[.!?,;:]/g, ''), + translation: word.replace(/[.!?,;:]/g, ''), + type: 'word' + }; + }).filter(wordData => wordData.word.length > 0); + + // Add punctuation as separate elements + const punctuation = sentence.match(/[.!?,;:]/g) || []; + punctuation.forEach((punct, index) => { + words.push({ + word: punct, + translation: punct, + type: 'punctuation', + uniqueId: `punct_${index}_${Date.now()}_${Math.random()}` + }); + }); + + return words; + } + + _calculateDamage(wordCount) { + if (wordCount <= 3) return Math.floor(Math.random() * 10) + 15; // 15-25 + if (wordCount <= 5) return Math.floor(Math.random() * 15) + 30; // 30-45 + if (wordCount <= 7) return Math.floor(Math.random() * 20) + 50; // 50-70 + return Math.floor(Math.random() * 30) + 70; // 70-100 + } + + _calculateCastTime(wordCount) { + if (wordCount <= 4) return 1000; // 1 second + if (wordCount <= 6) return 2000; // 2 seconds + return 3000; // 3 seconds + } + + _getTotalSpellCount() { + return this._spells.short.length + this._spells.medium.length + this._spells.long.length; + } + + _injectCSS() { + const cssId = `wizard-spell-caster-styles-${this.name}`; + if (document.getElementById(cssId)) return; + + const style = document.createElement('style'); + style.id = cssId; + style.textContent = ` .wizard-game-wrapper { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); min-height: 100vh; @@ -167,6 +502,7 @@ class WizardSpellCaster { background: linear-gradient(135deg, #ffd700, #ffed4e); color: #000; transform: scale(1.05); + animation: spellCharging 0.5s ease-in-out infinite alternate; } .spell-type { @@ -250,6 +586,22 @@ class WizardSpellCaster { box-shadow: none; } + .exit-wizard-btn { + padding: 8px 15px; + background: rgba(255, 255, 255, 0.1); + border: 2px solid rgba(255, 255, 255, 0.3); + color: white; + border-radius: 8px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.3s ease; + } + + .exit-wizard-btn:hover { + background: rgba(255, 255, 255, 0.2); + transform: translateY(-2px); + } + .damage-number { position: absolute; font-size: 36px; @@ -281,15 +633,21 @@ class WizardSpellCaster { } .fire-effect { - background: radial-gradient(circle, #ff6b7a, #ff4757, transparent); + background: radial-gradient(circle, #ff6b7a, #ff4757, #ff3742, transparent); + filter: drop-shadow(0 0 20px #ff4757); + animation: spellBlast 0.8s ease-out forwards, fireGlow 0.8s ease-out; } .lightning-effect { - background: radial-gradient(circle, #ffd700, #ffed4e, transparent); + background: radial-gradient(circle, #ffd700, #ffed4e, #fff200, transparent); + filter: drop-shadow(0 0 25px #ffd700); + animation: spellBlast 0.8s ease-out forwards, lightningPulse 0.8s ease-out; } .meteor-effect { - background: radial-gradient(circle, #a29bfe, #6c5ce7, transparent); + background: radial-gradient(circle, #a29bfe, #6c5ce7, #5f3dc4, transparent); + filter: drop-shadow(0 0 30px #6c5ce7); + animation: spellBlast 0.8s ease-out forwards, meteorImpact 0.8s ease-out; } @keyframes spellBlast { @@ -307,6 +665,33 @@ class WizardSpellCaster { } } + @keyframes fireGlow { + 0%, 100% { filter: drop-shadow(0 0 20px #ff4757) hue-rotate(0deg); } + 50% { filter: drop-shadow(0 0 40px #ff4757) hue-rotate(30deg); } + } + + @keyframes lightningPulse { + 0%, 100% { filter: drop-shadow(0 0 25px #ffd700) brightness(1); } + 50% { filter: drop-shadow(0 0 50px #ffd700) brightness(2); } + } + + @keyframes meteorImpact { + 0% { filter: drop-shadow(0 0 30px #6c5ce7) contrast(1); } + 30% { filter: drop-shadow(0 0 60px #6c5ce7) contrast(1.5); } + 100% { filter: drop-shadow(0 0 30px #6c5ce7) contrast(1); } + } + + @keyframes spellCharging { + 0% { + box-shadow: 0 0 20px rgba(255, 215, 0, 0.5); + transform: scale(1.05); + } + 100% { + box-shadow: 0 0 40px rgba(255, 215, 0, 0.8); + transform: scale(1.07); + } + } + .mini-enemy { position: absolute; width: 60px; @@ -354,214 +739,42 @@ class WizardSpellCaster { .flying-bird { position: fixed; - font-size: 48px; /* Plus gros ! */ + font-size: 48px; z-index: 500; pointer-events: none; - width: 60px; /* Plus gros ! */ - height: 60px; /* Plus gros ! */ + width: 60px; + height: 60px; display: flex; align-items: center; justify-content: center; } - .bird-path-1 { - animation: flyPath1 8s linear infinite; /* Plus rapide ! */ - } - - .bird-path-2 { - animation: flyPath2 6s linear infinite; /* Plus rapide ! */ - } - - .bird-path-3 { - animation: flyPath3 10s linear infinite; /* Plus rapide ! */ - } - - .bird-path-4 { - animation: flyPath4 12s linear infinite; /* Nouveau chemin ! */ - } - - .bird-path-5 { - animation: flyPath5 9s linear infinite; /* Encore un autre ! */ - } + .bird-path-1 { animation: flyPath1 8s linear infinite; } + .bird-path-2 { animation: flyPath2 6s linear infinite; } + .bird-path-3 { animation: flyPath3 10s linear infinite; } @keyframes flyPath1 { - 0% { - left: -100px; - top: 20vh; - transform: rotate(0deg) scale(1); - } - 15% { - left: 30vw; - top: 5vh; - transform: rotate(180deg) scale(1.5); /* Rotation plus douce */ - } - 30% { - left: 70vw; - top: 40vh; - transform: rotate(-90deg) scale(0.5); - } - 45% { - left: 100vw; - top: 15vh; - transform: rotate(270deg) scale(2); - } - 60% { - left: 60vw; - top: 85vh; - transform: rotate(-180deg) scale(0.8); - } - 80% { - left: 10vw; - top: 60vh; - transform: rotate(360deg) scale(1.2); - } - 100% { - left: -100px; - top: 20vh; - transform: rotate(450deg) scale(1); - } + 0% { left: -100px; top: 20vh; transform: rotate(0deg) scale(1); } + 25% { left: 30vw; top: 5vh; transform: rotate(180deg) scale(1.5); } + 50% { left: 70vw; top: 40vh; transform: rotate(-90deg) scale(0.5); } + 75% { left: 100vw; top: 15vh; transform: rotate(270deg) scale(2); } + 100% { left: -100px; top: 20vh; transform: rotate(360deg) scale(1); } } @keyframes flyPath2 { - 0% { - left: 50vw; - top: -80px; - transform: rotate(0deg) scale(0.5); - } - 20% { - left: 80vw; - top: 20vh; - transform: rotate(-225deg) scale(2.5); - } - 40% { - left: 20vw; - top: 50vh; - transform: rotate(315deg) scale(0.3); - } - 60% { - left: 90vw; - top: 80vh; - transform: rotate(-450deg) scale(3); - } - 80% { - left: 30vw; - top: 30vh; - transform: rotate(540deg) scale(0.7); - } - 100% { - left: 50vw; - top: -80px; - transform: rotate(-630deg) scale(0.5); - } + 0% { left: 50vw; top: -80px; transform: rotate(0deg) scale(0.5); } + 33% { left: 80vw; top: 20vh; transform: rotate(-225deg) scale(2.5); } + 66% { left: 20vw; top: 50vh; transform: rotate(315deg) scale(0.3); } + 100% { left: 50vw; top: -80px; transform: rotate(-630deg) scale(0.5); } } @keyframes flyPath3 { - 0% { - left: 120vw; - top: 10vh; - transform: rotate(0deg) scale(1); - } - 12% { - left: 75vw; - top: 70vh; - transform: rotate(-360deg) scale(4); - } - 25% { - left: 25vw; - top: 20vh; - transform: rotate(540deg) scale(0.2); - } - 37% { - left: 85vw; - top: 90vh; - transform: rotate(-720deg) scale(3.5); - } - 50% { - left: 10vw; - top: 5vh; - transform: rotate(900deg) scale(0.4); - } - 62% { - left: 90vw; - top: 55vh; - transform: rotate(-1080deg) scale(2.8); - } - 75% { - left: 40vw; - top: 95vh; - transform: rotate(1260deg) scale(0.6); - } - 87% { - left: 70vw; - top: 25vh; - transform: rotate(-1440deg) scale(3.2); - } - 100% { - left: 120vw; - top: 10vh; - transform: rotate(1800deg) scale(1); - } - } - - @keyframes flyPath4 { - 0% { - left: 25vw; - top: 100vh; - transform: rotate(0deg) scale(1.2); - } - 20% { - left: 75vw; - top: 80vh; - transform: rotate(180deg) scale(0.7); - } - 40% { - left: 90vw; - top: 40vh; - transform: rotate(-270deg) scale(2.2); - } - 60% { - left: 40vw; - top: 20vh; - transform: rotate(360deg) scale(0.4); - } - 80% { - left: 10vw; - top: 70vh; - transform: rotate(-450deg) scale(1.8); - } - 100% { - left: 25vw; - top: 100vh; - transform: rotate(540deg) scale(1.2); - } - } - - @keyframes flyPath5 { - 0% { - left: 100vw; - top: 50vh; - transform: rotate(45deg) scale(0.8); - } - 25% { - left: 60vw; - top: 10vh; - transform: rotate(-135deg) scale(2.5); - } - 50% { - left: 20vw; - top: 80vh; - transform: rotate(225deg) scale(0.3); - } - 75% { - left: 80vw; - top: 90vh; - transform: rotate(-315deg) scale(3); - } - 100% { - left: 100vw; - top: 50vh; - transform: rotate(405deg) scale(0.8); - } + 0% { left: 120vw; top: 10vh; transform: rotate(0deg) scale(1); } + 20% { left: 75vw; top: 70vh; transform: rotate(-360deg) scale(4); } + 40% { left: 25vw; top: 20vh; transform: rotate(540deg) scale(0.2); } + 60% { left: 85vw; top: 90vh; transform: rotate(-720deg) scale(3.5); } + 80% { left: 10vw; top: 5vh; transform: rotate(900deg) scale(0.4); } + 100% { left: 120vw; top: 10vh; transform: rotate(1800deg) scale(1); } } .screen-shake { @@ -613,18 +826,9 @@ class WizardSpellCaster { } @keyframes enemyAttackBlast { - 0% { - transform: scale(0); - opacity: 1; - } - 50% { - transform: scale(1.5); - opacity: 0.8; - } - 100% { - transform: scale(3); - opacity: 0; - } + 0% { transform: scale(0); opacity: 1; } + 50% { transform: scale(1.5); opacity: 0.8; } + 100% { transform: scale(3); opacity: 0; } } .enemy-charging { @@ -671,6 +875,39 @@ class WizardSpellCaster { color: #ff4757; } + .game-result-btn { + margin: 10px; + padding: 15px 30px; + border: 2px solid white; + border-radius: 10px; + background: transparent; + color: white; + font-size: 18px; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; + } + + .game-result-btn.victory { + border-color: #2ed573; + color: #2ed573; + } + + .game-result-btn.victory:hover { + background: #2ed573; + color: white; + } + + .game-result-btn.defeat { + border-color: #ff4757; + color: #ff4757; + } + + .game-result-btn.defeat:hover { + background: #ff4757; + color: white; + } + .fail-message { position: fixed; top: 30%; @@ -689,257 +926,92 @@ class WizardSpellCaster { } @keyframes failMessagePop { - 0% { - transform: translateX(-50%) scale(0); - opacity: 0; - } - 20% { - transform: translateX(-50%) scale(1.2); - opacity: 1; - } - 80% { - transform: translateX(-50%) scale(1); - opacity: 1; - } - 100% { - transform: translateX(-50%) scale(0.8); - opacity: 0; - } + 0% { transform: translateX(-50%) scale(0); opacity: 0; } + 20% { transform: translateX(-50%) scale(1.2); opacity: 1; } + 80% { transform: translateX(-50%) scale(1); opacity: 1; } + 100% { transform: translateX(-50%) scale(0.8); opacity: 0; } } - /* === ENHANCED SPELL EFFECTS === */ - - /* Particle animations */ - @keyframes fireParticle { - 0% { - transform: scale(1) translate(0, 0); - opacity: 1; - } - 50% { - transform: scale(1.5) translate(var(--random-x, 20px), var(--random-y, -30px)); - opacity: 0.8; - } - 100% { - transform: scale(0.5) translate(var(--random-x, 40px), var(--random-y, -60px)); - opacity: 0; - } + .game-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + text-align: center; + padding: 40px; + background: linear-gradient(135deg, #f87171 0%, #ef4444 100%); + color: white; } - @keyframes lightningParticle { - 0% { - transform: scale(1) translate(0, 0); - opacity: 1; - filter: brightness(2); - } - 25% { - transform: scale(2) translate(var(--random-x, 10px), var(--random-y, -20px)); - opacity: 1; - filter: brightness(3); - } - 100% { - transform: scale(0) translate(var(--random-x, 30px), var(--random-y, -50px)); - opacity: 0; - filter: brightness(1); - } + .game-error h3 { + font-size: 2rem; + margin-bottom: 20px; } - @keyframes meteorParticle { - 0% { - transform: scale(0.5) translate(0, -100px); - opacity: 0.5; - } - 30% { - transform: scale(1.2) translate(var(--random-x, 0px), 0); - opacity: 1; - } - 100% { - transform: scale(0.3) translate(var(--random-x, 20px), var(--random-y, 50px)); - opacity: 0; - } + .back-btn { + padding: 12px 25px; + background: white; + color: #ef4444; + border: none; + border-radius: 8px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + margin-top: 20px; } - /* Screen effects */ - @keyframes meteorTrail { - 0% { - opacity: 0; - transform: translateX(100px) scaleY(0); - } - 50% { - opacity: 1; - transform: translateX(0) scaleY(1); - } - 100% { - opacity: 0; - transform: translateX(-100px) scaleY(0.5); - } + .back-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(255, 255, 255, 0.3); } - @keyframes lightningFlash { - 0% { - opacity: 0; + @media (max-width: 768px) { + .wizard-game-wrapper { + padding: 10px; } - 50% { - opacity: 0.8; - } - 100% { - opacity: 0; - } - } - @keyframes fireRipple { - 0% { - transform: scale(0.5); - opacity: 1; - border-width: 3px; + .battle-area { + height: 50vh; + padding: 10px; } - 50% { - transform: scale(1.2); - opacity: 0.6; - border-width: 2px; + + .wizard-character, .enemy-character { + width: 80px; + height: 80px; + font-size: 32px; } - 100% { - transform: scale(2); - opacity: 0; - border-width: 1px; + + .spell-selection { + grid-template-columns: 1fr; + gap: 10px; } - } - /* Enhanced spell effect improvements */ - .fire-effect { - background: radial-gradient(circle, #ff6b7a, #ff4757, #ff3742, transparent); - filter: drop-shadow(0 0 20px #ff4757); - animation: spellBlast 0.8s ease-out forwards, fireGlow 0.8s ease-out; - } - - .lightning-effect { - background: radial-gradient(circle, #ffd700, #ffed4e, #fff200, transparent); - filter: drop-shadow(0 0 25px #ffd700); - animation: spellBlast 0.8s ease-out forwards, lightningPulse 0.8s ease-out; - } - - .meteor-effect { - background: radial-gradient(circle, #a29bfe, #6c5ce7, #5f3dc4, transparent); - filter: drop-shadow(0 0 30px #6c5ce7); - animation: spellBlast 0.8s ease-out forwards, meteorImpact 0.8s ease-out; - } - - @keyframes fireGlow { - 0%, 100% { filter: drop-shadow(0 0 20px #ff4757) hue-rotate(0deg); } - 50% { filter: drop-shadow(0 0 40px #ff4757) hue-rotate(30deg); } - } - - @keyframes lightningPulse { - 0%, 100% { filter: drop-shadow(0 0 25px #ffd700) brightness(1); } - 50% { filter: drop-shadow(0 0 50px #ffd700) brightness(2); } - } - - @keyframes meteorImpact { - 0% { filter: drop-shadow(0 0 30px #6c5ce7) contrast(1); } - 30% { filter: drop-shadow(0 0 60px #6c5ce7) contrast(1.5); } - 100% { filter: drop-shadow(0 0 30px #6c5ce7) contrast(1); } - } - - /* Spell casting enhancement */ - .spell-card.selected { - animation: spellCharging 0.5s ease-in-out infinite alternate; - } - - @keyframes spellCharging { - 0% { - box-shadow: 0 0 20px rgba(255, 215, 0, 0.5); - transform: scale(1); + .word-bank { + gap: 5px; } - 100% { - box-shadow: 0 0 40px rgba(255, 215, 0, 0.8); - transform: scale(1.02); - } - } - /* Casting effect animations */ - @keyframes magicCircleForm { - 0% { - transform: scale(0.5) rotate(0deg); - opacity: 0; - } - 50% { - transform: scale(1.1) rotate(180deg); - opacity: 1; - } - 100% { - transform: scale(1) rotate(360deg); - opacity: 0; - } - } - - @keyframes castingSparkle { - 0% { - transform: scale(1) rotate(0deg); - opacity: 1; - } - 50% { - transform: scale(1.5) rotate(180deg); - opacity: 0.8; - } - 100% { - transform: scale(0.5) rotate(360deg); - opacity: 0; + .word-tile { + padding: 6px 12px; + font-size: 0.9rem; } } `; - document.head.appendChild(styleSheet); + + document.head.appendChild(style); } - extractSpells() { - // Extract sentences from content and categorize by length - this.spells = { - short: [], // 3-4 words - medium: [], // 5-6 words - long: [] // 7+ words - }; - - // Process story sentences - if (this.content.story && this.content.story.chapters) { - this.content.story.chapters.forEach(chapter => { - chapter.sentences.forEach(sentence => { - const wordCount = sentence.words.length; - const spellData = { - english: sentence.original, - translation: sentence.translation, - words: sentence.words, - damage: this.calculateDamage(wordCount), - castTime: this.calculateCastTime(wordCount) - }; - - if (wordCount <= 4) { - this.spells.short.push(spellData); - } else if (wordCount <= 6) { - this.spells.medium.push(spellData); - } else { - this.spells.long.push(spellData); - } - }); - }); + _removeCSS() { + const cssId = `wizard-spell-caster-styles-${this.name}`; + const existingStyle = document.getElementById(cssId); + if (existingStyle) { + existingStyle.remove(); } - - console.log('Spells extracted:', this.spells); } - calculateDamage(wordCount) { - // Augmenter significativement les points pour les phrases longues - if (wordCount <= 3) return Math.floor(Math.random() * 10) + 15; // 15-25 (phrases courtes) - if (wordCount <= 5) return Math.floor(Math.random() * 15) + 30; // 30-45 (phrases moyennes) - if (wordCount <= 7) return Math.floor(Math.random() * 20) + 50; // 50-70 (phrases longues) - return Math.floor(Math.random() * 30) + 70; // 70-100 (phrases très longues) - } - - calculateCastTime(wordCount) { - if (wordCount <= 4) return 1000; // 1 second - if (wordCount <= 6) return 2000; // 2 seconds - return 3000; // 3 seconds - } - - init() { - this.container.innerHTML = ` + _createGameInterface() { + this._config.container.innerHTML = `
@@ -960,6 +1032,9 @@ class WizardSpellCaster {
+
@@ -992,57 +1067,62 @@ class WizardSpellCaster { `; - - this.setupEventListeners(); - this.generateNewSpells(); - this.startEnemyAttackSystem(); } - setupEventListeners() { - document.getElementById('cast-button').addEventListener('click', () => this.castSpell()); + _setupEventListeners() { + // Cast button + document.getElementById('cast-button').addEventListener('click', () => this._castSpell()); + + // Exit button + const exitButton = document.getElementById('exit-wizard'); + if (exitButton) { + exitButton.addEventListener('click', () => { + this._eventBus.emit('game:exit-request', { instanceId: this.name }, this.name); + }); + } } - generateNewSpells() { - this.currentSpells = []; + _generateNewSpells() { + this._currentSpells = []; // Get one spell of each type - if (this.spells.short.length > 0) { - this.currentSpells.push({ - ...this.spells.short[Math.floor(Math.random() * this.spells.short.length)], + if (this._spells.short.length > 0) { + this._currentSpells.push({ + ...this._spells.short[Math.floor(Math.random() * this._spells.short.length)], type: 'short', name: 'Fireball', icon: '🔥' }); } - if (this.spells.medium.length > 0) { - this.currentSpells.push({ - ...this.spells.medium[Math.floor(Math.random() * this.spells.medium.length)], + if (this._spells.medium.length > 0) { + this._currentSpells.push({ + ...this._spells.medium[Math.floor(Math.random() * this._spells.medium.length)], type: 'medium', name: 'Lightning', icon: '⚡' }); } - if (this.spells.long.length > 0) { - this.currentSpells.push({ - ...this.spells.long[Math.floor(Math.random() * this.spells.long.length)], + if (this._spells.long.length > 0) { + this._currentSpells.push({ + ...this._spells.long[Math.floor(Math.random() * this._spells.long.length)], type: 'long', name: 'Meteor', icon: '☄️' }); } - this.renderSpellCards(); - this.selectedSpell = null; - this.selectedWords = []; - this.updateWordBank(); - this.updateSentenceBuilder(); + this._renderSpellCards(); + this._selectedSpell = null; + this._selectedWords = []; + this._updateWordBank(); + this._updateSentenceBuilder(); } - renderSpellCards() { + _renderSpellCards() { const container = document.getElementById('spell-selection'); - container.innerHTML = this.currentSpells.map((spell, index) => ` + container.innerHTML = this._currentSpells.map((spell, index) => `
${spell.icon} ${spell.name}
${spell.translation}
@@ -1054,54 +1134,38 @@ class WizardSpellCaster { container.querySelectorAll('.spell-card').forEach(card => { card.addEventListener('click', (e) => { const spellIndex = parseInt(e.currentTarget.dataset.spellIndex); - this.selectSpell(spellIndex); + this._selectSpell(spellIndex); }); }); } - selectSpell(index) { + _selectSpell(index) { // Remove previous selection document.querySelectorAll('.spell-card').forEach(card => card.classList.remove('selected')); // Select new spell - this.selectedSpell = this.currentSpells[index]; + this._selectedSpell = this._currentSpells[index]; document.querySelector(`[data-spell-index="${index}"]`).classList.add('selected'); - // Démarrer le timer invisible pour le bonus de vitesse - this.spellStartTime = Date.now(); + // Start timing for speed bonus + this._spellStartTime = Date.now(); // Reset word selection - this.selectedWords = []; - this.updateWordBank(); - this.updateSentenceBuilder(); + this._selectedWords = []; + this._updateWordBank(); + this._updateSentenceBuilder(); } - updateWordBank() { + _updateWordBank() { const container = document.getElementById('word-bank'); - if (!this.selectedSpell) { + if (!this._selectedSpell) { container.innerHTML = '
Select a spell first
'; return; } - // Extract the complete sentence including ALL punctuation - const originalSentence = this.selectedSpell.english; - const words = [...this.selectedSpell.words]; - - // Extract ALL punctuation from the original sentence - const punctuationRegex = /[.!?,;:]/g; - const punctuationMarks = originalSentence.match(punctuationRegex) || []; - - // Add all punctuation marks as separate word tiles with unique IDs - punctuationMarks.forEach((punctuation, index) => { - words.push({ - word: punctuation, - translation: punctuation, - type: 'punctuation', - pronunciation: '', - uniqueId: `punct_${index}_${Date.now()}_${Math.random()}` - }); - }); + // Get words and add punctuation + const words = [...this._selectedSpell.words]; // Shuffle the words including punctuation const shuffledWords = [...words].sort(() => Math.random() - 0.5); @@ -1120,42 +1184,40 @@ class WizardSpellCaster { tile.addEventListener('click', (e) => { const word = e.currentTarget.dataset.word; const uniqueId = e.currentTarget.dataset.uniqueId; - this.toggleWord(word, e.currentTarget, uniqueId); + this._toggleWord(word, e.currentTarget, uniqueId); }); }); } - toggleWord(word, element, uniqueId) { - // Find the word by unique ID instead of just the word text - const wordIndex = this.selectedWords.findIndex(selectedWord => + _toggleWord(word, element, uniqueId) { + const wordIndex = this._selectedWords.findIndex(selectedWord => selectedWord.uniqueId === uniqueId ); if (wordIndex > -1) { // Remove word - this.selectedWords.splice(wordIndex, 1); + this._selectedWords.splice(wordIndex, 1); element.classList.remove('selected'); } else { // Add word with unique ID - this.selectedWords.push({ + this._selectedWords.push({ word: word, uniqueId: uniqueId }); element.classList.add('selected'); } - this.updateSentenceBuilder(); - this.updateCastButton(); + this._updateSentenceBuilder(); + this._updateCastButton(); } - updateSentenceBuilder() { + _updateSentenceBuilder() { const container = document.getElementById('current-sentence'); - const sentence = this.buildSentenceFromWords(this.selectedWords); + const sentence = this._buildSentenceFromWords(this._selectedWords); container.textContent = sentence; } - buildSentenceFromWords(words) { - // Join words and handle punctuation correctly (no space before punctuation) + _buildSentenceFromWords(words) { let sentence = ''; for (let i = 0; i < words.length; i++) { const wordText = typeof words[i] === 'string' ? words[i] : words[i].word; @@ -1172,93 +1234,90 @@ class WizardSpellCaster { return sentence; } - updateCastButton() { + _updateCastButton() { const button = document.getElementById('cast-button'); - - // Always enable the button - let players try and fail! button.disabled = false; - // Always show the same text - don't reveal if the spell is correct - if (this.selectedSpell) { - button.textContent = `🔥 CAST ${this.selectedSpell.name.toUpperCase()} 🔥`; + if (this._selectedSpell) { + button.textContent = `🔥 CAST ${this._selectedSpell.name.toUpperCase()} 🔥`; } else { button.textContent = '🔥 CAST SPELL 🔥'; } } - castSpell() { - if (!this.selectedSpell) { - this.showFailEffect('noSpell'); + _castSpell() { + if (!this._selectedSpell) { + this._showFailEffect('noSpell'); return; } - // Check if spell is correctly formed (including punctuation) - const expectedSentence = this.selectedSpell.english; - const playerSentence = this.buildSentenceFromWords(this.selectedWords); - - console.log('🔍 Spell check:'); - console.log('Expected:', expectedSentence); - console.log('Player:', playerSentence); - console.log('Selected words:', this.selectedWords); - + // Check if spell is correctly formed + const expectedSentence = this._selectedSpell.english; + const playerSentence = this._buildSentenceFromWords(this._selectedWords); const isCorrect = playerSentence === expectedSentence; if (isCorrect) { // Successful cast! - this.showCastingEffect(this.selectedSpell.type); + this._showCastingEffect(this._selectedSpell.type); - // Delay the main spell effect for dramatic timing setTimeout(() => { - this.showSpellEffect(this.selectedSpell.type); + this._showSpellEffect(this._selectedSpell.type); }, 500); // Deal damage - this.enemyHP = Math.max(0, this.enemyHP - this.selectedSpell.damage); - this.updateEnemyHealth(); - this.showDamageNumber(this.selectedSpell.damage); + this._enemyHP = Math.max(0, this._enemyHP - this._selectedSpell.damage); + this._updateEnemyHealth(); + this._showDamageNumber(this._selectedSpell.damage); - // Update score - bonus multiplicateur pour phrases longues - const wordCount = this.selectedWords.length; + // Update score with bonuses + const wordCount = this._selectedWords.length; let scoreMultiplier = 10; - if (wordCount >= 7) scoreMultiplier = 20; // x2 pour phrases très longues - else if (wordCount >= 5) scoreMultiplier = 15; // x1.5 pour phrases longues + if (wordCount >= 7) scoreMultiplier = 20; + else if (wordCount >= 5) scoreMultiplier = 15; - // Calculer le bonus de vitesse (invisible) + // Speed bonus let speedBonus = 0; - if (this.spellStartTime) { - const spellTime = (Date.now() - this.spellStartTime) / 1000; // en secondes - this.spellCount++; - this.averageSpellTime = ((this.averageSpellTime * (this.spellCount - 1)) + spellTime) / this.spellCount; + if (this._spellStartTime) { + const spellTime = (Date.now() - this._spellStartTime) / 1000; + this._spellCount++; + this._averageSpellTime = ((this._averageSpellTime * (this._spellCount - 1)) + spellTime) / this._spellCount; - // Bonus de vitesse : plus c'est rapide, plus de points - if (spellTime < 10) speedBonus = Math.floor((10 - spellTime) * 50); // Jusqu'à 500 bonus - if (spellTime < 5) speedBonus += 300; // Bonus extra pour super rapide - if (spellTime < 3) speedBonus += 500; // Bonus énorme pour très rapide + if (spellTime < 10) speedBonus = Math.floor((10 - spellTime) * 50); + if (spellTime < 5) speedBonus += 300; + if (spellTime < 3) speedBonus += 500; } - this.score += (this.selectedSpell.damage * scoreMultiplier) + speedBonus; - this.onScoreUpdate(this.score); - document.getElementById('current-score').textContent = this.score; + this._score += (this._selectedSpell.damage * scoreMultiplier) + speedBonus; + document.getElementById('current-score').textContent = this._score; + + // Emit spell cast event + this._eventBus.emit('wizard-spell-caster:spell-cast', { + gameId: 'wizard-spell-caster', + instanceId: this.name, + spell: this._selectedSpell, + damage: this._selectedSpell.damage, + score: this._score, + speedBonus + }, this.name); // Check win condition - if (this.enemyHP <= 0) { - this.handleVictory(); + if (this._enemyHP <= 0) { + this._handleVictory(); return; } // Generate new spells for next round setTimeout(() => { - this.generateNewSpells(); - // Reset timer pour nouveau round - this.spellStartTime = Date.now(); + this._generateNewSpells(); + this._spellStartTime = Date.now(); }, 1000); } else { - // Spell failed! Random funny effect - this.showFailEffect(); + // Spell failed! + this._showFailEffect(); } } - showSpellEffect(type) { + _showSpellEffect(type) { const enemyChar = document.querySelector('.enemy-character'); const rect = enemyChar.getBoundingClientRect(); @@ -1270,23 +1329,22 @@ class WizardSpellCaster { effect.style.top = rect.top + rect.height/2 - 50 + 'px'; document.body.appendChild(effect); - // Add spell-specific enhanced effects - this.createSpellParticles(type, rect); - this.triggerSpellAnimation(type, enemyChar); + // Enhanced effects based on spell type + this._createSpellParticles(type, rect); + this._triggerSpellAnimation(type, enemyChar); setTimeout(() => { effect.remove(); }, 800); } - createSpellParticles(type, enemyRect) { + _createSpellParticles(type, enemyRect) { const particleCount = type === 'meteor' ? 15 : type === 'lightning' ? 12 : 8; for (let i = 0; i < particleCount; i++) { const particle = document.createElement('div'); particle.className = `spell-particle ${type}-particle`; - // Random position around enemy const offsetX = (Math.random() - 0.5) * 200; const offsetY = (Math.random() - 0.5) * 200; @@ -1299,18 +1357,14 @@ class WizardSpellCaster { particle.style.pointerEvents = 'none'; particle.style.zIndex = '1000'; - // Spell-specific particle colors and animations if (type === 'fire') { particle.style.background = 'radial-gradient(circle, #ff6b7a, #ff4757)'; - particle.style.animation = 'fireParticle 1.2s ease-out forwards'; particle.style.boxShadow = '0 0 10px #ff4757'; } else if (type === 'lightning') { particle.style.background = 'radial-gradient(circle, #ffd700, #ffed4e)'; - particle.style.animation = 'lightningParticle 0.8s ease-out forwards'; particle.style.boxShadow = '0 0 15px #ffd700'; } else if (type === 'meteor') { particle.style.background = 'radial-gradient(circle, #a29bfe, #6c5ce7)'; - particle.style.animation = 'meteorParticle 1.5s ease-out forwards'; particle.style.boxShadow = '0 0 20px #6c5ce7'; } @@ -1322,21 +1376,10 @@ class WizardSpellCaster { } } - triggerSpellAnimation(type, enemyChar) { - // Screen effects based on spell type + _triggerSpellAnimation(type, enemyChar) { if (type === 'meteor') { - // Meteor causes screen shake document.body.classList.add('screen-shake'); setTimeout(() => document.body.classList.remove('screen-shake'), 500); - - // Create meteor trail effect - this.createMeteorTrail(); - } else if (type === 'lightning') { - // Lightning flash effect - this.createLightningFlash(); - } else if (type === 'fire') { - // Fire ripple effect - this.createFireRipple(enemyChar); } // Enemy hit reaction @@ -1351,67 +1394,8 @@ class WizardSpellCaster { }, 300); } - createMeteorTrail() { - const trail = document.createElement('div'); - trail.className = 'meteor-trail'; - trail.style.position = 'fixed'; - trail.style.top = '0'; - trail.style.right = '0'; - trail.style.width = '4px'; - trail.style.height = '100vh'; - trail.style.background = 'linear-gradient(180deg, #a29bfe, transparent)'; - trail.style.animation = 'meteorTrail 0.6s ease-out forwards'; - trail.style.pointerEvents = 'none'; - trail.style.zIndex = '999'; - - document.body.appendChild(trail); - setTimeout(() => trail.remove(), 600); - } - - createLightningFlash() { - const flash = document.createElement('div'); - flash.style.position = 'fixed'; - flash.style.top = '0'; - flash.style.left = '0'; - flash.style.width = '100vw'; - flash.style.height = '100vh'; - flash.style.background = 'rgba(255, 215, 0, 0.3)'; - flash.style.animation = 'lightningFlash 0.2s ease-out'; - flash.style.pointerEvents = 'none'; - flash.style.zIndex = '998'; - - document.body.appendChild(flash); - setTimeout(() => flash.remove(), 200); - } - - createFireRipple(enemyChar) { - const ripple = document.createElement('div'); - const rect = enemyChar.getBoundingClientRect(); - - ripple.style.position = 'fixed'; - ripple.style.left = rect.left + rect.width/2 - 100 + 'px'; - ripple.style.top = rect.top + rect.height/2 - 100 + 'px'; - ripple.style.width = '200px'; - ripple.style.height = '200px'; - ripple.style.border = '3px solid #ff4757'; - ripple.style.borderRadius = '50%'; - ripple.style.animation = 'fireRipple 0.8s ease-out forwards'; - ripple.style.pointerEvents = 'none'; - ripple.style.zIndex = '997'; - - document.body.appendChild(ripple); - setTimeout(() => ripple.remove(), 800); - } - - showCastingEffect(spellType) { + _showCastingEffect(spellType) { const wizardChar = document.querySelector('.wizard-character'); - const rect = wizardChar.getBoundingClientRect(); - - // Create magical circle around wizard - this.createMagicCircle(rect, spellType); - - // Add casting sparkles - this.createCastingSparkles(rect, spellType); // Wizard glow effect wizardChar.style.filter = 'drop-shadow(0 0 20px #ffd700)'; @@ -1423,76 +1407,7 @@ class WizardSpellCaster { }, 600); } - createMagicCircle(wizardRect, spellType) { - const circle = document.createElement('div'); - circle.style.position = 'fixed'; - circle.style.left = wizardRect.left + wizardRect.width/2 - 75 + 'px'; - circle.style.top = wizardRect.top + wizardRect.height/2 - 75 + 'px'; - circle.style.width = '150px'; - circle.style.height = '150px'; - circle.style.borderRadius = '50%'; - circle.style.pointerEvents = 'none'; - circle.style.zIndex = '500'; - - // Spell-specific circle colors - if (spellType === 'fire') { - circle.style.border = '3px solid #ff4757'; - circle.style.boxShadow = '0 0 30px #ff4757, inset 0 0 30px rgba(255, 71, 87, 0.3)'; - } else if (spellType === 'lightning') { - circle.style.border = '3px solid #ffd700'; - circle.style.boxShadow = '0 0 30px #ffd700, inset 0 0 30px rgba(255, 215, 0, 0.3)'; - } else if (spellType === 'meteor') { - circle.style.border = '3px solid #6c5ce7'; - circle.style.boxShadow = '0 0 30px #6c5ce7, inset 0 0 30px rgba(108, 92, 231, 0.3)'; - } - - circle.style.animation = 'magicCircleForm 0.6s ease-out forwards'; - - document.body.appendChild(circle); - setTimeout(() => circle.remove(), 600); - } - - createCastingSparkles(wizardRect, spellType) { - const sparkleCount = 8; - - for (let i = 0; i < sparkleCount; i++) { - const sparkle = document.createElement('div'); - sparkle.style.position = 'fixed'; - sparkle.style.width = '4px'; - sparkle.style.height = '4px'; - sparkle.style.borderRadius = '50%'; - sparkle.style.pointerEvents = 'none'; - sparkle.style.zIndex = '501'; - - // Position around wizard - const angle = (i / sparkleCount) * 2 * Math.PI; - const radius = 60; - const x = wizardRect.left + wizardRect.width/2 + Math.cos(angle) * radius; - const y = wizardRect.top + wizardRect.height/2 + Math.sin(angle) * radius; - - sparkle.style.left = x + 'px'; - sparkle.style.top = y + 'px'; - - // Spell-specific sparkle colors - if (spellType === 'fire') { - sparkle.style.background = '#ff4757'; - sparkle.style.boxShadow = '0 0 8px #ff4757'; - } else if (spellType === 'lightning') { - sparkle.style.background = '#ffd700'; - sparkle.style.boxShadow = '0 0 8px #ffd700'; - } else if (spellType === 'meteor') { - sparkle.style.background = '#6c5ce7'; - sparkle.style.boxShadow = '0 0 8px #6c5ce7'; - } - - sparkle.style.animation = 'castingSparkle 0.6s ease-out forwards'; - - document.body.appendChild(sparkle); - setTimeout(() => sparkle.remove(), 600); - } - } - - showDamageNumber(damage) { + _showDamageNumber(damage) { const damageEl = document.createElement('div'); damageEl.className = 'damage-number'; damageEl.textContent = `-${damage}`; @@ -1511,36 +1426,34 @@ class WizardSpellCaster { }, 1500); } - showFailEffect(type = 'random') { + _showFailEffect(type = 'random') { const effects = ['spawnMinion', 'loseHP', 'magicQuirk', 'flyingBirds']; const selectedEffect = type === 'random' ? effects[Math.floor(Math.random() * effects.length)] : type; - // Show fail message first - this.showFailMessage(); + this._showFailMessage(); - // Then trigger specific effect switch(selectedEffect) { case 'spawnMinion': - this.spawnMiniEnemy(); + this._spawnMiniEnemy(); break; case 'loseHP': - this.wizardTakesDamage(); + this._wizardTakesDamage(); break; case 'magicQuirk': - this.triggerMagicQuirk(); + this._triggerMagicQuirk(); break; case 'flyingBirds': - this.summonFlyingBirds(); + this._summonFlyingBirds(); break; case 'noSpell': - this.showFailMessage('Select a spell first! 🪄'); + this._showFailMessage('Select a spell first! 🪄'); break; } } - showFailMessage(customMessage = null) { + _showFailMessage(customMessage = null) { const messages = [ "Spell backfired! 💥", "Magic went wrong! 🌀", @@ -1563,14 +1476,11 @@ class WizardSpellCaster { }, 2000); } - spawnMiniEnemy() { - console.log('🧌 Spawning mini enemy!'); - + _spawnMiniEnemy() { const miniEnemy = document.createElement('div'); miniEnemy.className = 'mini-enemy'; miniEnemy.textContent = '👺'; - // Random position around the main enemy const mainEnemy = document.querySelector('.enemy-character'); const rect = mainEnemy.getBoundingClientRect(); @@ -1580,29 +1490,23 @@ class WizardSpellCaster { document.body.appendChild(miniEnemy); - // Mini enemy disappears after 5 seconds setTimeout(() => { miniEnemy.remove(); }, 5000); - // Make main enemy slightly stronger - this.enemyHP = Math.min(100, this.enemyHP + 5); - this.updateEnemyHealth(); + this._enemyHP = Math.min(this._config.maxEnemyHP, this._enemyHP + 5); + this._updateEnemyHealth(); } - wizardTakesDamage() { - console.log('🔥 Wizard takes damage!'); + _wizardTakesDamage() { + this._playerHP = Math.max(0, this._playerHP - 10); + document.getElementById('player-health').style.width = this._playerHP + '%'; - this.playerHP = Math.max(0, this.playerHP - 10); - document.getElementById('player-health').style.width = this.playerHP + '%'; - - // Screen shake effect document.body.classList.add('screen-shake'); setTimeout(() => { document.body.classList.remove('screen-shake'); }, 500); - // Show damage on wizard const damageEl = document.createElement('div'); damageEl.className = 'damage-number'; damageEl.textContent = '-10'; @@ -1621,28 +1525,23 @@ class WizardSpellCaster { damageEl.remove(); }, 1500); - // Check if wizard dies - if (this.playerHP <= 0) { + if (this._playerHP <= 0) { setTimeout(() => { - this.handleDefeat(); + this._handleDefeat(); }, 1000); } } - triggerMagicQuirk() { - console.log('🌀 Magic quirk activated!'); - - // Create multiple quirks at random positions - const numQuirks = 2 + Math.floor(Math.random() * 2); // 2-3 quirks + _triggerMagicQuirk() { + const numQuirks = 2 + Math.floor(Math.random() * 2); for (let i = 0; i < numQuirks; i++) { setTimeout(() => { const quirk = document.createElement('div'); quirk.className = 'magic-quirk'; - // Random position within viewport - const x = 20 + Math.random() * 60; // 20% to 80% of viewport width - const y = 20 + Math.random() * 60; // 20% to 80% of viewport height + const x = 20 + Math.random() * 60; + const y = 20 + Math.random() * 60; quirk.style.left = x + '%'; quirk.style.top = y + '%'; @@ -1653,21 +1552,18 @@ class WizardSpellCaster { setTimeout(() => { quirk.remove(); }, 2000); - }, i * 300); // Stagger the quirks + }, i * 300); } - // Scramble the word bank for extra chaos setTimeout(() => { - this.updateWordBank(); + this._updateWordBank(); }, 1000); } - summonFlyingBirds() { - console.log('🐦 Summoning flying birds!'); - - const birds = ['🐦', '🕊️', '🦅', '🦜', '🐧', '🦆', '🦢', '🐓', '🦃', '🦚', '🐤', '🐣', '🐥']; - const paths = ['bird-path-1', 'bird-path-2', 'bird-path-3', 'bird-path-4', 'bird-path-5']; - const numBirds = 5 + Math.floor(Math.random() * 3); // 5-7 birds maintenant ! + _summonFlyingBirds() { + const birds = ['🐦', '🕊️', '🦅', '🦜', '🐧', '🦆', '🦢', '🐓', '🦃', '🦚']; + const paths = ['bird-path-1', 'bird-path-2', 'bird-path-3']; + const numBirds = 3 + Math.floor(Math.random() * 2); for (let i = 0; i < numBirds; i++) { setTimeout(() => { @@ -1678,65 +1574,52 @@ class WizardSpellCaster { document.body.appendChild(bird); - console.log(`🐦 Bird ${i+1} spawned with class: ${bird.className}`); - setTimeout(() => { bird.remove(); - }, 30000); + }, 15000); - }, i * 500); // Stagger bird appearances + }, i * 500); } } - updateEnemyHealth() { + _updateEnemyHealth() { const healthBar = document.getElementById('enemy-health'); - const percentage = (this.enemyHP / 100) * 100; + const percentage = (this._enemyHP / this._config.maxEnemyHP) * 100; healthBar.style.width = percentage + '%'; } - - getRandomAttackTime() { - // Enemy attacks every 8-15 seconds randomly - return 8000 + Math.random() * 7000; + _getRandomAttackTime() { + return this._config.enemyAttackInterval.min + + Math.random() * (this._config.enemyAttackInterval.max - this._config.enemyAttackInterval.min); } - startEnemyAttackSystem() { - this.scheduleNextEnemyAttack(); + _startEnemyAttackSystem() { + this._scheduleNextEnemyAttack(); } - scheduleNextEnemyAttack() { - this.enemyAttackTimer = setTimeout(() => { - this.executeEnemyAttack(); - this.scheduleNextEnemyAttack(); // Schedule next attack - }, this.nextEnemyAttack); + _scheduleNextEnemyAttack() { + this._enemyAttackTimer = setTimeout(() => { + this._executeEnemyAttack(); + this._scheduleNextEnemyAttack(); + }, this._getRandomAttackTime()); } - executeEnemyAttack() { - console.log('👹 Enemy is attacking!'); - + _executeEnemyAttack() { const enemyChar = document.querySelector('.enemy-character'); - // Show attack warning - this.showEnemyAttackWarning(); - - // Enemy charging animation + this._showEnemyAttackWarning(); enemyChar.classList.add('enemy-charging'); - // Attack after 2 seconds warning setTimeout(() => { enemyChar.classList.remove('enemy-charging'); - this.dealEnemyDamage(); - this.showEnemyAttackEffect(); + this._dealEnemyDamage(); + this._showEnemyAttackEffect(); }, 2000); - - // Set next attack time - this.nextEnemyAttack = this.getRandomAttackTime(); } - showEnemyAttackWarning() { + _showEnemyAttackWarning() { const enemyChar = document.querySelector('.enemy-character'); - // Remove existing warning const existingWarning = enemyChar.querySelector('.enemy-attack-warning'); if (existingWarning) { existingWarning.remove(); @@ -1749,25 +1632,23 @@ class WizardSpellCaster { enemyChar.style.position = 'relative'; enemyChar.appendChild(warning); - // Remove warning after attack setTimeout(() => { warning.remove(); }, 2000); } - dealEnemyDamage() { - const damage = 12 + Math.floor(Math.random() * 8); // 12-20 damage + _dealEnemyDamage() { + const damage = this._config.enemyDamage.min + + Math.floor(Math.random() * (this._config.enemyDamage.max - this._config.enemyDamage.min + 1)); - this.playerHP = Math.max(0, this.playerHP - damage); - document.getElementById('player-health').style.width = this.playerHP + '%'; + this._playerHP = Math.max(0, this._playerHP - damage); + document.getElementById('player-health').style.width = this._playerHP + '%'; - // Screen shake document.body.classList.add('screen-shake'); setTimeout(() => { document.body.classList.remove('screen-shake'); }, 500); - // Show damage number on wizard const damageEl = document.createElement('div'); damageEl.className = 'damage-number'; damageEl.textContent = `-${damage}`; @@ -1786,17 +1667,22 @@ class WizardSpellCaster { damageEl.remove(); }, 1500); - console.log(`💔 Player took ${damage} damage! HP: ${this.playerHP}`); + // Emit enemy attack event + this._eventBus.emit('wizard-spell-caster:enemy-attack', { + gameId: 'wizard-spell-caster', + instanceId: this.name, + damage, + playerHP: this._playerHP + }, this.name); - // Check if player dies - if (this.playerHP <= 0) { + if (this._playerHP <= 0) { setTimeout(() => { - this.handleDefeat(); + this._handleDefeat(); }, 1000); } } - showEnemyAttackEffect() { + _showEnemyAttackEffect() { const effect = document.createElement('div'); effect.className = 'enemy-attack-effect'; @@ -1814,68 +1700,139 @@ class WizardSpellCaster { }, 1000); } - handleVictory() { - clearTimeout(this.enemyAttackTimer); + _handleVictory() { + if (this._enemyAttackTimer) { + clearTimeout(this._enemyAttackTimer); + this._enemyAttackTimer = null; + } - const bonusScore = 1000; // Fixed victory bonus - this.score += bonusScore; + const bonusScore = 1000; + this._score += bonusScore; - this.container.innerHTML += ` -
-
🎉 VICTORY! 🎉
-
You defeated the Grammar Demon!
-
Final Score: ${this.score}
-
Time Bonus: +${bonusScore}
- + const victoryScreen = document.createElement('div'); + victoryScreen.className = 'victory-screen'; + victoryScreen.innerHTML = ` +
🎉 VICTORY! 🎉
+
You defeated the Grammar Demon!
+
Final Score: ${this._score}
+
Victory Bonus: +${bonusScore}
+
+ +
`; - this.onGameEnd(this.score); + this._config.container.appendChild(victoryScreen); + + // Add event listeners + victoryScreen.querySelector('#play-again-btn').addEventListener('click', () => { + victoryScreen.remove(); + this._restartGame(); + }); + + victoryScreen.querySelector('#exit-victory-btn').addEventListener('click', () => { + this._eventBus.emit('navigation:navigate', { path: '/games' }, 'Bootstrap'); + }); + + // Emit victory event + this._eventBus.emit('game:completed', { + gameId: 'wizard-spell-caster', + instanceId: this.name, + score: this._score, + spellsCast: this._spellCount, + duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0 + }, this.name); } - handleDefeat() { - clearTimeout(this.enemyAttackTimer); + _handleDefeat() { + if (this._enemyAttackTimer) { + clearTimeout(this._enemyAttackTimer); + this._enemyAttackTimer = null; + } - this.container.innerHTML += ` -
-
💀 DEFEATED 💀
-
The Grammar Demon proved too strong!
-
Final Score: ${this.score}
- + const defeatScreen = document.createElement('div'); + defeatScreen.className = 'defeat-screen'; + defeatScreen.innerHTML = ` +
💀 DEFEATED 💀
+
The Grammar Demon proved too strong!
+
Final Score: ${this._score}
+
+ +
`; - this.onGameEnd(this.score); + this._config.container.appendChild(defeatScreen); + + // Add event listeners + defeatScreen.querySelector('#try-again-btn').addEventListener('click', () => { + defeatScreen.remove(); + this._restartGame(); + }); + + defeatScreen.querySelector('#exit-defeat-btn').addEventListener('click', () => { + this._eventBus.emit('navigation:navigate', { path: '/games' }, 'Bootstrap'); + }); + + // Emit defeat event + this._eventBus.emit('game:completed', { + gameId: 'wizard-spell-caster', + instanceId: this.name, + score: this._score, + result: 'defeat', + spellsCast: this._spellCount, + duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0 + }, this.name); } - start() { - // Game starts immediately when initialized + _restartGame() { + // Reset game state + this._score = 0; + this._enemyHP = this._config.maxEnemyHP; + this._playerHP = this._config.maxPlayerHP; + this._spellStartTime = Date.now(); + this._averageSpellTime = 0; + this._spellCount = 0; + this._selectedSpell = null; + this._selectedWords = []; + + // Update UI + document.getElementById('current-score').textContent = this._score; + document.getElementById('player-health').style.width = '100%'; + document.getElementById('enemy-health').style.width = '100%'; + + // Restart enemy attacks + this._startEnemyAttackSystem(); + + // Generate new spells + this._generateNewSpells(); } - destroy() { - if (this.enemyAttackTimer) { - clearTimeout(this.enemyAttackTimer); - } - - const styleSheet = document.getElementById('wizard-spell-caster-styles'); - if (styleSheet) { - styleSheet.remove(); + _showError(message) { + if (this._config.container) { + this._config.container.innerHTML = ` +
+

❌ Wizard Spell Caster Error

+

${message}

+

This game requires sentences for spell construction.

+ +
+ `; } } - restart() { - this.destroy(); - this.score = 0; - this.enemyHP = 100; - this.playerHP = 100; - this.spellStartTime = Date.now(); - this.averageSpellTime = 0; - this.spellCount = 0; - this.nextEnemyAttack = this.getRandomAttackTime(); - this.init(); + _handlePause() { + if (this._enemyAttackTimer) { + clearTimeout(this._enemyAttackTimer); + this._enemyAttackTimer = null; + } + this._eventBus.emit('game:paused', { instanceId: this.name }, this.name); + } + + _handleResume() { + this._startEnemyAttackSystem(); + this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name); } } -// Register the game module -window.GameModules = window.GameModules || {}; -window.GameModules.WizardSpellCaster = WizardSpellCaster; \ No newline at end of file +export default WizardSpellCaster; \ No newline at end of file diff --git a/src/games/WordDiscovery.js b/src/games/WordDiscovery.js new file mode 100644 index 0000000..f16e1bf --- /dev/null +++ b/src/games/WordDiscovery.js @@ -0,0 +1,914 @@ +import Module from '../core/Module.js'; + +class WordDiscovery extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); + + if (!dependencies.eventBus || !dependencies.content) { + throw new Error('WordDiscovery requires eventBus and content dependencies'); + } + + this._eventBus = dependencies.eventBus; + this._content = dependencies.content; + this._config = { + container: null, + difficulty: 'medium', + practiceCount: 10, + timerDuration: 30, + ...config + }; + + this._currentPhase = 'discovery'; + this._discoveredWords = []; + this._practiceWords = []; + this._currentWordIndex = 0; + this._currentPracticeLevel = 0; + this._practiceCorrect = 0; + this._practiceTotal = 0; + this._timer = null; + this._timeLeft = 0; + this._gameContainer = null; + this._audioElements = new Map(); + this._imageCache = new Map(); + + this._practiceOptions = []; + this._correctAnswer = null; + this._currentQuestion = null; + + Object.seal(this); + } + + static getMetadata() { + return { + id: 'word-discovery', + name: 'Word Discovery', + description: 'Discover and practice vocabulary with adaptive difficulty', + version: '2.0.0', + author: 'Class Generator', + category: 'vocabulary', + tags: ['vocabulary', 'learning', 'discovery', 'practice'], + difficulty: { + min: 1, + max: 4, + default: 2 + }, + estimatedDuration: 15, + requiredContent: ['vocabulary'] + }; + } + + static getCompatibilityScore(content) { + if (!content || (!Array.isArray(content.vocabulary) && !content.vocabulary)) { + return 0; + } + + // Handle both array and object vocabulary formats + let vocabCount; + if (Array.isArray(content.vocabulary)) { + vocabCount = content.vocabulary.length; + } else if (content.vocabulary && typeof content.vocabulary === 'object') { + vocabCount = Object.keys(content.vocabulary).length; + } else { + return 0; + } + + let score = 50; + + if (vocabCount >= 10) score += 20; + if (vocabCount >= 20) score += 15; + if (vocabCount >= 50) score += 10; + + // Check for bonus features depending on format + let hasImages, hasAudio, hasTranslations; + + if (Array.isArray(content.vocabulary)) { + hasImages = content.vocabulary.some(word => word.image); + hasAudio = content.vocabulary.some(word => word.audio); + hasTranslations = content.vocabulary.some(word => word.translation); + } else { + // Object format (SBS style) + const vocabEntries = Object.values(content.vocabulary); + hasImages = vocabEntries.some(entry => entry.image); + hasAudio = vocabEntries.some(entry => entry.audio); + hasTranslations = vocabEntries.some(entry => entry.user_language || entry.translation); + } + + if (hasImages) score += 5; + if (hasAudio) score += 5; + if (hasTranslations) score += 5; + + return Math.min(score, 100); + } + + async init() { + this._validateNotDestroyed(); + + // Validate container + if (!this._config.container) { + throw new Error('Game container is required'); + } + + this._eventBus.on('game:start', this._handleGameStart.bind(this), this.name); + this._eventBus.on('game:stop', this._handleGameStop.bind(this), this.name); + this._eventBus.on('navigation:change', this._handleNavigationChange.bind(this), this.name); + + this._injectCSS(); + + // Start game immediately + try { + this._gameContainer = this._config.container; + const content = this._content; + + if (!content || !content.vocabulary || Object.keys(content.vocabulary).length === 0) { + throw new Error('No vocabulary content available'); + } + + this._practiceWords = Object.entries(content.vocabulary).map(([word, data]) => ({ + word: word, + translation: typeof data === 'string' ? data : + data.user_language || data.translation || 'unknown', + pronunciation: data.pronunciation, + type: data.type || 'general', + audio: data.audio, + image: data.image, + definition: data.definition, + example: data.example + })); + + this._discoveredWords = []; + this._currentWordIndex = 0; + this._currentPhase = 'discovery'; + + await this._preloadAssets(); + this._renderDiscoveryPhase(); + + // Emit game ready event + this._eventBus.emit('game:ready', { + gameId: 'word-discovery', + instanceId: this.name, + vocabulary: this._practiceWords.length + }, this.name); + + } catch (error) { + this._showError(error.message); + throw error; + } + + this._setInitialized(); + } + + async destroy() { + this._validateNotDestroyed(); + + this._cleanup(); + this._removeCSS(); + this._eventBus.off('game:start', this.name); + this._eventBus.off('game:stop', this.name); + this._eventBus.off('navigation:change', this.name); + + this._setDestroyed(); + } + + _handleGameStart(event) { + this._validateInitialized(); + if (event.gameId === 'word-discovery') { + this._startGame(); + } + } + + _handleGameStop(event) { + this._validateInitialized(); + if (event.gameId === 'word-discovery') { + this._stopGame(); + } + } + + _handleNavigationChange(event) { + this._validateInitialized(); + if (event.from === '/games/word-discovery') { + this._cleanup(); + } + } + + async _startGame() { + try { + this._gameContainer = document.getElementById('game-content'); + if (!this._gameContainer) { + throw new Error('Game container not found'); + } + + const content = window.contentLoader ? window.contentLoader.getContent(window.currentChapterId) : await this._content.getCurrentContent(); + if (!content || !content.vocabulary || content.vocabulary.length === 0) { + throw new Error('No vocabulary content available'); + } + + this._practiceWords = [...content.vocabulary]; + this._discoveredWords = []; + this._currentWordIndex = 0; + this._currentPhase = 'discovery'; + + await this._preloadAssets(); + this._renderDiscoveryPhase(); + + } catch (error) { + console.error('Error starting Word Discovery:', error); + this._eventBus.emit('game:error', { + gameId: 'word-discovery', + error: error.message, + module: this.name + }); + } + } + + _stopGame() { + this._cleanup(); + } + + _cleanup() { + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + + this._audioElements.forEach(audio => { + audio.pause(); + audio.src = ''; + }); + this._audioElements.clear(); + + if (this._gameContainer) { + this._gameContainer.innerHTML = ''; + } + } + + async _preloadAssets() { + for (const word of this._practiceWords) { + if (word.audio) { + try { + const audio = new Audio(); + audio.preload = 'auto'; + audio.src = word.audio; + this._audioElements.set(word.word, audio); + } catch (error) { + console.warn(`Failed to preload audio for ${word.word}:`, error); + } + } + + if (word.image) { + try { + const img = new Image(); + img.src = word.image; + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + setTimeout(reject, 5000); + }); + this._imageCache.set(word.word, word.image); + } catch (error) { + console.warn(`Failed to preload image for ${word.word}:`, error); + } + } + } + } + + _renderDiscoveryPhase() { + const word = this._practiceWords[this._currentWordIndex]; + if (!word) { + this._startPracticePhase(); + return; + } + + this._gameContainer.innerHTML = ` +
+
+

Word Discovery

+
+
+
+

Progress: ${this._currentWordIndex + 1} / ${this._practiceWords.length}

+
+ +
+ ${word.image ? `
+ ${word.word} +
` : ''} + +
+

${word.word}

+ ${word.translation ? `

${word.translation}

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

${word.definition}

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

"${word.example}"

` : ''} +
+ +
+ ${word.audio ? `` : ''} + +
+
+ +
+ +
+
+ `; + + window.wordDiscovery = this; + } + + _nextWord() { + const currentWord = this._practiceWords[this._currentWordIndex]; + if (currentWord && !this._discoveredWords.find(w => w.word === currentWord.word)) { + this._discoveredWords.push(currentWord); + } + + this._currentWordIndex++; + this._renderDiscoveryPhase(); + } + + _playAudio(word) { + const audio = this._audioElements.get(word); + if (audio) { + audio.currentTime = 0; + audio.play().catch(error => { + console.warn(`Failed to play audio for ${word}:`, error); + }); + } + } + + _startPracticePhase() { + if (this._discoveredWords.length === 0) { + this._discoveredWords = [...this._practiceWords]; + } + + this._currentPhase = 'practice'; + this._currentPracticeLevel = 0; + this._practiceCorrect = 0; + this._practiceTotal = 0; + + this._renderPracticeLevel(); + } + + _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' } + }; + + const config = levelConfig[this._currentPracticeLevel]; + const levelName = levels[this._currentPracticeLevel]; + + this._gameContainer.innerHTML = ` +
+
+

Practice Phase - ${levelName}

+
+ Correct: ${this._practiceCorrect} + Total: ${this._practiceTotal} + Accuracy: ${this._practiceTotal > 0 ? Math.round((this._practiceCorrect / this._practiceTotal) * 100) : 0}% +
+
Time: ${config.time}
+
+ +
+ Loading question... +
+ +
+ + +
+
+ `; + + 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' } + }; + config = levelConfig[this._currentPracticeLevel]; + } + + const questionContainer = document.getElementById('practice-question'); + if (!questionContainer) return; + + const availableWords = this._discoveredWords.filter(w => w); + if (availableWords.length === 0) return; + + const correctWord = availableWords[Math.floor(Math.random() * availableWords.length)]; + this._correctAnswer = correctWord; + + const wrongWords = availableWords + .filter(w => w.word !== correctWord.word) + .sort(() => Math.random() - 0.5) + .slice(0, config.options - 1); + + this._practiceOptions = [correctWord, ...wrongWords] + .sort(() => Math.random() - 0.5); + + this._renderQuestion(config.type, correctWord); + } + + _renderQuestion(type, correctWord) { + const questionContainer = document.getElementById('practice-question'); + let questionHTML = ''; + + switch (type) { + case 'translation': + questionHTML = this._renderTranslationQuestion(correctWord); + break; + case 'definition': + questionHTML = this._renderDefinitionQuestion(correctWord); + break; + case 'context': + questionHTML = this._renderContextQuestion(correctWord); + break; + case 'mixed': + const types = ['translation', 'definition']; + const randomType = types[Math.floor(Math.random() * types.length)]; + questionHTML = this._renderQuestion(randomType, correctWord); + return; + } + + questionContainer.innerHTML = questionHTML; + } + + _renderTranslationQuestion(correctWord) { + return ` +
+

What does this word mean?

+
+ ${correctWord.word} + ${correctWord.audio ? `` : ''} +
+
+ ${this._practiceOptions.map(option => ` + + `).join('')} +
+
+ `; + } + + _renderDefinitionQuestion(correctWord) { + return ` +
+

Which word matches this definition?

+
+ ${correctWord.definition || correctWord.translation} +
+
+ ${this._practiceOptions.map(option => ` + + `).join('')} +
+
+ `; + } + + _renderContextQuestion(correctWord) { + return ` +
+

Complete the sentence:

+
+ ${correctWord.example ? correctWord.example.replace(correctWord.word, '_____') : `The _____ is very important.`} +
+
+ ${this._practiceOptions.map(option => ` + + `).join('')} +
+
+ `; + } + + _selectAnswer(selectedWord) { + this._practiceTotal++; + const isCorrect = selectedWord === this._correctAnswer.word; + + if (isCorrect) { + this._practiceCorrect++; + } + + this._showResult(isCorrect, isCorrect ? 'Correct!' : `Wrong! The answer was: ${this._correctAnswer.word}`); + + setTimeout(() => { + this._generateQuestion(); + }, 1500); + } + + _showResult(isCorrect, message) { + if (this._timer) { + clearInterval(this._timer); + this._timer = null; + } + + const questionContainer = document.getElementById('practice-question'); + if (!questionContainer) return; + + questionContainer.innerHTML = ` +
+

${message}

+ ${!isCorrect && this._correctAnswer.translation ? `

Translation: ${this._correctAnswer.translation}

` : ''} +
+ `; + + this._updateStats(); + } + + _updateStats() { + const statsElements = document.querySelectorAll('.practice-stats span'); + if (statsElements.length >= 3) { + statsElements[0].textContent = `Correct: ${this._practiceCorrect}`; + statsElements[1].textContent = `Total: ${this._practiceTotal}`; + statsElements[2].textContent = `Accuracy: ${this._practiceTotal > 0 ? Math.round((this._practiceCorrect / this._practiceTotal) * 100) : 0}%`; + } + } + + _nextLevel() { + if (this._currentPracticeLevel < 3) { + this._currentPracticeLevel++; + this._renderPracticeLevel(); + } + } + + _backToDiscovery() { + this._currentPhase = 'discovery'; + this._currentWordIndex = 0; + this._renderDiscoveryPhase(); + } + + _injectCSS() { + const cssId = 'word-discovery-styles'; + if (document.getElementById(cssId)) return; + + const style = document.createElement('style'); + style.id = cssId; + style.textContent = ` + .word-discovery-container { + max-width: 800px; + margin: 0 auto; + padding: 20px; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + } + + .discovery-header, .practice-header { + text-align: center; + margin-bottom: 30px; + } + + .discovery-header h2, .practice-header h2 { + color: #2c3e50; + margin-bottom: 15px; + } + + .progress-bar { + width: 100%; + height: 8px; + background-color: #ecf0f1; + border-radius: 4px; + overflow: hidden; + margin: 10px 0; + } + + .progress-fill { + height: 100%; + background: linear-gradient(90deg, #3498db, #2980b9); + transition: width 0.3s ease; + } + + .word-card { + background: white; + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + padding: 30px; + margin: 20px 0; + text-align: center; + border: 2px solid #e74c3c; + } + + .word-image img { + max-width: 200px; + max-height: 200px; + border-radius: 8px; + margin-bottom: 20px; + } + + .word-text { + font-size: 2.5em; + color: #2c3e50; + margin: 15px 0; + font-weight: bold; + } + + .word-translation { + font-size: 1.4em; + color: #e74c3c; + margin: 10px 0; + font-weight: 600; + } + + .word-definition { + font-size: 1.1em; + color: #7f8c8d; + margin: 10px 0; + font-style: italic; + } + + .word-example { + font-size: 1em; + color: #95a5a6; + margin: 15px 0; + padding: 10px; + background-color: #f8f9fa; + border-radius: 6px; + } + + .word-controls, .discovery-controls, .practice-controls { + display: flex; + gap: 15px; + justify-content: center; + margin-top: 20px; + } + + .audio-btn, .next-btn, .practice-btn, .back-btn, .next-level-btn { + padding: 12px 24px; + border: none; + border-radius: 8px; + font-size: 1.1em; + cursor: pointer; + transition: all 0.3s ease; + font-weight: 600; + } + + .audio-btn { + background: linear-gradient(135deg, #3498db, #2980b9); + color: white; + } + + .audio-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4); + } + + .next-btn, .next-level-btn { + background: linear-gradient(135deg, #27ae60, #229954); + color: white; + } + + .next-btn:hover, .next-level-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(39, 174, 96, 0.4); + } + + .practice-btn { + background: linear-gradient(135deg, #e74c3c, #c0392b); + color: white; + font-size: 1.3em; + padding: 15px 30px; + } + + .practice-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(231, 76, 60, 0.4); + } + + .back-btn { + background: linear-gradient(135deg, #95a5a6, #7f8c8d); + color: white; + } + + .back-btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(149, 165, 166, 0.4); + } + + .practice-stats { + display: flex; + gap: 20px; + justify-content: center; + margin: 15px 0; + font-size: 1.1em; + font-weight: 600; + } + + .practice-stats span { + color: #2c3e50; + } + + .timer { + font-size: 1.3em; + font-weight: bold; + color: #e74c3c; + margin: 10px 0; + } + + .question-content { + background: white; + border-radius: 12px; + padding: 30px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + margin: 20px 0; + } + + .question-content h3 { + color: #2c3e50; + margin-bottom: 20px; + text-align: center; + } + + .question-word { + font-size: 2em; + font-weight: bold; + color: #e74c3c; + text-align: center; + margin: 20px 0; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + } + + .question-definition, .question-context { + font-size: 1.3em; + color: #2c3e50; + text-align: center; + margin: 20px 0; + padding: 15px; + background-color: #f8f9fa; + border-radius: 8px; + border-left: 4px solid #3498db; + } + + .audio-btn-small { + padding: 8px 12px; + background: linear-gradient(135deg, #3498db, #2980b9); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.9em; + } + + .options-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; + margin-top: 25px; + } + + .option-btn { + padding: 15px 20px; + background: linear-gradient(135deg, #ecf0f1, #bdc3c7); + border: 2px solid #95a5a6; + border-radius: 8px; + cursor: pointer; + font-size: 1.1em; + font-weight: 600; + color: #2c3e50; + transition: all 0.3s ease; + } + + .option-btn:hover { + background: linear-gradient(135deg, #3498db, #2980b9); + color: white; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3); + } + + .result-display { + text-align: center; + padding: 30px; + border-radius: 12px; + margin: 20px 0; + } + + .result-display.correct { + background: linear-gradient(135deg, #2ecc71, #27ae60); + color: white; + } + + .result-display.incorrect { + background: linear-gradient(135deg, #e74c3c, #c0392b); + color: white; + } + + .result-display h3 { + margin: 0 0 10px 0; + font-size: 1.5em; + } + + .result-display p { + margin: 10px 0 0 0; + font-size: 1.1em; + opacity: 0.9; + } + + @media (max-width: 768px) { + .word-discovery-container { + padding: 10px; + } + + .word-text { + font-size: 2em; + } + + .word-controls, .discovery-controls, .practice-controls { + flex-direction: column; + align-items: center; + } + + .practice-stats { + flex-direction: column; + gap: 10px; + } + + .options-grid { + grid-template-columns: 1fr; + } + + .question-word { + font-size: 1.5em; + } + } + `; + + document.head.appendChild(style); + } + + _showError(message) { + if (this._gameContainer) { + this._gameContainer.innerHTML = ` +
+
+

Word Discovery Error

+

${message}

+ +
+ `; + } + } + + _removeCSS() { + const cssElement = document.getElementById('word-discovery-styles'); + if (cssElement) { + cssElement.remove(); + } + + if (window.wordDiscovery === this) { + delete window.wordDiscovery; + } + } +} + +export default WordDiscovery; \ No newline at end of file diff --git a/src/games/WordStorm.js b/src/games/WordStorm.js new file mode 100644 index 0000000..574c718 --- /dev/null +++ b/src/games/WordStorm.js @@ -0,0 +1,1253 @@ +import Module from '../core/Module.js'; + +/** + * WordStorm - Fast-paced falling words game where players match vocabulary + * Words fall from the sky like meteorites and players must select correct translations + */ +class WordStorm extends Module { + constructor(name, dependencies, config = {}) { + super(name, ['eventBus']); + + // Validate dependencies + if (!dependencies.eventBus || !dependencies.content) { + throw new Error('WordStorm requires eventBus and content dependencies'); + } + + this._eventBus = dependencies.eventBus; + this._content = dependencies.content; + this._config = { + container: null, + maxWords: 50, + fallSpeed: 8000, // ms to fall from top to bottom + spawnRate: 4000, // ms between spawns + wordLifetime: 9200, // ms before word disappears (+15% more time) + startingLives: 3, + ...config + }; + + // Game state + this._vocabulary = null; + this._score = 0; + this._level = 1; + this._lives = this._config.startingLives; + this._combo = 0; + this._isGamePaused = false; + this._isGameOver = false; + this._gameStartTime = null; + + // Game mechanics + this._fallingWords = []; + this._currentWordIndex = 0; + this._spawnInterval = null; + this._gameInterval = null; + + Object.seal(this); + } + + /** + * Get game metadata + * @returns {Object} Game metadata + */ + static getMetadata() { + return { + name: 'Word Storm', + description: 'Fast-paced falling words game with vocabulary matching', + difficulty: 'intermediate', + category: 'action', + estimatedTime: 6, // minutes + skills: ['vocabulary', 'speed', 'reflexes', 'concentration'] + }; + } + + /** + * Calculate compatibility score with content + * @param {Object} content - Content to check compatibility with + * @returns {Object} Compatibility score and details + */ + static getCompatibilityScore(content) { + const vocab = content?.vocabulary || {}; + const vocabCount = Object.keys(vocab).length; + + if (vocabCount < 8) { + return { + score: 0, + reason: `Insufficient vocabulary (${vocabCount}/8 required)`, + requirements: ['vocabulary'], + minWords: 8, + details: 'Word Storm needs at least 8 vocabulary words for meaningful gameplay' + }; + } + + // Perfect score at 30+ words, partial score for 8-29 + const score = Math.min(vocabCount / 30, 1); + + return { + score, + reason: `${vocabCount} vocabulary words available`, + requirements: ['vocabulary'], + minWords: 8, + optimalWords: 30, + details: `Can create dynamic gameplay with ${Math.min(vocabCount, this._config?.maxWords || 50)} words` + }; + } + + async init() { + this._validateNotDestroyed(); + + try { + // Validate container + if (!this._config.container) { + throw new Error('Game container is required'); + } + + // Extract and validate vocabulary + this._vocabulary = this._extractVocabulary(); + if (this._vocabulary.length < 8) { + throw new Error(`Insufficient vocabulary: need 8, got ${this._vocabulary.length}`); + } + + // Set up event listeners + this._eventBus.on('game:pause', this._handlePause.bind(this), this.name); + this._eventBus.on('game:resume', this._handleResume.bind(this), this.name); + + // Inject CSS + this._injectCSS(); + + // Initialize game interface + this._createGameInterface(); + this._setupEventListeners(); + + // Start the game + this._gameStartTime = Date.now(); + this._startSpawning(); + + // Emit game ready event + this._eventBus.emit('game:ready', { + gameId: 'word-storm', + instanceId: this.name, + vocabulary: this._vocabulary.length + }, this.name); + + this._setInitialized(); + + } catch (error) { + this._showError(error.message); + throw error; + } + } + + async destroy() { + this._validateNotDestroyed(); + + // Clear intervals + if (this._spawnInterval) { + clearInterval(this._spawnInterval); + this._spawnInterval = null; + } + if (this._gameInterval) { + clearInterval(this._gameInterval); + this._gameInterval = null; + } + + // Remove CSS + this._removeCSS(); + + // Clean up event listeners + if (this._config.container) { + this._config.container.innerHTML = ''; + } + + // Emit game end event + this._eventBus.emit('game:ended', { + gameId: 'word-storm', + instanceId: this.name, + score: this._score, + level: this._level, + combo: this._combo, + duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0 + }, this.name); + + this._setDestroyed(); + } + + /** + * Get current game state + * @returns {Object} Current game state + */ + getGameState() { + this._validateInitialized(); + + return { + score: this._score, + level: this._level, + lives: this._lives, + combo: this._combo, + isGameOver: this._isGameOver, + isPaused: this._isGamePaused, + duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0, + fallingWordsCount: this._fallingWords.length + }; + } + + // Private methods + _extractVocabulary() { + const vocab = this._content?.vocabulary || {}; + const vocabulary = []; + + for (const [word, data] of Object.entries(vocab)) { + if (data.user_language || (typeof data === 'string')) { + vocabulary.push({ + original: word, + translation: data.user_language || data, + type: data.type || 'unknown' + }); + } + } + + // Limit vocabulary and shuffle + return this._shuffleArray(vocabulary).slice(0, this._config.maxWords); + } + + _injectCSS() { + const cssId = `word-storm-styles-${this.name}`; + if (document.getElementById(cssId)) return; + + const style = document.createElement('style'); + style.id = cssId; + style.textContent = ` + .word-storm-game { + height: 100vh; + overflow: hidden; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + position: relative; + } + + .word-storm-hud { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px 20px; + background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); + color: white; + position: relative; + z-index: 100; + } + + .hud-section { + display: flex; + gap: 20px; + align-items: center; + } + + .hud-stat { + display: flex; + flex-direction: column; + align-items: center; + min-width: 60px; + } + + .hud-label { + font-size: 0.8rem; + opacity: 0.9; + margin-bottom: 2px; + } + + .hud-value { + font-size: 1.2rem; + font-weight: bold; + } + + .pause-btn { + padding: 8px 15px; + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.3s ease; + } + + .pause-btn:hover { + background: rgba(255, 255, 255, 0.3); + } + + .word-storm-area { + position: relative; + height: calc(80vh - 180px); + background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); + overflow: hidden; + border-radius: 20px 20px 0 0; + margin: 10px 10px 0 10px; + box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.3); + } + + .falling-word { + position: absolute; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 20px 30px; + border-radius: 25px; + font-size: 2rem; + font-weight: 600; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3), 0 0 20px rgba(102, 126, 234, 0.4); + cursor: default; + user-select: none; + transform: translateX(-50%); + animation: wordGlow 2s ease-in-out infinite; + z-index: 10; + } + + .falling-word.exploding { + animation: explode 0.8s ease-out forwards; + } + + .falling-word.wrong-shake { + animation: wrongShake 0.6s ease-in-out forwards; + } + + .word-storm-answer-panel { + position: relative; + background: rgba(0, 0, 0, 0.9); + padding: 15px; + border-top: 3px solid #667eea; + border-radius: 0 0 20px 20px; + margin: 0 10px 10px 10px; + z-index: 100; + } + + .word-storm-answer-panel.wrong-flash { + animation: wrongFlash 0.5s ease-in-out; + } + + .answer-buttons-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; + max-width: 600px; + margin: 0 auto; + } + + .word-storm-answer-btn { + padding: 10px 16px; + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + color: white; + border: none; + border-radius: 16px; + font-size: 0.95rem; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; + } + + .word-storm-answer-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4); + } + + .word-storm-answer-btn:active { + transform: translateY(0); + } + + .word-storm-answer-btn.correct { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + animation: correctPulse 0.6s ease-out; + } + + .word-storm-answer-btn.incorrect { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + animation: incorrectShake 0.6s ease-out; + } + + .level-up-popup { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(0, 0, 0, 0.9); + color: white; + padding: 30px; + border-radius: 15px; + text-align: center; + z-index: 1000; + animation: levelUpAppear 2s ease-out forwards; + } + + .points-popup { + position: absolute; + font-size: 2rem; + font-weight: bold; + color: #10b981; + pointer-events: none; + z-index: 1000; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); + animation: pointsFloat 1.5s ease-out forwards; + } + + .game-over-screen { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.9); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + } + + .game-over-content { + background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%); + color: white; + padding: 40px; + border-radius: 15px; + text-align: center; + max-width: 400px; + } + + .game-over-content h2 { + margin: 0 0 20px 0; + font-size: 2.5rem; + } + + .final-stats { + margin: 20px 0; + padding: 20px; + background: rgba(255, 255, 255, 0.1); + border-radius: 8px; + } + + .stat-row { + display: flex; + justify-content: space-between; + margin: 10px 0; + } + + .restart-btn, .exit-btn { + margin: 10px; + padding: 12px 25px; + border: 2px solid white; + border-radius: 8px; + background: transparent; + color: white; + font-size: 1rem; + cursor: pointer; + transition: all 0.3s ease; + } + + .restart-btn:hover, .exit-btn:hover { + background: white; + color: #dc2626; + } + + /* Animations */ + @keyframes wordGlow { + 0%, 100% { + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3), 0 0 20px rgba(102, 126, 234, 0.4); + } + 50% { + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3), 0 0 30px rgba(102, 126, 234, 0.6); + } + } + + @keyframes explode { + 0% { + transform: translateX(-50%) scale(1) rotate(0deg); + opacity: 1; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + } + 25% { + transform: translateX(-50%) scale(1.3) rotate(5deg); + opacity: 0.9; + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + box-shadow: 0 8px 25px rgba(16, 185, 129, 0.5), 0 0 40px rgba(16, 185, 129, 0.8); + } + 50% { + transform: translateX(-50%) scale(1.5) rotate(-3deg); + opacity: 0.7; + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + box-shadow: 0 12px 35px rgba(245, 158, 11, 0.6), 0 0 60px rgba(245, 158, 11, 0.9); + } + 75% { + transform: translateX(-50%) scale(0.8) rotate(2deg); + opacity: 0.4; + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + } + 100% { + transform: translateX(-50%) scale(0.1) rotate(0deg); + opacity: 0; + } + } + + @keyframes wrongShake { + 0%, 100% { + transform: translateX(-50%) scale(1); + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + } + 10%, 30%, 50%, 70%, 90% { + transform: translateX(-60%) scale(0.95); + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + box-shadow: 0 4px 15px rgba(239, 68, 68, 0.6), 0 0 25px rgba(239, 68, 68, 0.8); + } + 20%, 40%, 60%, 80% { + transform: translateX(-40%) scale(0.95); + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + box-shadow: 0 4px 15px rgba(239, 68, 68, 0.6), 0 0 25px rgba(239, 68, 68, 0.8); + } + } + + @keyframes wrongFlash { + 0%, 100% { + background: rgba(0, 0, 0, 0.8); + } + 50% { + background: rgba(239, 68, 68, 0.6); + box-shadow: 0 0 20px rgba(239, 68, 68, 0.6), inset 0 0 20px rgba(239, 68, 68, 0.3); + } + } + + @keyframes correctPulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.05); } + 100% { transform: scale(1); } + } + + @keyframes incorrectShake { + 0%, 100% { transform: translateX(0); } + 25% { transform: translateX(-5px); } + 75% { transform: translateX(5px); } + } + + @keyframes pointsFloat { + 0% { + transform: translateY(0) scale(1); + opacity: 1; + } + 30% { + transform: translateY(-20px) scale(1.3); + opacity: 1; + } + 100% { + transform: translateY(-80px) scale(0.5); + opacity: 0; + } + } + + @keyframes levelUpAppear { + 0% { + transform: translate(-50%, -50%) scale(0.5); + opacity: 0; + } + 20% { + transform: translate(-50%, -50%) scale(1.2); + opacity: 1; + } + 80% { + transform: translate(-50%, -50%) scale(1); + opacity: 1; + } + 100% { + transform: translate(-50%, -50%) scale(1); + opacity: 0; + } + } + + @keyframes screenShake { + 0%, 100% { transform: translateX(0); } + 10% { transform: translateX(-3px) translateY(1px); } + 20% { transform: translateX(3px) translateY(-1px); } + 30% { transform: translateX(-2px) translateY(2px); } + 40% { transform: translateX(2px) translateY(-2px); } + 50% { transform: translateX(-1px) translateY(1px); } + 60% { transform: translateX(1px) translateY(-1px); } + 70% { transform: translateX(-2px) translateY(0px); } + 80% { transform: translateX(2px) translateY(1px); } + 90% { transform: translateX(-1px) translateY(-1px); } + } + + @media (max-width: 768px) { + .falling-word { + padding: 15px 25px; + font-size: 1.8rem; + } + + .hud-section { + gap: 15px; + } + + .answer-buttons-grid { + grid-template-columns: 1fr 1fr; + gap: 10px; + } + } + + @media (max-width: 480px) { + .falling-word { + font-size: 1.5rem; + padding: 12px 20px; + } + + .answer-buttons-grid { + grid-template-columns: 1fr; + } + } + `; + + document.head.appendChild(style); + } + + _removeCSS() { + const cssId = `word-storm-styles-${this.name}`; + const existingStyle = document.getElementById(cssId); + if (existingStyle) { + existingStyle.remove(); + } + } + + _createGameInterface() { + this._config.container.innerHTML = ` +
+
+
+
+
Score
+
0
+
+
+
Level
+
1
+
+
+ +
+
+
Lives
+
3
+
+
+
Combo
+
0
+
+
+ +
+ + +
+
+ +
+ +
+
+ +
+
+
+ `; + + this._generateAnswerOptions(); + } + + _setupEventListeners() { + // Pause button + const pauseBtn = document.getElementById('pause-btn'); + if (pauseBtn) { + pauseBtn.addEventListener('click', () => this._togglePause()); + } + + // Exit button + const exitButton = document.getElementById('exit-storm'); + if (exitButton) { + exitButton.addEventListener('click', () => { + this._eventBus.emit('game:exit-request', { instanceId: this.name }, this.name); + }); + } + + // Answer button clicks + this._config.container.addEventListener('click', (event) => { + if (event.target.matches('.word-storm-answer-btn')) { + const answer = event.target.textContent; + this._checkAnswer(answer); + } + + if (event.target.matches('.restart-btn')) { + this._restartGame(); + } + }); + + // Keyboard support + document.addEventListener('keydown', (event) => { + if (event.key >= '1' && event.key <= '4') { + const btnIndex = parseInt(event.key) - 1; + const buttons = document.querySelectorAll('.word-storm-answer-btn'); + if (buttons[btnIndex]) { + buttons[btnIndex].click(); + } + } + + if (event.key === ' ' || event.key === 'Escape') { + event.preventDefault(); + this._togglePause(); + } + }); + } + + _startSpawning() { + this._spawnInterval = setInterval(() => { + if (!this._isGamePaused && !this._isGameOver) { + this._spawnFallingWord(); + } + }, this._config.spawnRate); + } + + _spawnFallingWord() { + if (this._vocabulary.length === 0) return; + + const word = this._vocabulary[this._currentWordIndex % this._vocabulary.length]; + this._currentWordIndex++; + + const gameArea = document.getElementById('game-area'); + const wordElement = document.createElement('div'); + wordElement.className = 'falling-word'; + wordElement.textContent = word.original; + wordElement.style.left = Math.random() * 80 + 10 + '%'; + wordElement.style.top = '80px'; // Start just below the HUD + + gameArea.appendChild(wordElement); + + this._fallingWords.push({ + element: wordElement, + word: word, + startTime: Date.now() + }); + + // Generate new answer options when word spawns + this._generateAnswerOptions(); + + // 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`; + setTimeout(() => { + wordElement.style.top = 'calc(100vh + 60px)'; // Continue falling past screen + }, 50); + } + + _generateAnswerOptions() { + if (this._vocabulary.length === 0) return; + + const buttons = []; + const correctWord = this._fallingWords.length > 0 ? + this._fallingWords[this._fallingWords.length - 1].word : + this._vocabulary[0]; + + // Add correct answer + buttons.push(correctWord.translation); + + // Add 3 random incorrect answers + while (buttons.length < 4) { + const randomWord = this._vocabulary[Math.floor(Math.random() * this._vocabulary.length)]; + if (!buttons.includes(randomWord.translation)) { + buttons.push(randomWord.translation); + } + } + + // Shuffle buttons + this._shuffleArray(buttons); + + // Update answer panel + const answerButtons = document.getElementById('answer-buttons'); + if (answerButtons) { + answerButtons.innerHTML = buttons.map(answer => + `` + ).join(''); + } + } + + _checkAnswer(selectedAnswer) { + const activeFallingWords = this._fallingWords.filter(fw => fw.element.parentNode); + + for (let i = 0; i < activeFallingWords.length; i++) { + const fallingWord = activeFallingWords[i]; + if (fallingWord.word.translation === selectedAnswer) { + this._correctAnswer(fallingWord); + return; + } + } + + // Wrong answer + this._wrongAnswer(); + } + + _correctAnswer(fallingWord) { + // Remove from game with epic explosion + if (fallingWord.element.parentNode) { + fallingWord.element.classList.add('exploding'); + + // Add screen shake effect + const gameArea = document.getElementById('game-area'); + if (gameArea) { + gameArea.style.animation = 'none'; + gameArea.offsetHeight; // Force reflow + gameArea.style.animation = 'screenShake 0.3s ease-in-out'; + setTimeout(() => { + gameArea.style.animation = ''; + }, 300); + } + + setTimeout(() => { + if (fallingWord.element.parentNode) { + fallingWord.element.remove(); + } + }, 800); + } + + // Remove from tracking + this._fallingWords = this._fallingWords.filter(fw => fw !== fallingWord); + + // Update score + this._combo++; + const points = 10 + (this._combo * 2); + this._score += points; + + // Update display + this._updateHUD(); + + // Add points popup animation + this._showPointsPopup(points, fallingWord.element); + + // Vibration feedback (if supported) + if (navigator.vibrate) { + navigator.vibrate([50, 30, 50]); + } + + // Level up check + if (this._score > 0 && this._score % 100 === 0) { + this._levelUp(); + } + + // Emit correct answer event + this._eventBus.emit('word-storm:correct-answer', { + gameId: 'word-storm', + instanceId: this.name, + word: fallingWord.word, + points, + combo: this._combo, + score: this._score + }, this.name); + } + + _wrongAnswer() { + this._combo = 0; + + // Enhanced wrong answer animation + const answerPanel = document.getElementById('answer-panel'); + if (answerPanel) { + answerPanel.classList.add('wrong-flash'); + setTimeout(() => { + answerPanel.classList.remove('wrong-flash'); + }, 500); + } + + // Shake all falling words to show disappointment + this._fallingWords.forEach(fw => { + if (fw.element.parentNode && !fw.element.classList.contains('exploding')) { + fw.element.classList.add('wrong-shake'); + setTimeout(() => { + fw.element.classList.remove('wrong-shake'); + }, 600); + } + }); + + // Screen flash red + const gameArea = document.getElementById('game-area'); + if (gameArea) { + const overlay = document.createElement('div'); + overlay.style.cssText = ` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(239, 68, 68, 0.3); + pointer-events: none; + animation: wrongFlash 0.4s ease-in-out; + z-index: 100; + `; + gameArea.appendChild(overlay); + setTimeout(() => { + if (overlay.parentNode) overlay.remove(); + }, 400); + } + + this._updateHUD(); + + // Wrong answer vibration (stronger/longer) + if (navigator.vibrate) { + navigator.vibrate([200, 100, 200, 100, 200]); + } + + // Emit wrong answer event + this._eventBus.emit('word-storm:wrong-answer', { + gameId: 'word-storm', + instanceId: this.name, + score: this._score + }, this.name); + } + + _showPointsPopup(points, wordElement) { + const popup = document.createElement('div'); + popup.textContent = `+${points}`; + popup.className = 'points-popup'; + popup.style.left = wordElement.style.left; + popup.style.top = wordElement.offsetTop + 'px'; + + const gameArea = document.getElementById('game-area'); + if (gameArea) { + gameArea.appendChild(popup); + setTimeout(() => { + if (popup.parentNode) popup.remove(); + }, 1500); + } + } + + _missWord(wordElement) { + // Remove word + if (wordElement.parentNode) { + wordElement.remove(); + } + + // Remove from tracking + this._fallingWords = this._fallingWords.filter(fw => fw.element !== wordElement); + + // Lose life + this._lives--; + this._combo = 0; + + this._updateHUD(); + + if (this._lives <= 0) { + this._gameOver(); + } + + // Emit word missed event + this._eventBus.emit('word-storm:word-missed', { + gameId: 'word-storm', + instanceId: this.name, + lives: this._lives, + score: this._score + }, this.name); + } + + _levelUp() { + this._level++; + + // Increase difficulty by 5% (x1.05 speed = /1.05 time) + this._config.fallSpeed = Math.max(1000, this._config.fallSpeed / 1.05); + this._config.spawnRate = Math.max(800, this._config.spawnRate / 1.05); + + // Restart intervals with new timing + if (this._spawnInterval) { + clearInterval(this._spawnInterval); + this._startSpawning(); + } + + this._updateHUD(); + + // Show level up message + const gameArea = document.getElementById('game-area'); + const levelUpMsg = document.createElement('div'); + levelUpMsg.className = 'level-up-popup'; + levelUpMsg.innerHTML = ` +

⚡ LEVEL UP! ⚡

+

Level ${this._level}

+

Words fall faster!

+ `; + gameArea.appendChild(levelUpMsg); + + setTimeout(() => { + if (levelUpMsg.parentNode) { + levelUpMsg.remove(); + } + }, 2000); + + // Emit level up event + this._eventBus.emit('word-storm:level-up', { + gameId: 'word-storm', + instanceId: this.name, + level: this._level, + score: this._score + }, this.name); + } + + _togglePause() { + this._isGamePaused = !this._isGamePaused; + const pauseBtn = document.getElementById('pause-btn'); + if (pauseBtn) { + pauseBtn.textContent = this._isGamePaused ? '▶️ Resume' : '⏸️ Pause'; + } + + if (this._isGamePaused) { + this._eventBus.emit('game:paused', { instanceId: this.name }, this.name); + } else { + this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name); + } + } + + _gameOver() { + this._isGameOver = true; + + // Clear intervals + if (this._spawnInterval) { + clearInterval(this._spawnInterval); + this._spawnInterval = null; + } + + // Clear falling words + this._fallingWords.forEach(fw => { + if (fw.element.parentNode) { + fw.element.remove(); + } + }); + this._fallingWords = []; + + // Show game over screen + this._showGameOverScreen(); + + // Emit game over event + this._eventBus.emit('game:completed', { + gameId: 'word-storm', + instanceId: this.name, + score: this._score, + level: this._level, + duration: this._gameStartTime ? Date.now() - this._gameStartTime : 0 + }, this.name); + } + + _showGameOverScreen() { + const duration = this._gameStartTime ? Math.round((Date.now() - this._gameStartTime) / 1000) : 0; + + // Store best score + const gameKey = 'word-storm'; + const currentScore = this._score; + const bestScore = parseInt(localStorage.getItem(`${gameKey}-best-score`) || '0'); + const isNewBest = currentScore > bestScore; + + if (isNewBest) { + localStorage.setItem(`${gameKey}-best-score`, currentScore.toString()); + } + + // Show victory popup + this._showVictoryPopup({ + gameTitle: 'Word Storm', + currentScore, + bestScore: isNewBest ? currentScore : bestScore, + isNewBest, + stats: { + 'Level Reached': this._level, + 'Duration': `${duration}s`, + 'Words Caught': this._wordsCaught || 0, + 'Accuracy': this._wordsCaught ? `${Math.round((this._wordsCaught / (this._wordsCaught + this._wordsMissed || 0)) * 100)}%` : '0%' + } + }); + } + + _restartGame() { + // Reset game state + this._score = 0; + this._level = 1; + this._lives = this._config.startingLives; + this._combo = 0; + this._isGamePaused = false; + this._isGameOver = false; + this._currentWordIndex = 0; + this._gameStartTime = Date.now(); + + // Reset fall speed and spawn rate + this._config.fallSpeed = 8000; + this._config.spawnRate = 4000; + + // Clear existing intervals + if (this._spawnInterval) { + clearInterval(this._spawnInterval); + } + + // Clear falling words + this._fallingWords.forEach(fw => { + if (fw.element.parentNode) { + fw.element.remove(); + } + }); + this._fallingWords = []; + + // Victory popup is handled by its own close events + + // Update HUD and restart + this._updateHUD(); + this._generateAnswerOptions(); + this._startSpawning(); + } + + _updateHUD() { + const scoreDisplay = document.getElementById('score-display'); + const levelDisplay = document.getElementById('level-display'); + const livesDisplay = document.getElementById('lives-display'); + const comboDisplay = document.getElementById('combo-display'); + + if (scoreDisplay) scoreDisplay.textContent = this._score; + if (levelDisplay) levelDisplay.textContent = this._level; + if (livesDisplay) livesDisplay.textContent = this._lives; + if (comboDisplay) comboDisplay.textContent = this._combo; + } + + _showError(message) { + if (this._config.container) { + this._config.container.innerHTML = ` +
+
+

Word Storm Error

+

${message}

+ +
+ `; + } + } + + _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; + } + + _handlePause() { + this._isGamePaused = true; + const pauseBtn = document.getElementById('pause-btn'); + if (pauseBtn) { + pauseBtn.textContent = '▶️ Resume'; + } + this._eventBus.emit('game:paused', { instanceId: this.name }, this.name); + } + + _handleResume() { + this._isGamePaused = false; + const pauseBtn = document.getElementById('pause-btn'); + if (pauseBtn) { + pauseBtn.textContent = '⏸️ Pause'; + } + this._eventBus.emit('game:resumed', { instanceId: this.name }, this.name); + } + + _showVictoryPopup({ gameTitle, currentScore, bestScore, isNewBest, stats }) { + const popup = document.createElement('div'); + popup.className = 'victory-popup'; + popup.innerHTML = ` +
+
+
💥
+

${gameTitle} Complete!

+ ${isNewBest ? '
🎉 New Best Score!
' : ''} +
+ +
+
+
Your Score
+
${currentScore}
+
+
+
Best Score
+
${bestScore}
+
+
+ +
+ ${Object.entries(stats).map(([key, value]) => ` +
+
${key}
+
${value}
+
+ `).join('')} +
+ +
+ + + +
+
+ `; + + document.body.appendChild(popup); + + // Animate in + requestAnimationFrame(() => { + popup.classList.add('show'); + }); + + // Add event listeners + popup.querySelector('#play-again-btn').addEventListener('click', () => { + popup.remove(); + this._restartGame(); + }); + + popup.querySelector('#different-game-btn').addEventListener('click', () => { + popup.remove(); + if (window.app && window.app.getCore().router) { + window.app.getCore().router.navigate('/games'); + } else { + window.location.href = '/#/games'; + } + }); + + popup.querySelector('#main-menu-btn').addEventListener('click', () => { + popup.remove(); + if (window.app && window.app.getCore().router) { + window.app.getCore().router.navigate('/'); + } else { + window.location.href = '/'; + } + }); + + // Close on backdrop click + popup.addEventListener('click', (e) => { + if (e.target === popup) { + popup.remove(); + if (window.app && window.app.getCore().router) { + window.app.getCore().router.navigate('/games'); + } else { + window.location.href = '/#/games'; + } + } + }); + } +} + +export default WordStorm; \ No newline at end of file diff --git a/src/games/adventure-reader.js b/src/games/adventure-reader.js deleted file mode 100644 index 6e81dca..0000000 --- a/src/games/adventure-reader.js +++ /dev/null @@ -1,1287 +0,0 @@ -// === MODULE ADVENTURE READER (ZELDA-STYLE) === - -class AdventureReaderGame { - constructor(options) { - this.container = options.container; - this.content = options.content; - this.onScoreUpdate = options.onScoreUpdate || (() => {}); - this.onGameEnd = options.onGameEnd || (() => {}); - - // Game state - this.score = 0; - this.currentSentenceIndex = 0; - this.currentVocabIndex = 0; - this.potsDestroyed = 0; - this.enemiesDefeated = 0; - this.isGamePaused = false; - - // Game objects - this.pots = []; - this.enemies = []; - this.player = { x: 0, y: 0 }; // Will be set when map is created - this.isPlayerMoving = false; - this.isPlayerInvulnerable = false; - this.invulnerabilityTimeout = null; - - // TTS settings - this.autoPlayTTS = true; - this.ttsEnabled = true; - - // Expose content globally for SettingsManager TTS language detection - window.currentGameContent = this.content; - - // Content extraction - this.vocabulary = this.extractVocabulary(this.content); - this.sentences = this.extractSentences(this.content); - this.stories = this.extractStories(this.content); - this.dialogues = this.extractDialogues(this.content); - - this.init(); - } - - init() { - const hasVocabulary = this.vocabulary && this.vocabulary.length > 0; - const hasSentences = this.sentences && this.sentences.length > 0; - const hasStories = this.stories && this.stories.length > 0; - const hasDialogues = this.dialogues && this.dialogues.length > 0; - - if (!hasVocabulary && !hasSentences && !hasStories && !hasDialogues) { - logSh('No compatible content found for Adventure Reader', 'ERROR'); - this.showInitError(); - return; - } - - logSh(`Adventure Reader initialized with: ${this.vocabulary.length} vocab, ${this.sentences.length} sentences, ${this.stories.length} stories, ${this.dialogues.length} dialogues`, 'INFO'); - - this.createGameInterface(); - this.initializePlayer(); - this.setupEventListeners(); - this.updateContentInfo(); - this.generateGameObjects(); - this.generateDecorations(); - this.startGameLoop(); - } - - showInitError() { - this.container.innerHTML = ` -
-

❌ No Adventure Content Found

-

This content module needs adventure-compatible content:

-
    -
  • 📚 texts: Stories with original_language and user_language
  • -
  • 💬 dialogues: Character conversations with speakers
  • -
  • 📝 vocabulary: Words with translations for discovery
  • -
  • 📖 sentences: Individual phrases for reading practice
  • -
-

Add adventure content to enable this game mode.

- -
- `; - } - - extractVocabulary(content) { - let vocabulary = []; - - // Support pour Dragon's Pearl vocabulary structure - if (content.vocabulary && typeof content.vocabulary === 'object') { - vocabulary = Object.entries(content.vocabulary).map(([original_language, vocabData]) => { - if (typeof vocabData === 'string') { - // Simple format: "word": "translation" - return { - original_language: original_language, - user_language: vocabData, - type: 'unknown' - }; - } else if (typeof vocabData === 'object') { - // Rich format: "word": { user_language: "translation", type: "noun", ... } - return { - original_language: original_language, - user_language: vocabData.user_language || vocabData.translation || 'No translation', - type: vocabData.type || 'unknown', - pronunciation: vocabData.pronunciation, - difficulty: vocabData.difficulty - }; - } - return null; - }).filter(item => item !== null); - } - // Ultra-modular format support - else if (content.rawContent && content.rawContent.vocabulary) { - if (typeof content.rawContent.vocabulary === 'object' && !Array.isArray(content.rawContent.vocabulary)) { - vocabulary = Object.entries(content.rawContent.vocabulary).map(([original_language, vocabData]) => { - if (typeof vocabData === 'string') { - // Simple format: "word": "translation" - return { - original_language: original_language, - user_language: vocabData, - type: 'unknown' - }; - } else if (typeof vocabData === 'object') { - // Rich format: "word": { user_language: "translation", type: "noun", ... } - return { - original_language: original_language, - user_language: vocabData.user_language || vocabData.translation || 'No translation', - type: vocabData.type || 'unknown', - pronunciation: vocabData.pronunciation, - difficulty: vocabData.difficulty - }; - } - return null; - }).filter(item => item !== null); - } - } - - return vocabulary.filter(item => item && item.original_language && item.user_language); - } - - extractSentences(content) { - let sentences = []; - - logSh('🐉 Adventure Reader: Extracting sentences from content...', 'DEBUG'); - logSh(`🐉 Content structure: story=${!!content.story}, rawContent=${!!content.rawContent}`, 'DEBUG'); - - // Support pour Dragon's Pearl structure: content.story.chapters[].sentences[] - if (content.story && content.story.chapters && Array.isArray(content.story.chapters)) { - logSh(`🐉 Dragon's Pearl structure detected, ${content.story.chapters.length} chapters`, 'DEBUG'); - - content.story.chapters.forEach((chapter, chapterIndex) => { - logSh(`🐉 Processing chapter ${chapterIndex}: ${chapter.title}`, 'DEBUG'); - - if (chapter.sentences && Array.isArray(chapter.sentences)) { - logSh(`🐉 Chapter ${chapterIndex} has ${chapter.sentences.length} sentences`, 'DEBUG'); - - chapter.sentences.forEach((sentence, sentenceIndex) => { - if (sentence.original && sentence.translation) { - // Construire la prononciation depuis les mots si pas disponible directement - let pronunciation = sentence.pronunciation || ''; - - if (!pronunciation && sentence.words && Array.isArray(sentence.words)) { - pronunciation = sentence.words - .map(wordObj => wordObj.pronunciation || '') - .filter(p => p.trim().length > 0) - .join(' '); - } - - sentences.push({ - original_language: sentence.original, - user_language: sentence.translation, - pronunciation: pronunciation, - chapter: chapter.title || '', - id: sentence.id || sentences.length - }); - } else { - logSh(`🐉 WARNING: Skipping sentence ${sentenceIndex} in chapter ${chapterIndex} - missing original/translation`, 'WARN'); - } - }); - } else { - logSh(`🐉 WARNING: Chapter ${chapterIndex} has no sentences array`, 'WARN'); - } - }); - - logSh(`🐉 Dragon's Pearl extraction complete: ${sentences.length} sentences extracted`, 'INFO'); - } - // Support pour la structure ultra-modulaire existante - else if (content.rawContent) { - // Ultra-modular format: Extract from texts (stories/adventures) - if (content.rawContent.texts && Array.isArray(content.rawContent.texts)) { - content.rawContent.texts.forEach(text => { - if (text.original_language && text.user_language) { - // Split long texts into sentences for adventure reading - const originalSentences = text.original_language.split(/[.!?]+/).filter(s => s.trim().length > 10); - const userSentences = text.user_language.split(/[.!?]+/).filter(s => s.trim().length > 10); - - // Match sentences by index - originalSentences.forEach((originalSentence, index) => { - const userSentence = userSentences[index] || originalSentence; - sentences.push({ - original_language: originalSentence.trim() + '.', - user_language: userSentence.trim() + '.', - title: text.title || 'Adventure Text', - id: text.id || `text_${index}` - }); - }); - } - }); - } - - // Ultra-modular format: Extract from dialogues - if (content.rawContent.dialogues && Array.isArray(content.rawContent.dialogues)) { - content.rawContent.dialogues.forEach(dialogue => { - if (dialogue.conversation && Array.isArray(dialogue.conversation)) { - dialogue.conversation.forEach(line => { - if (line.original_language && line.user_language) { - sentences.push({ - original_language: line.original_language, - user_language: line.user_language, - speaker: line.speaker || 'Character', - title: dialogue.title || 'Dialogue', - id: line.id || dialogue.id - }); - } - }); - } - }); - } - - // Legacy format support for backward compatibility - if (content.rawContent.sentences && Array.isArray(content.rawContent.sentences)) { - content.rawContent.sentences.forEach(sentence => { - sentences.push({ - original_language: sentence.english || sentence.original_language || '', - user_language: sentence.chinese || sentence.french || sentence.user_language || sentence.translation || '', - pronunciation: sentence.prononciation || sentence.pronunciation - }); - }); - } - } - - return sentences.filter(item => item && item.original_language && item.user_language); - } - - extractStories(content) { - let stories = []; - - // Support pour Dragon's Pearl structure - if (content.story && content.story.chapters && Array.isArray(content.story.chapters)) { - // Créer une histoire depuis les chapitres de Dragon's Pearl - stories.push({ - title: content.story.title || content.name || "Dragon's Pearl", - original_language: content.story.chapters.map(ch => - ch.sentences.map(s => s.original).join(' ') - ).join('\n\n'), - user_language: content.story.chapters.map(ch => - ch.sentences.map(s => s.translation).join(' ') - ).join('\n\n'), - chapters: content.story.chapters.map(chapter => ({ - title: chapter.title, - sentences: chapter.sentences - })) - }); - } - // Support pour la structure ultra-modulaire existante - else if (content.rawContent && content.rawContent.texts && Array.isArray(content.rawContent.texts)) { - stories = content.rawContent.texts.filter(text => - text.original_language && text.user_language && text.title - ).map(text => ({ - id: text.id || `story_${Date.now()}_${Math.random()}`, - title: text.title, - original_language: text.original_language, - user_language: text.user_language, - description: text.description || '', - difficulty: text.difficulty || 'medium' - })); - } - - return stories; - } - - extractDialogues(content) { - let dialogues = []; - - if (content.rawContent && content.rawContent.dialogues && Array.isArray(content.rawContent.dialogues)) { - dialogues = content.rawContent.dialogues.filter(dialogue => - dialogue.conversation && Array.isArray(dialogue.conversation) && dialogue.conversation.length > 0 - ).map(dialogue => ({ - id: dialogue.id || `dialogue_${Date.now()}_${Math.random()}`, - title: dialogue.title || 'Character Dialogue', - conversation: dialogue.conversation.filter(line => - line.original_language && line.user_language - ).map(line => ({ - id: line.id || `line_${Date.now()}_${Math.random()}`, - speaker: line.speaker || 'Character', - original_language: line.original_language, - user_language: line.user_language, - emotion: line.emotion || 'neutral' - })) - })); - } - - return dialogues.filter(dialogue => dialogue.conversation.length > 0); - } - - updateContentInfo() { - const contentInfoEl = document.getElementById('content-info'); - if (!contentInfoEl) return; - - const contentTypes = []; - - if (this.stories && this.stories.length > 0) { - contentTypes.push(`📚 ${this.stories.length} stories`); - } - - if (this.dialogues && this.dialogues.length > 0) { - contentTypes.push(`💬 ${this.dialogues.length} dialogues`); - } - - if (this.vocabulary && this.vocabulary.length > 0) { - contentTypes.push(`📝 ${this.vocabulary.length} words`); - } - - if (this.sentences && this.sentences.length > 0) { - contentTypes.push(`📖 ${this.sentences.length} sentences`); - } - - if (contentTypes.length > 0) { - contentInfoEl.innerHTML = ` -
- Adventure Content: ${contentTypes.join(' • ')} -
- `; - } - } - - createGameInterface() { - this.container.innerHTML = ` -
- -
-
-
- 🏆 - 0 -
-
- 🏺 - 0 -
-
- ⚔️ - 0 -
-
-
-
- Start your adventure! -
-
-
- - -
- -
🧙‍♂️
- - -
- - -
-
- Click 🏺 pots for vocabulary • Click 👹 enemies for sentences -
- - -
- - - - - - -
- `; - } - - initializePlayer() { - // Set player initial position to center of map - const gameMap = document.getElementById('game-map'); - const mapRect = gameMap.getBoundingClientRect(); - this.player.x = mapRect.width / 2 - 20; // -20 for half player width - this.player.y = mapRect.height / 2 - 20; // -20 for half player height - - const playerElement = document.getElementById('player'); - playerElement.style.left = this.player.x + 'px'; - playerElement.style.top = this.player.y + 'px'; - } - - setupEventListeners() { - document.getElementById('restart-btn').addEventListener('click', () => this.restart()); - document.getElementById('continue-btn').addEventListener('click', () => this.closeModal()); - - // Map click handler - const gameMap = document.getElementById('game-map'); - gameMap.addEventListener('click', (e) => this.handleMapClick(e)); - - // Window resize handler - window.addEventListener('resize', () => { - setTimeout(() => this.initializePlayer(), 100); - }); - } - - generateGameObjects() { - const gameMap = document.getElementById('game-map'); - - // Clear existing objects - gameMap.querySelectorAll('.pot, .enemy').forEach(el => el.remove()); - - this.pots = []; - this.enemies = []; - - // Generate pots (for vocabulary) - const numPots = Math.min(8, this.vocabulary.length); - for (let i = 0; i < numPots; i++) { - const pot = this.createPot(); - this.pots.push(pot); - gameMap.appendChild(pot.element); - } - - // Generate enemies (for sentences) - spawn across entire viewport - const numEnemies = Math.min(8, this.sentences.length); - for (let i = 0; i < numEnemies; i++) { - const enemy = this.createEnemy(); - this.enemies.push(enemy); - gameMap.appendChild(enemy.element); - } - - this.updateHUD(); - } - - createPot() { - const pot = document.createElement('div'); - pot.className = 'pot'; - pot.innerHTML = '🏺'; - - const position = this.getRandomPosition(); - pot.style.left = position.x + 'px'; - pot.style.top = position.y + 'px'; - - return { - element: pot, - x: position.x, - y: position.y, - destroyed: false - }; - } - - createEnemy() { - const enemy = document.createElement('div'); - enemy.className = 'enemy'; - enemy.innerHTML = '👹'; - - const position = this.getRandomPosition(true); // Force away from player - enemy.style.left = position.x + 'px'; - enemy.style.top = position.y + 'px'; - - // Random movement pattern for each enemy - const patterns = ['patrol', 'chase', 'wander', 'circle']; - const pattern = patterns[Math.floor(Math.random() * patterns.length)]; - - return { - element: enemy, - x: position.x, - y: position.y, - defeated: false, - moveDirection: Math.random() * Math.PI * 2, - speed: 0.6 + Math.random() * 0.6, // Reduced speed - pattern: pattern, - patrolStartX: position.x, - patrolStartY: position.y, - patrolDistance: 80 + Math.random() * 60, - circleCenter: { x: position.x, y: position.y }, - circleRadius: 60 + Math.random() * 40, - circleAngle: Math.random() * Math.PI * 2, - changeDirectionTimer: 0, - dashCooldown: 0, - isDashing: false - }; - } - - getRandomPosition(forceAwayFromPlayer = false) { - const gameMap = document.getElementById('game-map'); - const mapRect = gameMap.getBoundingClientRect(); - const mapWidth = mapRect.width; - const mapHeight = mapRect.height; - const margin = 40; - - let x, y; - let tooClose; - const minDistance = forceAwayFromPlayer ? 150 : 80; - - do { - x = margin + Math.random() * (mapWidth - margin * 2); - y = margin + Math.random() * (mapHeight - margin * 2); - - // Check distance from player - const distFromPlayer = Math.sqrt( - Math.pow(x - this.player.x, 2) + Math.pow(y - this.player.y, 2) - ); - tooClose = distFromPlayer < minDistance; - - } while (tooClose); - - return { x, y }; - } - - handleMapClick(e) { - if (this.isGamePaused || this.isPlayerMoving) return; - - const rect = e.currentTarget.getBoundingClientRect(); - const clickX = e.clientX - rect.left; - const clickY = e.clientY - rect.top; - - // Check pot clicks - let targetFound = false; - this.pots.forEach(pot => { - if (!pot.destroyed && this.isNearPosition(clickX, clickY, pot)) { - this.movePlayerToTarget(pot, 'pot'); - targetFound = true; - } - }); - - // Check enemy clicks (only if no pot was clicked) - if (!targetFound) { - this.enemies.forEach(enemy => { - if (!enemy.defeated && this.isNearPosition(clickX, clickY, enemy)) { - this.movePlayerToTarget(enemy, 'enemy'); - targetFound = true; - } - }); - } - - // If no target found, move to empty area - if (!targetFound) { - this.movePlayerToPosition(clickX, clickY); - } - } - - isNearPosition(clickX, clickY, object) { - const distance = Math.sqrt( - Math.pow(clickX - (object.x + 20), 2) + Math.pow(clickY - (object.y + 20), 2) - ); - return distance < 60; // Larger clickable area - } - - movePlayerToTarget(target, type) { - this.isPlayerMoving = true; - const playerElement = document.getElementById('player'); - - // Grant invulnerability IMMEDIATELY when attacking an enemy - if (type === 'enemy') { - this.grantAttackInvulnerability(); - } - - // Calculate target position (near the object) - const targetX = target.x; - const targetY = target.y; - - // Update player position - this.player.x = targetX; - this.player.y = targetY; - - // Animate player movement - playerElement.style.left = targetX + 'px'; - playerElement.style.top = targetY + 'px'; - - // Add walking animation - playerElement.style.transform = 'scale(1.1)'; - - // Wait for movement animation to complete, then interact - setTimeout(() => { - playerElement.style.transform = 'scale(1)'; - this.isPlayerMoving = false; - - if (type === 'pot') { - this.destroyPot(target); - } else if (type === 'enemy') { - this.defeatEnemy(target); - } - }, 800); // Match CSS transition duration - } - - movePlayerToPosition(targetX, targetY) { - this.isPlayerMoving = true; - const playerElement = document.getElementById('player'); - - // Update player position - this.player.x = targetX - 20; // Center the player on click point - this.player.y = targetY - 20; - - // Keep player within bounds - const gameMap = document.getElementById('game-map'); - const mapRect = gameMap.getBoundingClientRect(); - const margin = 20; - - this.player.x = Math.max(margin, Math.min(mapRect.width - 60, this.player.x)); - this.player.y = Math.max(margin, Math.min(mapRect.height - 60, this.player.y)); - - // Animate player movement - playerElement.style.left = this.player.x + 'px'; - playerElement.style.top = this.player.y + 'px'; - - // Add walking animation - playerElement.style.transform = 'scale(1.1)'; - - // Reset animation after movement - setTimeout(() => { - playerElement.style.transform = 'scale(1)'; - this.isPlayerMoving = false; - }, 800); - } - - destroyPot(pot) { - pot.destroyed = true; - pot.element.classList.add('destroyed'); - - // Animation - pot.element.innerHTML = '💥'; - setTimeout(() => { - pot.element.style.opacity = '0.3'; - pot.element.innerHTML = '💨'; - }, 200); - - this.potsDestroyed++; - this.score += 10; - - // Show vocabulary - if (this.currentVocabIndex < this.vocabulary.length) { - this.showVocabPopup(this.vocabulary[this.currentVocabIndex]); - this.currentVocabIndex++; - } - - this.updateHUD(); - this.checkGameComplete(); - } - - defeatEnemy(enemy) { - enemy.defeated = true; - enemy.element.classList.add('defeated'); - - // Animation - enemy.element.innerHTML = '☠️'; - setTimeout(() => { - enemy.element.style.opacity = '0.3'; - }, 300); - - this.enemiesDefeated++; - this.score += 25; - - // Invulnerability is already granted at start of movement - // Just refresh the timer to ensure full 2 seconds from now - this.refreshAttackInvulnerability(); - - // Show sentence (pause game) - if (this.currentSentenceIndex < this.sentences.length) { - this.showReadingModal(this.sentences[this.currentSentenceIndex]); - this.currentSentenceIndex++; - } - - this.updateHUD(); - } - - showVocabPopup(vocab) { - const popup = document.getElementById('vocab-popup'); - const wordEl = document.getElementById('vocab-word'); - const translationEl = document.getElementById('vocab-translation'); - const pronunciationEl = document.getElementById('vocab-pronunciation'); - - wordEl.textContent = vocab.original_language; - translationEl.textContent = vocab.user_language; - - // Afficher la prononciation si disponible - if (vocab.pronunciation) { - pronunciationEl.textContent = `🗣️ ${vocab.pronunciation}`; - pronunciationEl.style.display = 'block'; - } else { - pronunciationEl.style.display = 'none'; - } - - popup.style.display = 'block'; - popup.classList.add('show'); - - // Auto-play TTS for vocabulary - if (this.autoPlayTTS && this.ttsEnabled) { - setTimeout(() => { - this.speakText(vocab.original_language, { rate: 0.8 }); - }, 400); // Small delay to let popup appear - } - - setTimeout(() => { - popup.classList.remove('show'); - setTimeout(() => { - popup.style.display = 'none'; - }, 300); - }, 2000); - } - - showReadingModal(sentence) { - this.isGamePaused = true; - const modal = document.getElementById('reading-modal'); - const content = document.getElementById('reading-content'); - const modalTitle = document.getElementById('modal-title'); - - // Determine content type and set appropriate modal title - let modalTitleText = 'Adventure Text'; - if (sentence.speaker) { - modalTitleText = `💬 ${sentence.speaker} says...`; - } else if (sentence.title) { - modalTitleText = `📚 ${sentence.title}`; - } - - modalTitle.textContent = modalTitleText; - - // Create content with appropriate styling based on type - const speakerInfo = sentence.speaker ? `
🎭 ${sentence.speaker}
` : ''; - const titleInfo = sentence.title && !sentence.speaker ? `
📖 ${sentence.title}
` : ''; - const emotionInfo = sentence.emotion && sentence.emotion !== 'neutral' ? `
😊 ${sentence.emotion}
` : ''; - - content.innerHTML = ` -
- ${titleInfo} - ${speakerInfo} - ${emotionInfo} -
-

${sentence.original_language}

-

${sentence.user_language}

- ${sentence.pronunciation ? `

🗣️ ${sentence.pronunciation}

` : ''} -
-
- `; - - modal.style.display = 'flex'; - modal.classList.add('show'); - - // Auto-play TTS for sentence - if (this.autoPlayTTS && this.ttsEnabled) { - setTimeout(() => { - this.speakText(sentence.original_language, { rate: 0.8 }); - }, 600); // Longer delay for modal animation - } - } - - closeModal() { - const modal = document.getElementById('reading-modal'); - modal.classList.remove('show'); - setTimeout(() => { - modal.style.display = 'none'; - this.isGamePaused = false; - }, 300); - - this.checkGameComplete(); - } - - checkGameComplete() { - const allPotsDestroyed = this.pots.every(pot => pot.destroyed); - const allEnemiesDefeated = this.enemies.every(enemy => enemy.defeated); - - if (allPotsDestroyed && allEnemiesDefeated) { - setTimeout(() => { - this.gameComplete(); - }, 1000); - } - } - - gameComplete() { - // Bonus for completion - this.score += 100; - this.updateHUD(); - - document.getElementById('progress-text').textContent = '🏆 Adventure Complete!'; - - setTimeout(() => { - this.onGameEnd(this.score); - }, 2000); - } - - updateHUD() { - document.getElementById('score-display').textContent = this.score; - document.getElementById('pots-counter').textContent = this.potsDestroyed; - document.getElementById('enemies-counter').textContent = this.enemiesDefeated; - - const totalObjects = this.pots.length + this.enemies.length; - const destroyedObjects = this.potsDestroyed + this.enemiesDefeated; - - document.getElementById('progress-text').textContent = - `Progress: ${destroyedObjects}/${totalObjects} objects`; - - this.onScoreUpdate(this.score); - } - - generateDecorations() { - const gameMap = document.getElementById('game-map'); - const mapRect = gameMap.getBoundingClientRect(); - const mapWidth = mapRect.width; - const mapHeight = mapRect.height; - - // Remove existing decorations - gameMap.querySelectorAll('.decoration').forEach(el => el.remove()); - - // Generate trees (fewer, larger) - const numTrees = 4 + Math.floor(Math.random() * 4); // 4-7 trees - for (let i = 0; i < numTrees; i++) { - const tree = document.createElement('div'); - tree.className = 'decoration tree'; - tree.innerHTML = Math.random() < 0.5 ? '🌳' : '🌲'; - - const position = this.getDecorationPosition(mapWidth, mapHeight, 60); // Keep away from objects - tree.style.left = position.x + 'px'; - tree.style.top = position.y + 'px'; - tree.style.fontSize = (25 + Math.random() * 15) + 'px'; // Random size - - gameMap.appendChild(tree); - } - - // Generate grass patches (many, small) - const numGrass = 15 + Math.floor(Math.random() * 10); // 15-24 grass - for (let i = 0; i < numGrass; i++) { - const grass = document.createElement('div'); - grass.className = 'decoration grass'; - const grassTypes = ['🌿', '🌱', '🍀', '🌾']; - grass.innerHTML = grassTypes[Math.floor(Math.random() * grassTypes.length)]; - - const position = this.getDecorationPosition(mapWidth, mapHeight, 30); // Smaller keepaway - grass.style.left = position.x + 'px'; - grass.style.top = position.y + 'px'; - grass.style.fontSize = (15 + Math.random() * 8) + 'px'; // Smaller size - - gameMap.appendChild(grass); - } - - // Generate rocks (medium amount) - const numRocks = 3 + Math.floor(Math.random() * 3); // 3-5 rocks - for (let i = 0; i < numRocks; i++) { - const rock = document.createElement('div'); - rock.className = 'decoration rock'; - rock.innerHTML = Math.random() < 0.5 ? '🪨' : '⛰️'; - - 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'; - - gameMap.appendChild(rock); - } - } - - getDecorationPosition(mapWidth, mapHeight, keepAwayDistance) { - const margin = 20; - let x, y; - let attempts = 0; - let validPosition = false; - - do { - x = margin + Math.random() * (mapWidth - margin * 2); - y = margin + Math.random() * (mapHeight - margin * 2); - - // Check distance from player - const distFromPlayer = Math.sqrt( - Math.pow(x - this.player.x, 2) + Math.pow(y - this.player.y, 2) - ); - - // Check distance from pots and enemies - let tooClose = distFromPlayer < keepAwayDistance; - - if (!tooClose) { - this.pots.forEach(pot => { - const dist = Math.sqrt(Math.pow(x - pot.x, 2) + Math.pow(y - pot.y, 2)); - if (dist < keepAwayDistance) tooClose = true; - }); - } - - if (!tooClose) { - this.enemies.forEach(enemy => { - const dist = Math.sqrt(Math.pow(x - enemy.x, 2) + Math.pow(y - enemy.y, 2)); - if (dist < keepAwayDistance) tooClose = true; - }); - } - - validPosition = !tooClose; - attempts++; - - } while (!validPosition && attempts < 50); - - return { x, y }; - } - - startGameLoop() { - const animate = () => { - if (!this.isGamePaused) { - this.moveEnemies(); - } - requestAnimationFrame(animate); - }; - animate(); - } - - moveEnemies() { - const gameMap = document.getElementById('game-map'); - const mapRect = gameMap.getBoundingClientRect(); - const mapWidth = mapRect.width; - const mapHeight = mapRect.height; - - this.enemies.forEach(enemy => { - if (enemy.defeated) return; - - // Apply movement pattern - this.applyMovementPattern(enemy, mapWidth, mapHeight); - - // Bounce off walls (using dynamic map size) - if (enemy.x < 10 || enemy.x > mapWidth - 50) { - enemy.moveDirection = Math.PI - enemy.moveDirection; - enemy.x = Math.max(10, Math.min(mapWidth - 50, enemy.x)); - } - if (enemy.y < 10 || enemy.y > mapHeight - 50) { - enemy.moveDirection = -enemy.moveDirection; - enemy.y = Math.max(10, Math.min(mapHeight - 50, enemy.y)); - } - - enemy.element.style.left = enemy.x + 'px'; - enemy.element.style.top = enemy.y + 'px'; - - // Check collision with player - this.checkPlayerEnemyCollision(enemy); - }); - } - - applyMovementPattern(enemy, mapWidth, mapHeight) { - enemy.changeDirectionTimer++; - - switch (enemy.pattern) { - case 'patrol': - // Patrol back and forth - const distanceFromStart = Math.sqrt( - Math.pow(enemy.x - enemy.patrolStartX, 2) + Math.pow(enemy.y - enemy.patrolStartY, 2) - ); - - if (distanceFromStart > enemy.patrolDistance) { - // Turn around and head back to start - const angleToStart = Math.atan2( - enemy.patrolStartY - enemy.y, - enemy.patrolStartX - enemy.x - ); - enemy.moveDirection = angleToStart; - } - - if (enemy.changeDirectionTimer > 120) { // Change direction every ~2 seconds - enemy.moveDirection += (Math.random() - 0.5) * Math.PI * 0.5; - enemy.changeDirectionTimer = 0; - } - - enemy.x += Math.cos(enemy.moveDirection) * enemy.speed; - enemy.y += Math.sin(enemy.moveDirection) * enemy.speed; - break; - - case 'chase': - enemy.dashCooldown--; - - if (enemy.isDashing) { - // Continue dash movement with very high speed - enemy.x += Math.cos(enemy.moveDirection) * (enemy.speed * 6); - enemy.y += Math.sin(enemy.moveDirection) * (enemy.speed * 6); - enemy.dashCooldown--; - - if (enemy.dashCooldown <= 0) { - enemy.isDashing = false; - enemy.dashCooldown = 120 + Math.random() * 60; // Reset cooldown - } - } else { - // Normal chase behavior - const angleToPlayer = Math.atan2( - this.player.y - enemy.y, - this.player.x - enemy.x - ); - - // Sometimes do a perpendicular dash - if (enemy.dashCooldown <= 0 && Math.random() < 0.3) { - enemy.isDashing = true; - enemy.dashCooldown = 50; // Much longer dash duration - - // Perpendicular angle (90 degrees from player direction) - const perpAngle = angleToPlayer + (Math.random() < 0.5 ? Math.PI/2 : -Math.PI/2); - enemy.moveDirection = perpAngle; - - // Start dash with visual effect - enemy.element.style.filter = 'drop-shadow(0 0 8px red)'; - setTimeout(() => { - enemy.element.style.filter = 'drop-shadow(1px 1px 2px rgba(0,0,0,0.3))'; - }, 300); - } else { - // Mix chasing with some randomness - 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': - // Random wandering - if (enemy.changeDirectionTimer > 60 + Math.random() * 60) { - enemy.moveDirection += (Math.random() - 0.5) * Math.PI; - enemy.changeDirectionTimer = 0; - } - - enemy.x += Math.cos(enemy.moveDirection) * enemy.speed; - enemy.y += Math.sin(enemy.moveDirection) * enemy.speed; - break; - - case 'circle': - // Move in circular pattern - enemy.circleAngle += 0.03 + (enemy.speed * 0.01); - - enemy.x = enemy.circleCenter.x + Math.cos(enemy.circleAngle) * enemy.circleRadius; - enemy.y = enemy.circleCenter.y + Math.sin(enemy.circleAngle) * enemy.circleRadius; - - // Occasionally change circle center - if (enemy.changeDirectionTimer > 180) { - enemy.circleCenter.x += (Math.random() - 0.5) * 100; - enemy.circleCenter.y += (Math.random() - 0.5) * 100; - - // Keep circle center within bounds - enemy.circleCenter.x = Math.max(enemy.circleRadius + 20, - Math.min(mapWidth - enemy.circleRadius - 20, enemy.circleCenter.x)); - enemy.circleCenter.y = Math.max(enemy.circleRadius + 20, - Math.min(mapHeight - enemy.circleRadius - 20, enemy.circleCenter.y)); - - enemy.changeDirectionTimer = 0; - } - break; - } - } - - checkPlayerEnemyCollision(enemy) { - if (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) - ); - - // Collision detected - if (distance < 35) { - this.takeDamage(); - } - } - - takeDamage() { - if (this.isPlayerInvulnerable) return; - - // Apply damage - this.score = Math.max(0, this.score - 20); - this.updateHUD(); - - // Clear any existing invulnerability timeout - if (this.invulnerabilityTimeout) { - clearTimeout(this.invulnerabilityTimeout); - } - - // Start damage invulnerability - this.isPlayerInvulnerable = true; - const playerElement = document.getElementById('player'); - - // Visual feedback - blinking effect - let blinkCount = 0; - const blinkInterval = setInterval(() => { - playerElement.style.opacity = playerElement.style.opacity === '0.3' ? '1' : '0.3'; - blinkCount++; - - if (blinkCount >= 8) { // 4 blinks in 2 seconds - clearInterval(blinkInterval); - playerElement.style.opacity = '1'; - playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))'; - playerElement.style.transform = 'scale(1)'; - this.isPlayerInvulnerable = false; - } - }, 250); - - // Show damage feedback - this.showDamagePopup(); - } - - grantAttackInvulnerability() { - // Always grant invulnerability after attack, even if already invulnerable - this.isPlayerInvulnerable = true; - const playerElement = document.getElementById('player'); - - // Clear any existing timeout - if (this.invulnerabilityTimeout) { - clearTimeout(this.invulnerabilityTimeout); - } - - // Different visual effect for attack invulnerability (golden glow) - playerElement.style.filter = 'drop-shadow(0 0 15px gold) brightness(1.4)'; - - this.invulnerabilityTimeout = setTimeout(() => { - playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))'; - this.isPlayerInvulnerable = false; - }, 2000); - - // Show invulnerability feedback - this.showInvulnerabilityPopup(); - } - - refreshAttackInvulnerability() { - // Refresh the invulnerability timer without changing visual state - if (this.invulnerabilityTimeout) { - clearTimeout(this.invulnerabilityTimeout); - } - - const playerElement = document.getElementById('player'); - this.isPlayerInvulnerable = true; - - this.invulnerabilityTimeout = setTimeout(() => { - playerElement.style.filter = 'drop-shadow(2px 2px 4px rgba(0,0,0,0.3))'; - this.isPlayerInvulnerable = false; - }, 2000); - } - - showInvulnerabilityPopup() { - const popup = document.createElement('div'); - popup.className = 'invulnerability-popup'; - popup.innerHTML = 'Protected!'; - popup.style.position = 'fixed'; - popup.style.left = '50%'; - popup.style.top = '25%'; - popup.style.transform = 'translate(-50%, -50%)'; - popup.style.color = '#FFD700'; - popup.style.fontSize = '1.5rem'; - popup.style.fontWeight = 'bold'; - popup.style.zIndex = '999'; - popup.style.pointerEvents = 'none'; - popup.style.animation = 'protectionFloat 2s ease-out forwards'; - - document.body.appendChild(popup); - - setTimeout(() => { - popup.remove(); - }, 2000); - } - - showDamagePopup() { - // Create damage popup - const damagePopup = document.createElement('div'); - damagePopup.className = 'damage-popup'; - damagePopup.innerHTML = '-20'; - damagePopup.style.position = 'fixed'; - damagePopup.style.left = '50%'; - damagePopup.style.top = '30%'; - damagePopup.style.transform = 'translate(-50%, -50%)'; - damagePopup.style.color = '#EF4444'; - damagePopup.style.fontSize = '2rem'; - damagePopup.style.fontWeight = 'bold'; - damagePopup.style.zIndex = '999'; - damagePopup.style.pointerEvents = 'none'; - damagePopup.style.animation = 'damageFloat 1.5s ease-out forwards'; - - document.body.appendChild(damagePopup); - - setTimeout(() => { - damagePopup.remove(); - }, 1500); - } - - start() { - logSh('⚔️ Adventure Reader: Starting', 'INFO'); - document.getElementById('progress-text').textContent = 'Click objects to begin your adventure!'; - } - - restart() { - logSh('🔄 Adventure Reader: Restarting', 'INFO'); - this.reset(); - this.start(); - } - - reset() { - this.score = 0; - this.currentSentenceIndex = 0; - this.currentVocabIndex = 0; - this.potsDestroyed = 0; - this.enemiesDefeated = 0; - this.isGamePaused = false; - this.isPlayerMoving = false; - this.isPlayerInvulnerable = false; - - // Clear any existing timeout - if (this.invulnerabilityTimeout) { - clearTimeout(this.invulnerabilityTimeout); - this.invulnerabilityTimeout = null; - } - - this.generateGameObjects(); - this.initializePlayer(); - this.generateDecorations(); - } - - // TTS Methods - speakText(text, options = {}) { - if (!text || !this.ttsEnabled) return; - - // Use SettingsManager if available for better language support - if (window.SettingsManager && window.SettingsManager.speak) { - const ttsOptions = { - lang: this.getContentLanguage(), - rate: options.rate || 0.8, - ...options - }; - - window.SettingsManager.speak(text, ttsOptions) - .catch(error => { - console.warn('🔊 SettingsManager TTS failed:', error); - this.fallbackTTS(text, ttsOptions); - }); - } else { - this.fallbackTTS(text, options); - } - } - - fallbackTTS(text, options = {}) { - if ('speechSynthesis' in window && text) { - // Cancel any ongoing speech - speechSynthesis.cancel(); - - const utterance = new SpeechSynthesisUtterance(text); - utterance.lang = this.getContentLanguage(); - utterance.rate = options.rate || 0.8; - utterance.volume = 1.0; - - speechSynthesis.speak(utterance); - } - } - - getContentLanguage() { - // Get language from content or use sensible defaults - if (this.content.language) { - const langMap = { - 'chinese': 'zh-CN', - 'english': 'en-US', - 'french': 'fr-FR', - 'spanish': 'es-ES' - }; - return langMap[this.content.language] || this.content.language; - } - return 'en-US'; // Default fallback - } - - destroy() { - // Cancel any ongoing TTS - if ('speechSynthesis' in window) { - speechSynthesis.cancel(); - } - this.container.innerHTML = ''; - } -} - -// Module registration -window.GameModules = window.GameModules || {}; -window.GameModules.AdventureReader = AdventureReaderGame; \ No newline at end of file diff --git a/src/games/fill-the-blank.js b/src/games/fill-the-blank.js deleted file mode 100644 index 5628c1a..0000000 --- a/src/games/fill-the-blank.js +++ /dev/null @@ -1,569 +0,0 @@ -// === MODULE FILL THE BLANK === - -class FillTheBlankGame { - constructor(options) { - this.container = options.container; - this.content = options.content; - this.onScoreUpdate = options.onScoreUpdate || (() => {}); - this.onGameEnd = options.onGameEnd || (() => {}); - - // Game state - this.score = 0; - this.errors = 0; - this.currentSentenceIndex = 0; - this.isRunning = false; - - // Game data - this.vocabulary = this.extractVocabulary(this.content); - this.sentences = this.extractRealSentences(); - this.currentSentence = null; - this.blanks = []; - this.userAnswers = []; - - this.init(); - } - - init() { - // Check that we have vocabulary - if (!this.vocabulary || this.vocabulary.length === 0) { - logSh('No vocabulary available for Fill the Blank', 'ERROR'); - this.showInitError(); - return; - } - - this.createGameBoard(); - this.setupEventListeners(); - // The game will start when start() is called - } - - showInitError() { - this.container.innerHTML = ` -
-

❌ Loading Error

-

This content does not contain vocabulary compatible with Fill the Blank.

-

The game requires words with their translations in ultra-modular format.

- -
- `; - } - - extractVocabulary(content) { - let vocabulary = []; - - logSh('🔍 Extracting vocabulary from:', content?.name || 'content', 'INFO'); - - // Priority 1: Use raw module content (ultra-modular format) - if (content.rawContent) { - logSh('📦 Using raw module content', 'INFO'); - return this.extractVocabularyFromRaw(content.rawContent); - } - - // Priority 2: Ultra-modular format (vocabulary object) - ONLY format supported - if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) { - logSh('✨ Ultra-modular format detected (vocabulary object)', 'INFO'); - vocabulary = Object.entries(content.vocabulary).map(([word, data]) => { - // Support ultra-modular format ONLY - if (typeof data === 'object' && data.user_language) { - return { - original: word, // Clé = original_language - translation: data.user_language.split(';')[0], // First translation - fullTranslation: data.user_language, // Complete translation - type: data.type || 'general', - audio: data.audio, - image: data.image, - examples: data.examples, - pronunciation: data.pronunciation, - category: data.type || 'general' - }; - } - // Legacy fallback - simple string (temporary, will be removed) - else if (typeof data === 'string') { - return { - original: word, - translation: data.split(';')[0], - fullTranslation: data, - type: 'general', - category: 'general' - }; - } - return null; - }).filter(Boolean); - } - // No other formats supported - ultra-modular only - - return this.finalizeVocabulary(vocabulary); - } - - extractVocabularyFromRaw(rawContent) { - logSh('🔧 Extracting from raw content:', rawContent.name || 'Module', 'INFO'); - let vocabulary = []; - - // Ultra-modular format (vocabulary object) - ONLY format supported - if (rawContent.vocabulary && typeof rawContent.vocabulary === 'object' && !Array.isArray(rawContent.vocabulary)) { - vocabulary = Object.entries(rawContent.vocabulary).map(([word, data]) => { - // Support ultra-modular format ONLY - if (typeof data === 'object' && data.user_language) { - return { - original: word, // Clé = original_language - translation: data.user_language.split(';')[0], // First translation - fullTranslation: data.user_language, // Complete translation - type: data.type || 'general', - audio: data.audio, - image: data.image, - examples: data.examples, - pronunciation: data.pronunciation, - category: data.type || 'general' - }; - } - // Legacy fallback - simple string (temporary, will be removed) - else if (typeof data === 'string') { - return { - original: word, - translation: data.split(';')[0], - fullTranslation: data, - type: 'general', - category: 'general' - }; - } - return null; - }).filter(Boolean); - logSh(`✨ ${vocabulary.length} words extracted from ultra-modular vocabulary`, 'INFO'); - } - // No other formats supported - ultra-modular only - else { - logSh('⚠️ Content format not supported - ultra-modular format required', 'WARN'); - } - - return this.finalizeVocabulary(vocabulary); - } - - finalizeVocabulary(vocabulary) { - // Validation and cleanup for ultra-modular format - vocabulary = vocabulary.filter(word => - word && - typeof word.original === 'string' && - typeof word.translation === 'string' && - word.original.trim() !== '' && - word.translation.trim() !== '' - ); - - if (vocabulary.length === 0) { - logSh('❌ No valid vocabulary found', 'ERROR'); - // Demo vocabulary as last resort - vocabulary = [ - { original: 'hello', translation: 'bonjour', category: 'greetings' }, - { original: 'goodbye', translation: 'au revoir', category: 'greetings' }, - { original: 'thank you', translation: 'merci', category: 'greetings' }, - { original: 'cat', translation: 'chat', category: 'animals' }, - { original: 'dog', translation: 'chien', category: 'animals' }, - { original: 'house', translation: 'maison', category: 'objects' }, - { original: 'school', translation: 'école', category: 'places' }, - { original: 'book', translation: 'livre', category: 'objects' } - ]; - logSh('🚨 Using demo vocabulary', 'WARN'); - } - - logSh(`✅ Fill the Blank: ${vocabulary.length} words finalized`, 'INFO'); - return vocabulary; - } - - extractRealSentences() { - let sentences = []; - - logSh('🔍 Extracting real sentences from content...', 'INFO'); - - // Priority 1: Extract from story chapters - if (this.content.story?.chapters) { - this.content.story.chapters.forEach(chapter => { - if (chapter.sentences) { - chapter.sentences.forEach(sentence => { - if (sentence.original && sentence.translation) { - sentences.push({ - original: sentence.original, - translation: sentence.translation, - source: 'story' - }); - } - }); - } - }); - } - - // Priority 2: Extract from rawContent story - if (this.content.rawContent?.story?.chapters) { - this.content.rawContent.story.chapters.forEach(chapter => { - if (chapter.sentences) { - chapter.sentences.forEach(sentence => { - if (sentence.original && sentence.translation) { - sentences.push({ - original: sentence.original, - translation: sentence.translation, - source: 'rawContent.story' - }); - } - }); - } - }); - } - - // Priority 3: Extract from sentences array - const directSentences = this.content.sentences || this.content.rawContent?.sentences; - if (directSentences && Array.isArray(directSentences)) { - directSentences.forEach(sentence => { - if (sentence.english && sentence.chinese) { - sentences.push({ - original: sentence.english, - translation: sentence.chinese, - source: 'sentences' - }); - } else if (sentence.original && sentence.translation) { - sentences.push({ - original: sentence.original, - translation: sentence.translation, - source: 'sentences' - }); - } - }); - } - - // Filter sentences that are suitable for fill-the-blank (min 3 words) - sentences = sentences.filter(sentence => - sentence.original && - sentence.original.split(' ').length >= 3 && - sentence.original.trim().length > 0 - ); - - // Shuffle and limit - sentences = this.shuffleArray(sentences); - - logSh(`📝 Extracted ${sentences.length} real sentences for fill-the-blank`, 'INFO'); - - if (sentences.length === 0) { - logSh('❌ No suitable sentences found for fill-the-blank', 'ERROR'); - return this.createFallbackSentences(); - } - - return sentences.slice(0, 20); // Limit to 20 sentences max - } - - createFallbackSentences() { - // Simple fallback using vocabulary words in basic sentences - const fallback = []; - this.vocabulary.slice(0, 10).forEach(vocab => { - fallback.push({ - original: `This is a ${vocab.original}.`, - translation: `这是一个 ${vocab.translation}。`, - source: 'fallback' - }); - }); - return fallback; - } - - createGameBoard() { - this.container.innerHTML = ` -
- -
-
-
- ${this.currentSentenceIndex + 1} - / ${this.sentences.length} -
-
- ${this.errors} - Errors -
-
- ${this.score} - Score -
-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- - - -
- - - -
- `; - } - - setupEventListeners() { - document.getElementById('check-btn').addEventListener('click', () => this.checkAnswer()); - document.getElementById('hint-btn').addEventListener('click', () => this.showHint()); - document.getElementById('skip-btn').addEventListener('click', () => this.skipSentence()); - - // Enter key to check answer - document.addEventListener('keydown', (e) => { - if (e.key === 'Enter' && this.isRunning) { - this.checkAnswer(); - } - }); - } - - start() { - logSh('🎮 Fill the Blank: Starting game', 'INFO'); - this.loadNextSentence(); - } - - restart() { - logSh('🔄 Fill the Blank: Restarting game', 'INFO'); - this.reset(); - this.start(); - } - - reset() { - this.score = 0; - this.errors = 0; - this.currentSentenceIndex = 0; - this.isRunning = false; - this.currentSentence = null; - this.blanks = []; - this.userAnswers = []; - this.onScoreUpdate(0); - } - - loadNextSentence() { - // If we've finished all sentences, restart from the beginning - if (this.currentSentenceIndex >= this.sentences.length) { - this.currentSentenceIndex = 0; - this.sentences = this.shuffleArray(this.sentences); // Shuffle again - this.showFeedback(`🎉 All sentences completed! Starting over with a new order.`, 'success'); - setTimeout(() => { - this.loadNextSentence(); - }, 1500); - return; - } - - this.isRunning = true; - this.currentSentence = this.sentences[this.currentSentenceIndex]; - this.createBlanks(); - this.displaySentence(); - this.updateUI(); - } - - createBlanks() { - const words = this.currentSentence.original.split(' '); - this.blanks = []; - - // Create 1-2 blanks randomly (readable sentences) - const numBlanks = Math.random() < 0.5 ? 1 : 2; - const blankIndices = new Set(); - - // PRIORITY 1: Words from vocabulary (educational value) - const vocabularyWords = []; - const otherWords = []; - - words.forEach((word, index) => { - const cleanWord = word.replace(/[.,!?;:"'()[\]{}\-–—]/g, '').toLowerCase(); - const isVocabularyWord = this.vocabulary.some(vocab => - vocab.original.toLowerCase() === cleanWord - ); - - if (isVocabularyWord) { - vocabularyWords.push({ word, index, priority: 'vocabulary' }); - } else { - otherWords.push({ word, index, priority: 'other', length: cleanWord.length }); - } - }); - - // Select blanks: vocabulary first, then longest words - const selectedWords = []; - - // Take vocabulary words first (shuffled) - const shuffledVocab = this.shuffleArray(vocabularyWords); - for (let i = 0; i < Math.min(numBlanks, shuffledVocab.length); i++) { - selectedWords.push(shuffledVocab[i]); - } - - // If need more blanks, take longest other words - if (selectedWords.length < numBlanks) { - const sortedOthers = otherWords.sort((a, b) => b.length - a.length); - const needed = numBlanks - selectedWords.length; - for (let i = 0; i < Math.min(needed, sortedOthers.length); i++) { - selectedWords.push(sortedOthers[i]); - } - } - - // Add selected indices to blanks - selectedWords.forEach(item => blankIndices.add(item.index)); - - // Create blank structure - words.forEach((word, index) => { - if (blankIndices.has(index)) { - this.blanks.push({ - index: index, - word: word.replace(/[.,!?;:]$/, ''), // Remove punctuation - punctuation: word.match(/[.,!?;:]$/) ? word.match(/[.,!?;:]$/)[0] : '', - userAnswer: '' - }); - } - }); - } - - displaySentence() { - const words = this.currentSentence.original.split(' '); - let sentenceHTML = ''; - let blankCounter = 0; - - words.forEach((word, index) => { - const blank = this.blanks.find(b => b.index === index); - if (blank) { - sentenceHTML += ` - - ${blank.punctuation} - `; - blankCounter++; - } else { - sentenceHTML += `${word} `; - } - }); - - document.getElementById('sentence-container').innerHTML = sentenceHTML; - - // Display translation if available - const translation = this.currentSentence.translation || ''; - document.getElementById('translation-hint').innerHTML = translation ? - `💭 ${translation}` : ''; - - // Focus on first input - const firstInput = document.getElementById('blank-0'); - if (firstInput) { - setTimeout(() => firstInput.focus(), 100); - } - } - - checkAnswer() { - if (!this.isRunning) return; - - let allCorrect = true; - let correctCount = 0; - - // Check each blank - this.blanks.forEach((blank, index) => { - const input = document.getElementById(`blank-${index}`); - const userAnswer = input.value.trim().toLowerCase(); - const correctAnswer = blank.word.toLowerCase(); - - blank.userAnswer = input.value.trim(); - - if (userAnswer === correctAnswer) { - input.classList.remove('incorrect'); - input.classList.add('correct'); - correctCount++; - } else { - input.classList.remove('correct'); - input.classList.add('incorrect'); - allCorrect = false; - } - }); - - if (allCorrect) { - // All answers are correct - this.score += 10 * this.blanks.length; - this.showFeedback(`🎉 Perfect! +${10 * this.blanks.length} points`, 'success'); - setTimeout(() => { - this.currentSentenceIndex++; - this.loadNextSentence(); - }, 1500); - } else { - // Some errors - this.errors++; - if (correctCount > 0) { - this.score += 5 * correctCount; - this.showFeedback(`✨ ${correctCount}/${this.blanks.length} correct! +${5 * correctCount} points. Try again.`, 'partial'); - } else { - this.showFeedback(`❌ Try again! (${this.errors} errors)`, 'error'); - } - } - - this.updateUI(); - this.onScoreUpdate(this.score); - } - - showHint() { - // Show first letter of each empty blank - this.blanks.forEach((blank, index) => { - const input = document.getElementById(`blank-${index}`); - if (!input.value.trim()) { - input.value = blank.word[0]; - input.focus(); - } - }); - - this.showFeedback('💡 First letter added!', 'info'); - } - - skipSentence() { - // Reveal correct answers - this.blanks.forEach((blank, index) => { - const input = document.getElementById(`blank-${index}`); - input.value = blank.word; - input.classList.add('revealed'); - }); - - this.showFeedback('📖 Answers revealed! Next sentence...', 'info'); - setTimeout(() => { - this.currentSentenceIndex++; - this.loadNextSentence(); - }, 2000); - } - - // endGame method removed - game continues indefinitely - - showFeedback(message, type = 'info') { - const feedbackArea = document.getElementById('feedback-area'); - feedbackArea.innerHTML = `
${message}
`; - } - - updateUI() { - document.getElementById('current-question').textContent = this.currentSentenceIndex + 1; - document.getElementById('errors-count').textContent = this.errors; - document.getElementById('score-display').textContent = 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; - } - - destroy() { - this.isRunning = false; - this.container.innerHTML = ''; - } -} - -// Module registration -window.GameModules = window.GameModules || {}; -window.GameModules.FillTheBlank = FillTheBlankGame; \ No newline at end of file diff --git a/src/games/grammar-discovery.js b/src/games/grammar-discovery.js deleted file mode 100644 index b1f83d5..0000000 --- a/src/games/grammar-discovery.js +++ /dev/null @@ -1,1185 +0,0 @@ -// === GRAMMAR DISCOVERY GAME === -// Interactive game for discovering and learning grammar patterns - -class GrammarDiscovery { - constructor({ container, content, onScoreUpdate, onGameEnd }) { - this.container = container; - this.content = content; - this.onScoreUpdate = onScoreUpdate; - this.onGameEnd = onGameEnd; - - this.score = 0; - - // ROTATION SYSTEM FOR FOCUSED CONCEPT LEARNING - this.rotationSteps = [ - 'explanation-basic', // 1. Explication de base (langue originale) - 'examples-simple', // 2. Exemples simples - 'exercise-basic', // 3. Exercices de base - 'explanation-detailed', // 4. Explication détaillée - 'examples-complex', // 5. Exemples complexes - 'exercise-intermediate', // 6. Exercices intermédiaires - 'summary', // 7. Résumé du concept - 'exercise-global' // 8. Exercice global final - ]; - - this.currentStep = 0; - this.grammarConcept = null; // Single focused concept (user selected) - this.conceptData = {}; - this.stepProgress = {}; - this.availableConcepts = []; // All available grammar concepts - this.conceptSelected = false; // Whether user has chosen a concept - - this.injectCSS(); - this.extractAvailableConcepts(); - this.init(); - } - - injectCSS() { - if (document.getElementById('grammar-discovery-styles')) return; - - const styleSheet = document.createElement('style'); - styleSheet.id = 'grammar-discovery-styles'; - styleSheet.textContent = ` - .grammar-discovery { - display: flex; - flex-direction: column; - height: 100%; - font-family: 'Arial', sans-serif; - } - - .grammar-hud { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - padding: 15px; - display: flex; - justify-content: space-between; - align-items: center; - border-radius: 10px 10px 0 0; - } - - .grammar-phase { - font-size: 18px; - font-weight: bold; - display: flex; - align-items: center; - gap: 10px; - } - - .phase-icon { - font-size: 24px; - } - - .grammar-content { - flex: 1; - display: flex; - flex-direction: column; - padding: 20px; - background: linear-gradient(145deg, #f8f9ff, #e6e9ff); - overflow-y: auto; - } - - .rule-card { - background: white; - border-radius: 15px; - padding: 25px; - margin-bottom: 20px; - box-shadow: 0 8px 32px rgba(102, 126, 234, 0.1); - border: 2px solid transparent; - transition: all 0.3s ease; - } - - .rule-card.active { - border-color: #667eea; - transform: translateY(-2px); - box-shadow: 0 12px 40px rgba(102, 126, 234, 0.2); - } - - .rule-title { - font-size: 24px; - font-weight: bold; - color: #4c51bf; - margin-bottom: 15px; - display: flex; - align-items: center; - gap: 10px; - } - - .rule-explanation { - font-size: 16px; - line-height: 1.6; - color: #4a5568; - margin-bottom: 20px; - background: #f7fafc; - padding: 15px; - border-radius: 8px; - border-left: 4px solid #667eea; - } - - .examples-section { - margin-top: 20px; - } - - .example-item { - background: #ffffff; - border: 2px solid #e2e8f0; - border-radius: 12px; - padding: 20px; - margin-bottom: 15px; - transition: all 0.3s ease; - cursor: pointer; - } - - .example-item:hover { - border-color: #667eea; - transform: translateX(5px); - } - - .example-item.revealed { - border-color: #48bb78; - background: linear-gradient(135deg, #f0fff4, #c6f6d5); - } - - .chinese-text { - font-size: 22px; - font-weight: bold; - color: #2d3748; - margin-bottom: 8px; - } - - .english-text { - font-size: 18px; - color: #4a5568; - margin-bottom: 8px; - } - - .pronunciation { - font-size: 16px; - color: #718096; - font-style: italic; - margin-bottom: 10px; - } - - .explanation-text { - font-size: 14px; - color: #667eea; - background: #edf2f7; - padding: 10px; - border-radius: 6px; - display: none; - } - - .example-item.revealed .explanation-text { - display: block; - animation: fadeIn 0.5s ease; - } - - .discovery-controls { - display: flex; - gap: 15px; - margin-top: 20px; - justify-content: center; - } - - .discover-btn { - background: linear-gradient(135deg, #667eea, #764ba2); - color: white; - border: none; - padding: 12px 25px; - border-radius: 25px; - font-size: 16px; - font-weight: bold; - cursor: pointer; - transition: all 0.3s ease; - box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); - } - - .discover-btn:hover { - transform: translateY(-2px); - box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); - } - - .discover-btn:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; - } - - .practice-question { - background: white; - border-radius: 15px; - padding: 25px; - margin-bottom: 20px; - box-shadow: 0 8px 32px rgba(102, 126, 234, 0.1); - } - - .question-text { - font-size: 20px; - color: #2d3748; - margin-bottom: 20px; - line-height: 1.6; - } - - .options-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 15px; - margin-bottom: 20px; - } - - .option-btn { - background: white; - border: 2px solid #e2e8f0; - border-radius: 12px; - padding: 15px; - font-size: 16px; - cursor: pointer; - transition: all 0.3s ease; - text-align: center; - } - - .option-btn:hover { - border-color: #667eea; - background: #f7fafc; - } - - .option-btn.correct { - border-color: #48bb78; - background: linear-gradient(135deg, #f0fff4, #c6f6d5); - color: #22543d; - } - - .option-btn.incorrect { - border-color: #f56565; - background: linear-gradient(135deg, #fed7d7, #feb2b2); - color: #742a2a; - } - - .feedback { - background: #f7fafc; - border-radius: 10px; - padding: 15px; - margin-top: 15px; - border-left: 4px solid #667eea; - display: none; - } - - .feedback.show { - display: block; - animation: slideIn 0.3s ease; - } - - .progress-bar { - background: #e2e8f0; - height: 6px; - border-radius: 3px; - margin: 10px 0; - overflow: hidden; - } - - .progress-fill { - background: linear-gradient(90deg, #667eea, #764ba2); - height: 100%; - border-radius: 3px; - transition: width 0.5s ease; - } - - @keyframes fadeIn { - from { opacity: 0; transform: translateY(-10px); } - to { opacity: 1; transform: translateY(0); } - } - - @keyframes slideIn { - from { opacity: 0; transform: translateX(-20px); } - to { opacity: 1; transform: translateX(0); } - } - - .concept-selector { - background: white; - border-radius: 15px; - padding: 30px; - margin: 20px 0; - box-shadow: 0 8px 32px rgba(102, 126, 234, 0.1); - text-align: center; - } - - .selector-title { - font-size: 24px; - font-weight: bold; - color: #4c51bf; - margin-bottom: 20px; - } - - .selector-description { - font-size: 16px; - color: #4a5568; - margin-bottom: 25px; - line-height: 1.6; - } - - .concept-dropdown { - width: 100%; - max-width: 400px; - padding: 15px; - font-size: 16px; - border: 2px solid #e2e8f0; - border-radius: 10px; - background: white; - margin-bottom: 20px; - cursor: pointer; - transition: all 0.3s ease; - } - - .concept-dropdown:hover { - border-color: #667eea; - } - - .concept-dropdown:focus { - outline: none; - border-color: #667eea; - box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); - } - - .concept-preview { - background: #f7fafc; - border-radius: 10px; - padding: 20px; - margin: 20px 0; - text-align: left; - display: none; - } - - .concept-preview.show { - display: block; - animation: fadeIn 0.3s ease; - } - - .preview-title { - font-size: 18px; - font-weight: bold; - color: #2d3748; - margin-bottom: 10px; - } - - .preview-explanation { - font-size: 14px; - color: #4a5568; - margin-bottom: 15px; - } - - .preview-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: 10px; - font-size: 12px; - color: #718096; - } - - .stat-item { - background: white; - padding: 8px; - border-radius: 6px; - text-align: center; - } - - .phase-complete { - text-align: center; - padding: 40px; - background: linear-gradient(135deg, #f0fff4, #c6f6d5); - border-radius: 15px; - margin: 20px 0; - } - - .complete-icon { - font-size: 48px; - margin-bottom: 20px; - } - - .complete-title { - font-size: 24px; - font-weight: bold; - color: #22543d; - margin-bottom: 15px; - } - - .tts-controls { - display: flex; - gap: 10px; - align-items: center; - margin-top: 10px; - } - - .tts-btn { - background: #667eea; - color: white; - border: none; - border-radius: 20px; - padding: 8px 15px; - cursor: pointer; - font-size: 14px; - transition: all 0.2s ease; - } - - .tts-btn:hover { - background: #5a67d8; - transform: scale(1.05); - } - `; - document.head.appendChild(styleSheet); - } - - extractAvailableConcepts() { - if (!this.content || !this.content.grammar) { - console.error('No grammar content found for Grammar Discovery game'); - return; - } - - // GET ALL AVAILABLE CONCEPTS for selection - this.availableConcepts = Object.entries(this.content.grammar).map(([key, conceptData]) => ({ - id: key, - title: conceptData.title, - explanation: conceptData.explanation, - data: conceptData - })); - - console.log(`🎯 Found ${this.availableConcepts.length} grammar concepts available for selection`); - } - - selectConcept(conceptId) { - const selectedConcept = this.availableConcepts.find(c => c.id === conceptId); - if (!selectedConcept) { - console.error('Concept not found:', conceptId); - return; - } - - this.grammarConcept = conceptId; - this.conceptData = selectedConcept.data; - this.conceptSelected = true; - - console.log(`🎯 Selected concept: ${this.grammarConcept}`); - - // ORGANIZE CONTENT BY DIFFICULTY LEVELS - this.organizeConceptContent(); - - // Start the rotation cycle - this.startRotationCycle(); - } - - organizeConceptContent() { - const concept = this.conceptData; - - // BASIC EXAMPLES (first 2-3 simple ones) - this.simpleExamples = []; - this.complexExamples = []; - - if (concept.examples) { - this.simpleExamples = concept.examples.slice(0, 3); - this.complexExamples = concept.examples.slice(3); - } - - // Get examples from detailed explanation sections - if (concept.detailedExplanation) { - Object.values(concept.detailedExplanation).forEach(section => { - if (section.examples) { - // First 2 go to simple, rest to complex - const sectionSimple = section.examples.slice(0, 2); - const sectionComplex = section.examples.slice(2); - - this.simpleExamples.push(...sectionSimple); - this.complexExamples.push(...sectionComplex); - } - }); - } - - // ORGANIZE EXERCISES BY DIFFICULTY - this.basicExercises = []; - this.intermediateExercises = []; - this.globalExercises = []; - - if (this.content.fillInBlanks) { - this.content.fillInBlanks.forEach(exercise => { - // Accept all fillInBlanks for focused grammar lesson content - const isGrammarLesson = this.content.type === 'grammar_course'; - const isRelevant = isGrammarLesson || - exercise.grammarFocus === this.grammarConcept || - exercise.grammarFocus === 'completion' || - exercise.grammarFocus === 'change-of-state'; - - if (isRelevant) { - // Determine difficulty by sentence complexity - if (exercise.sentence.length < 15) { - this.basicExercises.push(exercise); - } else { - this.intermediateExercises.push(exercise); - } - } - }); - } - - if (this.content.corrections) { - this.content.corrections.forEach(correction => { - // Accept all corrections for focused grammar lesson content - const isGrammarLesson = this.content.type === 'grammar_course'; - const isRelevant = isGrammarLesson || - correction.grammarFocus === this.grammarConcept; - - if (isRelevant) { - this.intermediateExercises.push({ - type: 'correction', - question: 'Which sentence is correct?', - options: [correction.correct, correction.incorrect], - correctAnswer: correction.correct, - explanation: correction.explanation - }); - } - }); - } - - // Global exercises combine multiple concepts - this.globalExercises = [...this.basicExercises, ...this.intermediateExercises]; - this.globalExercises = this.shuffleArray(this.globalExercises).slice(0, 5); - - console.log(`📊 Content organized: - - Simple examples: ${this.simpleExamples.length} - - Complex examples: ${this.complexExamples.length} - - Basic exercises: ${this.basicExercises.length} - - Intermediate exercises: ${this.intermediateExercises.length} - - Global exercises: ${this.globalExercises.length}`); - } - - init() { - this.container.innerHTML = ` -
-
-
- 🎯 - Select Grammar Concept -
- -
Score: 0
-
-
- -
-
- `; - - this.showConceptSelector(); - } - - showConceptSelector() { - if (this.availableConcepts.length === 0) { - console.error('No concepts available for selection'); - return; - } - - const contentDiv = document.getElementById('grammar-content'); - - contentDiv.innerHTML = ` -
-
- 🎯 Choose Grammar Concept -
-
- Select which grammar concept you want to focus on for intensive study with our 8-step rotation system. -
- - - -
- -
- - -
- `; - - // Store reference for global access - window.currentGrammarGame = this; - } - - previewConcept(conceptId) { - const previewDiv = document.getElementById('concept-preview'); - const startBtn = document.getElementById('start-btn'); - - if (!conceptId) { - previewDiv.classList.remove('show'); - startBtn.disabled = true; - return; - } - - const concept = this.availableConcepts.find(c => c.id === conceptId); - if (!concept) return; - - // Calculate stats for this concept - this.grammarConcept = conceptId; - this.conceptData = concept.data; - this.organizeConceptContent(); - - previewDiv.innerHTML = ` -
${concept.title}
-
${concept.explanation}
-
-
- ${this.simpleExamples.length}
- Simple Examples -
-
- ${this.complexExamples.length}
- Complex Examples -
-
- ${this.basicExercises.length}
- Basic Exercises -
-
- ${this.intermediateExercises.length}
- Intermediate Exercises -
-
- ${this.globalExercises.length}
- Final Test Questions -
-
- `; - - previewDiv.classList.add('show'); - startBtn.disabled = false; - } - - startSelectedConcept() { - const dropdown = document.getElementById('concept-dropdown'); - const selectedConceptId = dropdown.value; - - if (!selectedConceptId) { - alert('Please select a grammar concept first!'); - return; - } - - // Update UI to show rotation progress - document.getElementById('step-progress').style.display = 'block'; - document.getElementById('phase-icon').textContent = '📚'; - document.getElementById('phase-text').textContent = 'Basic Explanation'; - - this.selectConcept(selectedConceptId); - } - - startRotationCycle() { - this.currentStep = 0; - this.showCurrentStep(); - } - - showCurrentStep() { - const stepType = this.rotationSteps[this.currentStep]; - const stepNumber = this.currentStep + 1; - - document.getElementById('current-step').textContent = stepNumber; - - // Update phase icon and text based on step - const phaseInfo = this.getPhaseInfo(stepType); - document.getElementById('phase-icon').textContent = phaseInfo.icon; - document.getElementById('phase-text').textContent = phaseInfo.text; - - // Store reference for global access - window.currentGrammarGame = this; - - // Show content for current step - switch (stepType) { - case 'explanation-basic': - this.showBasicExplanation(); - break; - case 'examples-simple': - this.showSimpleExamples(); - break; - case 'exercise-basic': - this.showBasicExercises(); - break; - case 'explanation-detailed': - this.showDetailedExplanation(); - break; - case 'examples-complex': - this.showComplexExamples(); - break; - case 'exercise-intermediate': - this.showIntermediateExercises(); - break; - case 'summary': - this.showSummary(); - break; - case 'exercise-global': - this.showGlobalExercises(); - break; - } - } - - getPhaseInfo(stepType) { - const phaseMap = { - 'explanation-basic': { icon: '📚', text: 'Basic Explanation' }, - 'examples-simple': { icon: '💡', text: 'Simple Examples' }, - 'exercise-basic': { icon: '✏️', text: 'Basic Practice' }, - 'explanation-detailed': { icon: '🔍', text: 'Detailed Explanation' }, - 'examples-complex': { icon: '🧩', text: 'Complex Examples' }, - 'exercise-intermediate': { icon: '💪', text: 'Intermediate Practice' }, - 'summary': { icon: '📝', text: 'Summary' }, - 'exercise-global': { icon: '🏆', text: 'Final Test' } - }; - return phaseMap[stepType] || { icon: '❓', text: 'Unknown' }; - } - - nextStep() { - this.currentStep++; - if (this.currentStep >= this.rotationSteps.length) { - this.completeRotation(); - } else { - this.showCurrentStep(); - } - } - - completeRotation() { - const contentDiv = document.getElementById('grammar-content'); - contentDiv.innerHTML = ` -
-
🎉
-
Grammar Concept Mastered!
-

You've completed the full rotation for: ${this.conceptData.title}

-

Final Score: ${this.score}

- -
- `; - - this.onGameEnd(this.score); - } - - // === ROTATION STEP IMPLEMENTATIONS === - - showBasicExplanation() { - const concept = this.conceptData; - const contentDiv = document.getElementById('grammar-content'); - - contentDiv.innerHTML = ` -
-
- 📚 ${concept.title} -
-
- ${concept.explanation} -
- - ${concept.mainRules ? ` -
-

🎯 Key Rules:

-
    - ${concept.mainRules.slice(0, 3).map(rule => `
  • ${rule}
  • `).join('')} -
-
- ` : ''} - -
- -
-
- `; - } - - showSimpleExamples() { - const contentDiv = document.getElementById('grammar-content'); - - contentDiv.innerHTML = ` -
-
- 💡 Simple Examples -
-
- ${this.simpleExamples.map((example, index) => ` -
-
${example.chinese}
-
${example.english}
-
${example.pronunciation || ''}
-
${example.explanation || example.breakdown || ''}
-
- - -
-
- `).join('')} -
- -
- -
-
- `; - - // Auto-play first example - if (this.simpleExamples.length > 0) { - setTimeout(() => { - this.speakChinese(this.simpleExamples[0].chinese); - }, 1000); - } - } - - showBasicExercises() { - if (this.basicExercises.length === 0) { - this.nextStep(); - return; - } - - this.currentExerciseSet = this.basicExercises; - this.currentExerciseIndex = 0; - this.showExercise('basic'); - } - - showDetailedExplanation() { - const concept = this.conceptData; - const contentDiv = document.getElementById('grammar-content'); - - let detailsHtml = ''; - if (concept.detailedExplanation) { - detailsHtml = Object.entries(concept.detailedExplanation).map(([key, section]) => ` -
-

🔍 ${section.title}

-

${section.explanation}

- ${section.pattern ? `
Pattern: ${section.pattern}
` : ''} -
- `).join(''); - } - - contentDiv.innerHTML = ` -
-
- 🔍 Detailed Explanation -
- - ${detailsHtml} - - ${concept.commonMistakes ? ` -
-

⚠️ Common Mistakes:

- ${concept.commonMistakes.map(mistake => ` -
-
❌ ${mistake.wrong}
-
✅ ${mistake.correct}
-
${mistake.explanation}
-
- `).join('')} -
- ` : ''} - -
- -
-
- `; - } - - showComplexExamples() { - const contentDiv = document.getElementById('grammar-content'); - - contentDiv.innerHTML = ` -
-
- 🧩 Complex Examples -
-
- ${this.complexExamples.map((example, index) => ` -
-
${example.chinese}
-
${example.english}
-
${example.pronunciation || ''}
-
${example.explanation || example.breakdown || ''}
-
- - -
-
- `).join('')} -
- -
- -
-
- `; - } - - showIntermediateExercises() { - if (this.intermediateExercises.length === 0) { - this.nextStep(); - return; - } - - this.currentExerciseSet = this.intermediateExercises; - this.currentExerciseIndex = 0; - this.showExercise('intermediate'); - } - - showSummary() { - const concept = this.conceptData; - const contentDiv = document.getElementById('grammar-content'); - - contentDiv.innerHTML = ` -
-
- 📝 Summary: ${concept.title} -
- -
-

🎯 What You've Learned:

-
-
- Basic Concept:
- ${concept.explanation} -
- ${concept.mainRules ? ` -
- Key Rules:
- ${concept.mainRules.map(rule => `• ${rule}`).join('
')} -
- ` : ''} - ${concept.practicePoints ? ` -
- Practice Points:
- ${concept.practicePoints.map(point => `• ${point}`).join('
')} -
- ` : ''} -
-
- -
- -
-
- `; - } - - showGlobalExercises() { - if (this.globalExercises.length === 0) { - this.completeRotation(); - return; - } - - this.currentExerciseSet = this.globalExercises; - this.currentExerciseIndex = 0; - this.showExercise('global'); - } - - // === UNIFIED EXERCISE SYSTEM === - - showExercise(type) { - const exercise = this.currentExerciseSet[this.currentExerciseIndex]; - if (!exercise) { - this.nextStep(); - return; - } - - const contentDiv = document.getElementById('grammar-content'); - const typeInfo = { - 'basic': { title: '✏️ Basic Practice', color: '#48bb78' }, - 'intermediate': { title: '💪 Intermediate Practice', color: '#3182ce' }, - 'global': { title: '🏆 Final Test', color: '#805ad5' } - }; - - const info = typeInfo[type] || typeInfo['basic']; - - if (exercise.type === 'correction') { - contentDiv.innerHTML = ` -
-
- ${info.title} -
-
- ${exercise.question} -
-
- ${exercise.options.map(option => ` - - `).join('')} -
- -
- Exercise ${this.currentExerciseIndex + 1} of ${this.currentExerciseSet.length} -
-
- `; - } else { - // Fill in the blank - contentDiv.innerHTML = ` -
-
- ${info.title} -
-
- Fill in the blank: ${exercise.sentence} -
-
- ${exercise.options.map(option => ` - - `).join('')} -
- -
- Exercise ${this.currentExerciseIndex + 1} of ${this.currentExerciseSet.length} -
-
- `; - } - } - - selectAnswer(selected, correct, exerciseType) { - const buttons = document.querySelectorAll('.option-btn'); - const feedback = document.getElementById('feedback'); - - buttons.forEach(btn => { - btn.disabled = true; - if (btn.textContent.trim() === correct) { - btn.classList.add('correct'); - } else if (btn.textContent.trim() === selected && selected !== correct) { - btn.classList.add('incorrect'); - } - }); - - // Scoring based on exercise type - if (selected === correct) { - const points = exerciseType === 'global' ? 30 : (exerciseType === 'intermediate' ? 20 : 15); - this.score += points; - } else { - this.score = Math.max(0, this.score - 5); - } - - this.onScoreUpdate(this.score); - document.getElementById('score-value').textContent = this.score; - - feedback.classList.add('show'); - - setTimeout(() => { - this.currentExerciseIndex++; - if (this.currentExerciseIndex >= this.currentExerciseSet.length) { - // Finished this exercise set - this.nextStep(); - } else { - // Show next exercise in set - this.showExercise(exerciseType); - } - }, 2500); - } - - restart() { - this.score = 0; - this.currentStep = 0; - this.currentExerciseIndex = 0; - this.conceptSelected = false; - this.grammarConcept = null; - this.conceptData = {}; - - // Reset UI to concept selector - document.getElementById('score-value').textContent = '0'; - document.getElementById('phase-icon').textContent = '🎯'; - document.getElementById('phase-text').textContent = 'Select Grammar Concept'; - document.getElementById('step-progress').style.display = 'none'; - - this.onScoreUpdate(0); - this.showConceptSelector(); - } - - // TTS Functions - speakChinese(text) { - if (window.SettingsManager && window.SettingsManager.speak) { - window.SettingsManager.speak(text, { - lang: 'zh-CN', - rate: 0.8 - }); - } - } - - speakEnglish(text) { - if (window.SettingsManager && window.SettingsManager.speak) { - window.SettingsManager.speak(text, { - lang: 'en-US', - rate: 0.9 - }); - } - } - - // Utility Functions - 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; - } - - restart() { - this.score = 0; - this.currentPhase = 'discovery'; - this.currentRule = 0; - this.currentExampleIndex = 0; - this.currentPracticeIndex = 0; - this.practiceQuestions = this.shuffleArray(this.practiceQuestions); - - document.getElementById('phase-text').innerHTML = ` - 🔍 Discovery Phase - `; - document.getElementById('score-value').textContent = '0'; - - this.onScoreUpdate(0); - this.startDiscovery(); - } - - start() { - // Game starts automatically in constructor - } - - destroy() { - // Cleanup - const styleSheet = document.getElementById('grammar-discovery-styles'); - if (styleSheet) { - styleSheet.remove(); - } - - if (window.currentGrammarGame === this) { - delete window.currentGrammarGame; - } - } -} - -// Export to global -window.GameModules = window.GameModules || {}; -window.GameModules.GrammarDiscovery = GrammarDiscovery; \ No newline at end of file diff --git a/src/games/letter-discovery.js b/src/games/letter-discovery.js deleted file mode 100644 index 6147d52..0000000 --- a/src/games/letter-discovery.js +++ /dev/null @@ -1,781 +0,0 @@ -// === LETTER DISCOVERY GAME === -// Discover letters first, then explore words that start with each letter - -class LetterDiscovery { - constructor({ container, content, onScoreUpdate, onGameEnd }) { - this.container = container; - this.content = content; - this.onScoreUpdate = onScoreUpdate; - this.onGameEnd = onGameEnd; - - // Game state - this.currentPhase = 'letter-discovery'; // letter-discovery, word-exploration, practice - this.currentLetterIndex = 0; - this.discoveredLetters = []; - this.currentLetter = null; - this.currentWordIndex = 0; - this.discoveredWords = []; - this.score = 0; - this.lives = 3; - - // Content processing - this.letters = []; - this.letterWords = {}; // Map letter -> words starting with that letter - - // Practice system - this.practiceLevel = 1; - this.practiceRound = 0; - this.maxPracticeRounds = 8; - this.practiceCorrectAnswers = 0; - this.practiceErrors = 0; - this.currentPracticeItems = []; - - this.injectCSS(); - this.extractContent(); - this.init(); - } - - injectCSS() { - if (document.getElementById('letter-discovery-styles')) return; - - const styleSheet = document.createElement('style'); - styleSheet.id = 'letter-discovery-styles'; - styleSheet.textContent = ` - .letter-discovery-wrapper { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - min-height: 100vh; - padding: 20px; - position: relative; - overflow-y: auto; - } - - .letter-discovery-hud { - display: flex; - justify-content: space-between; - align-items: center; - background: rgba(255,255,255,0.1); - padding: 15px 20px; - border-radius: 15px; - backdrop-filter: blur(10px); - margin-bottom: 20px; - flex-wrap: wrap; - gap: 10px; - } - - .hud-group { - display: flex; - align-items: center; - gap: 15px; - } - - .hud-item { - color: white; - font-weight: bold; - font-size: 1.1em; - } - - .phase-indicator { - background: rgba(255,255,255,0.2); - padding: 8px 16px; - border-radius: 20px; - font-size: 0.9em; - color: white; - backdrop-filter: blur(5px); - } - - .letter-discovery-main { - background: rgba(255,255,255,0.1); - border-radius: 20px; - padding: 30px; - backdrop-filter: blur(10px); - min-height: 70vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - } - - .game-content { - width: 100%; - max-width: 900px; - text-align: center; - } - - /* Letter Display Styles */ - .letter-card { - background: rgba(255,255,255,0.95); - border-radius: 25px; - padding: 60px 40px; - margin: 30px auto; - max-width: 400px; - box-shadow: 0 20px 40px rgba(0,0,0,0.1); - transform: scale(0.8); - animation: letterAppear 0.8s ease-out forwards; - } - - @keyframes letterAppear { - to { transform: scale(1); } - } - - .letter-display { - font-size: 8em; - font-weight: bold; - color: #667eea; - margin-bottom: 20px; - text-shadow: 0 4px 8px rgba(0,0,0,0.1); - font-family: 'Arial Black', Arial, sans-serif; - } - - .letter-info { - font-size: 1.5em; - color: #333; - margin-bottom: 15px; - } - - .letter-pronunciation { - font-size: 1.2em; - color: #666; - font-style: italic; - margin-bottom: 25px; - } - - .letter-controls { - display: flex; - gap: 15px; - justify-content: center; - margin-top: 30px; - } - - /* Word Exploration Styles */ - .word-exploration-header { - background: rgba(255,255,255,0.1); - padding: 20px; - border-radius: 15px; - margin-bottom: 30px; - backdrop-filter: blur(5px); - } - - .exploring-letter { - font-size: 3em; - color: white; - margin-bottom: 10px; - font-weight: bold; - } - - .word-progress { - color: rgba(255,255,255,0.8); - font-size: 1.1em; - } - - .word-card { - background: rgba(255,255,255,0.95); - border-radius: 20px; - padding: 40px 30px; - margin: 25px auto; - max-width: 500px; - box-shadow: 0 15px 30px rgba(0,0,0,0.1); - transform: translateY(20px); - animation: wordSlideIn 0.6s ease-out forwards; - } - - @keyframes wordSlideIn { - to { transform: translateY(0); } - } - - .word-text { - font-size: 2.5em; - color: #667eea; - margin-bottom: 15px; - font-weight: bold; - } - - .word-translation { - font-size: 1.3em; - color: #333; - margin-bottom: 10px; - } - - .word-pronunciation { - font-size: 1.1em; - color: #666; - font-style: italic; - margin-bottom: 10px; - } - - .word-type { - font-size: 0.9em; - color: #667eea; - background: rgba(102, 126, 234, 0.1); - padding: 4px 12px; - border-radius: 15px; - display: inline-block; - margin-bottom: 15px; - font-weight: 500; - } - - .word-example { - font-size: 1em; - color: #555; - font-style: italic; - padding: 10px 15px; - background: rgba(0, 0, 0, 0.05); - border-left: 3px solid #667eea; - border-radius: 0 8px 8px 0; - margin-bottom: 15px; - } - - /* Practice Challenge Styles */ - .practice-challenge { - text-align: center; - margin-bottom: 30px; - } - - .challenge-text { - font-size: 1.8em; - color: white; - margin-bottom: 25px; - text-shadow: 0 2px 4px rgba(0,0,0,0.3); - } - - .practice-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 15px; - max-width: 800px; - margin: 0 auto; - } - - .practice-option { - background: rgba(255,255,255,0.9); - border: none; - border-radius: 15px; - padding: 20px; - font-size: 1.2em; - cursor: pointer; - transition: all 0.3s ease; - color: #333; - font-weight: 500; - } - - .practice-option:hover { - background: rgba(255,255,255,1); - transform: translateY(-3px); - box-shadow: 0 8px 20px rgba(0,0,0,0.2); - } - - .practice-option.correct { - background: #4CAF50; - color: white; - animation: correctPulse 0.6s ease; - } - - .practice-option.incorrect { - background: #F44336; - color: white; - animation: incorrectShake 0.6s ease; - } - - @keyframes correctPulse { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.05); } - } - - @keyframes incorrectShake { - 0%, 100% { transform: translateX(0); } - 25% { transform: translateX(-5px); } - 75% { transform: translateX(5px); } - } - - .practice-stats { - display: flex; - justify-content: space-around; - margin-top: 20px; - color: white; - font-size: 1.1em; - } - - .stat-item { - text-align: center; - padding: 10px; - background: rgba(255,255,255,0.1); - border-radius: 10px; - backdrop-filter: blur(5px); - } - - /* Control Buttons */ - .discovery-btn { - background: linear-gradient(45deg, #667eea, #764ba2); - color: white; - border: none; - padding: 15px 30px; - border-radius: 25px; - font-size: 1.1em; - font-weight: bold; - cursor: pointer; - transition: all 0.3s ease; - margin: 0 10px; - } - - .discovery-btn:hover { - transform: translateY(-2px); - box-shadow: 0 8px 20px rgba(0,0,0,0.3); - } - - .discovery-btn:active { - transform: translateY(0); - } - - .audio-btn { - background: none; - border: none; - font-size: 2em; - cursor: pointer; - color: #667eea; - margin-left: 15px; - transition: all 0.3s ease; - } - - .audio-btn:hover { - transform: scale(1.2); - color: #764ba2; - } - - /* Completion Message */ - .completion-message { - text-align: center; - padding: 40px; - background: rgba(255,255,255,0.1); - border-radius: 20px; - backdrop-filter: blur(10px); - color: white; - } - - .completion-title { - font-size: 2.5em; - margin-bottom: 20px; - color: #00ff88; - text-shadow: 0 2px 10px rgba(0,255,136,0.3); - } - - .completion-stats { - font-size: 1.3em; - margin-bottom: 30px; - line-height: 1.6; - } - - /* Responsive Design */ - @media (max-width: 768px) { - .letter-discovery-wrapper { - padding: 15px; - } - - .letter-display { - font-size: 6em; - } - - .word-text { - font-size: 2em; - } - - .challenge-text { - font-size: 1.5em; - } - - .practice-grid { - grid-template-columns: 1fr; - } - } - `; - document.head.appendChild(styleSheet); - } - - extractContent() { - logSh('🔍 Letter Discovery - Extracting content...', 'INFO'); - - // Check for letters in content or rawContent - const letters = this.content.letters || this.content.rawContent?.letters; - - if (letters && Object.keys(letters).length > 0) { - this.letters = Object.keys(letters).sort(); - this.letterWords = letters; - logSh(`📝 Found ${this.letters.length} letters with words`, 'INFO'); - } else { - this.showNoLettersMessage(); - return; - } - - logSh(`🎯 Letter Discovery ready: ${this.letters.length} letters`, 'INFO'); - } - - showNoLettersMessage() { - this.container.innerHTML = ` -
-
-

🔤 Letter Discovery

-

❌ No letter structure found in this content.

-

This game requires content with a predefined letters system.

-

Try with content that includes letter-based learning material.

- -
-
- `; - } - - init() { - this.container.innerHTML = ` -
-
-
-
Score: ${this.score}
-
Lives: ${this.lives}
-
-
Letter Discovery
-
-
Progress: 0/${this.letters.length}
-
-
-
-
- -
-
-
- `; - - this.updateHUD(); - } - - start() { - this.showLetterCard(); - } - - updateHUD() { - const scoreDisplay = document.getElementById('score-display'); - const livesDisplay = document.getElementById('lives-display'); - const progressDisplay = document.getElementById('progress-display'); - const phaseIndicator = document.getElementById('phase-indicator'); - - if (scoreDisplay) scoreDisplay.textContent = this.score; - if (livesDisplay) livesDisplay.textContent = this.lives; - - if (this.currentPhase === 'letter-discovery') { - if (progressDisplay) progressDisplay.textContent = `${this.currentLetterIndex}/${this.letters.length}`; - if (phaseIndicator) phaseIndicator.textContent = 'Letter Discovery'; - } else if (this.currentPhase === 'word-exploration') { - if (progressDisplay) progressDisplay.textContent = `${this.currentWordIndex}/${this.letterWords[this.currentLetter].length}`; - if (phaseIndicator) phaseIndicator.textContent = `Exploring Letter "${this.currentLetter}"`; - } else if (this.currentPhase === 'practice') { - if (progressDisplay) progressDisplay.textContent = `Round ${this.practiceRound + 1}/${this.maxPracticeRounds}`; - if (phaseIndicator) phaseIndicator.textContent = `Practice - Level ${this.practiceLevel}`; - } - } - - showLetterCard() { - if (this.currentLetterIndex >= this.letters.length) { - this.showCompletion(); - return; - } - - const letter = this.letters[this.currentLetterIndex]; - const gameContent = document.getElementById('game-content'); - - gameContent.innerHTML = ` -
-
${letter}
-
Letter "${letter}"
-
${this.getLetterPronunciation(letter)}
-
- - -
-
- `; - - // Store reference for button callbacks - window.currentLetterGame = this; - - // Auto-play letter sound - setTimeout(() => this.playLetterSound(letter), 500); - } - - getLetterPronunciation(letter) { - // Basic letter pronunciation guide - const pronunciations = { - 'A': 'ay', 'B': 'bee', 'C': 'see', 'D': 'dee', 'E': 'ee', - 'F': 'ef', 'G': 'gee', 'H': 'aych', 'I': 'eye', 'J': 'jay', - 'K': 'kay', 'L': 'el', 'M': 'em', 'N': 'en', 'O': 'oh', - 'P': 'pee', 'Q': 'cue', 'R': 'ar', 'S': 'ess', 'T': 'tee', - 'U': 'you', 'V': 'vee', 'W': 'double-you', 'X': 'ex', 'Y': 'why', 'Z': 'zee' - }; - return pronunciations[letter] || letter.toLowerCase(); - } - - playLetterSound(letter) { - if (window.SettingsManager && window.SettingsManager.speak) { - const speed = 0.8; // Slower for letters - window.SettingsManager.speak(letter, { - lang: this.content.language || 'en-US', - rate: speed - }).catch(error => { - console.warn('🔊 TTS failed for letter:', error); - }); - } - } - - discoverLetter() { - const letter = this.letters[this.currentLetterIndex]; - this.discoveredLetters.push(letter); - this.score += 10; - this.onScoreUpdate(this.score); - - // Start word exploration for this letter - this.currentLetter = letter; - this.currentPhase = 'word-exploration'; - this.currentWordIndex = 0; - - this.updateHUD(); - this.showWordExploration(); - } - - showWordExploration() { - const words = this.letterWords[this.currentLetter]; - - if (!words || this.currentWordIndex >= words.length) { - // Finished exploring words for this letter - this.currentPhase = 'letter-discovery'; - this.currentLetterIndex++; - this.updateHUD(); - this.showLetterCard(); - return; - } - - const word = words[this.currentWordIndex]; - const gameContent = document.getElementById('game-content'); - - gameContent.innerHTML = ` -
-
Letter "${this.currentLetter}"
-
Word ${this.currentWordIndex + 1} of ${words.length}
-
-
-
${word.word}
-
${word.translation}
- ${word.pronunciation ? `
[${word.pronunciation}]
` : ''} - ${word.type ? `
${word.type}
` : ''} - ${word.example ? `
"${word.example}"
` : ''} -
- - -
-
- `; - - // Add word to discovered list - this.discoveredWords.push(word); - - // Auto-play word sound - setTimeout(() => this.playWordSound(word.word), 500); - } - - playWordSound(word) { - if (window.SettingsManager && window.SettingsManager.speak) { - const speed = 0.9; - window.SettingsManager.speak(word, { - lang: this.content.language || 'en-US', - rate: speed - }).catch(error => { - console.warn('🔊 TTS failed for word:', error); - }); - } - } - - nextWord() { - this.currentWordIndex++; - this.score += 5; - this.onScoreUpdate(this.score); - this.updateHUD(); - this.showWordExploration(); - } - - showCompletion() { - const gameContent = document.getElementById('game-content'); - const totalWords = Object.values(this.letterWords).reduce((sum, words) => sum + words.length, 0); - - gameContent.innerHTML = ` -
-
🎉 All Letters Discovered!
-
- Letters Discovered: ${this.discoveredLetters.length}
- Words Learned: ${this.discoveredWords.length}
- Final Score: ${this.score} -
-
- - -
-
- `; - } - - startPractice() { - this.currentPhase = 'practice'; - this.practiceLevel = 1; - this.practiceRound = 0; - this.practiceCorrectAnswers = 0; - this.practiceErrors = 0; - - // Create mixed practice from all discovered words - this.currentPracticeItems = this.shuffleArray([...this.discoveredWords]); - - this.updateHUD(); - this.showPracticeChallenge(); - } - - showPracticeChallenge() { - if (this.practiceRound >= this.maxPracticeRounds) { - this.endPractice(); - return; - } - - const currentItem = this.currentPracticeItems[this.practiceRound % this.currentPracticeItems.length]; - const gameContent = document.getElementById('game-content'); - - // Generate options (correct + 3 random) - const allWords = this.discoveredWords.filter(w => w.word !== currentItem.word); - const randomOptions = this.shuffleArray([...allWords]).slice(0, 3); - const options = this.shuffleArray([currentItem, ...randomOptions]); - - gameContent.innerHTML = ` -
-
What does "${currentItem.word}" mean?
-
- ${options.map((option, index) => ` - - `).join('')} -
-
-
Correct: ${this.practiceCorrectAnswers}
-
Errors: ${this.practiceErrors}
-
Round: ${this.practiceRound + 1}/${this.maxPracticeRounds}
-
-
- `; - - // Store correct answer for checking - this.currentCorrectAnswer = currentItem.word; - - // Auto-play word - setTimeout(() => this.playWordSound(currentItem.word), 500); - } - - selectPracticeAnswer(selectedIndex, selectedWord) { - const buttons = document.querySelectorAll('.practice-option'); - const isCorrect = selectedWord === this.currentCorrectAnswer; - - if (isCorrect) { - buttons[selectedIndex].classList.add('correct'); - this.practiceCorrectAnswers++; - this.score += 10; - this.onScoreUpdate(this.score); - } else { - buttons[selectedIndex].classList.add('incorrect'); - this.practiceErrors++; - // Show correct answer - buttons.forEach((btn, index) => { - if (btn.textContent.trim() === this.discoveredWords.find(w => w.word === this.currentCorrectAnswer)?.translation) { - btn.classList.add('correct'); - } - }); - } - - setTimeout(() => { - this.practiceRound++; - this.updateHUD(); - this.showPracticeChallenge(); - }, 1500); - } - - endPractice() { - const accuracy = Math.round((this.practiceCorrectAnswers / this.maxPracticeRounds) * 100); - const gameContent = document.getElementById('game-content'); - - gameContent.innerHTML = ` -
-
🏆 Practice Complete!
-
- Accuracy: ${accuracy}%
- Correct Answers: ${this.practiceCorrectAnswers}/${this.maxPracticeRounds}
- Final Score: ${this.score} -
-
- -
-
- `; - - // End game - setTimeout(() => { - this.onGameEnd(this.score); - }, 3000); - } - - 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; - } - - restart() { - this.currentPhase = 'letter-discovery'; - this.currentLetterIndex = 0; - this.discoveredLetters = []; - this.currentLetter = null; - this.currentWordIndex = 0; - this.discoveredWords = []; - this.score = 0; - this.lives = 3; - this.practiceLevel = 1; - this.practiceRound = 0; - this.practiceCorrectAnswers = 0; - this.practiceErrors = 0; - this.currentPracticeItems = []; - - this.updateHUD(); - this.start(); - } - - destroy() { - // Cleanup - if (window.currentLetterGame === this) { - delete window.currentLetterGame; - } - - const styleSheet = document.getElementById('letter-discovery-styles'); - if (styleSheet) { - styleSheet.remove(); - } - } -} - -// Register the game module -window.GameModules = window.GameModules || {}; -window.GameModules.LetterDiscovery = LetterDiscovery; \ No newline at end of file diff --git a/src/games/memory-match.js b/src/games/memory-match.js deleted file mode 100644 index d3ceb40..0000000 --- a/src/games/memory-match.js +++ /dev/null @@ -1,495 +0,0 @@ -// === MODULE MEMORY MATCH === - -class MemoryMatchGame { - constructor(options) { - this.container = options.container; - this.content = options.content; - this.onScoreUpdate = options.onScoreUpdate || (() => {}); - this.onGameEnd = options.onGameEnd || (() => {}); - - // Game state - this.cards = []; - this.flippedCards = []; - this.matchedPairs = 0; - this.totalPairs = 8; // 4x4 grid = 16 cards = 8 pairs - this.moves = 0; - this.score = 0; - this.isFlipping = false; - - // Extract vocabulary - this.vocabulary = this.extractVocabulary(this.content); - - this.init(); - } - - init() { - // Check if we have enough vocabulary - if (!this.vocabulary || this.vocabulary.length < this.totalPairs) { - logSh('Not enough vocabulary for Memory Match', 'ERROR'); - this.showInitError(); - return; - } - - this.createGameInterface(); - this.generateCards(); - this.setupEventListeners(); - } - - showInitError() { - this.container.innerHTML = ` -
-

❌ Error loading

-

This content doesn't have enough vocabulary for Memory Match.

-

The game needs at least ${this.totalPairs} vocabulary pairs.

- -
- `; - } - - extractVocabulary(content) { - let vocabulary = []; - - logSh('📝 Extracting vocabulary from:', content?.name || 'content', 'INFO'); - - // Use raw module content if available - if (content.rawContent) { - logSh('📦 Using raw module content', 'INFO'); - return this.extractVocabularyFromRaw(content.rawContent); - } - - // Ultra-modular format (vocabulary object) - ONLY format supported - if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) { - logSh('✨ Ultra-modular format detected (vocabulary object)', 'INFO'); - vocabulary = Object.entries(content.vocabulary).map(([word, data]) => { - // Support ultra-modular format ONLY - if (typeof data === 'object' && data.user_language) { - return { - original: word, // Clé = original_language - translation: data.user_language.split(';')[0], // First translation - fullTranslation: data.user_language, // Complete translation - type: data.type || 'general', - audio: data.audio, - image: data.image, - examples: data.examples, - pronunciation: data.pronunciation, - category: data.type || 'general' - }; - } - // Legacy fallback - simple string (temporary, will be removed) - else if (typeof data === 'string') { - return { - original: word, - translation: data.split(';')[0], - fullTranslation: data, - type: 'general', - category: 'general' - }; - } - return null; - }).filter(Boolean); - } - // No other formats supported - ultra-modular only - - return this.finalizeVocabulary(vocabulary); - } - - extractVocabularyFromRaw(rawContent) { - logSh('🔧 Extracting from raw content:', rawContent.name || 'Module', 'INFO'); - let vocabulary = []; - - // Ultra-modular format (vocabulary object) - ONLY format supported - if (rawContent.vocabulary && typeof rawContent.vocabulary === 'object' && !Array.isArray(rawContent.vocabulary)) { - vocabulary = Object.entries(rawContent.vocabulary).map(([word, data]) => { - // Support ultra-modular format ONLY - if (typeof data === 'object' && data.user_language) { - return { - original: word, // Clé = original_language - translation: data.user_language.split(';')[0], // First translation - fullTranslation: data.user_language, // Complete translation - type: data.type || 'general', - audio: data.audio, - image: data.image, - examples: data.examples, - pronunciation: data.pronunciation, - category: data.type || 'general' - }; - } - // Legacy fallback - simple string (temporary, will be removed) - else if (typeof data === 'string') { - return { - original: word, - translation: data.split(';')[0], - fullTranslation: data, - type: 'general', - category: 'general' - }; - } - return null; - }).filter(Boolean); - logSh(`✨ ${vocabulary.length} words extracted from ultra-modular vocabulary`, 'INFO'); - } - // No other formats supported - ultra-modular only - else { - logSh('⚠️ Content format not supported - ultra-modular format required', 'WARN'); - } - - return this.finalizeVocabulary(vocabulary); - } - - finalizeVocabulary(vocabulary) { - // Filter and validate vocabulary for ultra-modular format - vocabulary = vocabulary.filter(item => - item && - typeof item.original === 'string' && - typeof item.translation === 'string' && - item.original.trim() !== '' && - item.translation.trim() !== '' - ); - - if (vocabulary.length === 0) { - logSh('❌ No valid vocabulary found', 'ERROR'); - // Demo vocabulary as fallback - vocabulary = [ - { original: "cat", translation: "chat" }, - { original: "dog", translation: "chien" }, - { original: "house", translation: "maison" }, - { original: "car", translation: "voiture" }, - { original: "book", translation: "livre" }, - { original: "water", translation: "eau" }, - { original: "food", translation: "nourriture" }, - { original: "friend", translation: "ami" } - ]; - logSh('🚨 Using demo vocabulary', 'WARN'); - } - - logSh(`✅ Memory Match: ${vocabulary.length} vocabulary items finalized`, 'INFO'); - return vocabulary; - } - - createGameInterface() { - this.container.innerHTML = ` -
- -
-
- Moves: - 0 -
-
- Pairs: - 0 / ${this.totalPairs} -
-
- Score: - 0 -
-
- - -
- -
- - -
- - -
- - - -
- `; - } - - generateCards() { - // Select random vocabulary pairs - const selectedVocab = this.vocabulary - .sort(() => Math.random() - 0.5) - .slice(0, this.totalPairs); - - // Create card pairs - this.cards = []; - selectedVocab.forEach((item, index) => { - // English card - this.cards.push({ - id: `en_${index}`, - content: item.original, - type: 'english', - pairId: index, - isFlipped: false, - isMatched: false - }); - - // French card - this.cards.push({ - id: `fr_${index}`, - content: item.translation, - type: 'french', - pairId: index, - isFlipped: false, - isMatched: false - }); - }); - - // Shuffle cards - this.cards.sort(() => Math.random() - 0.5); - - // Render cards - this.renderCards(); - } - - renderCards() { - const grid = document.getElementById('memory-grid'); - grid.innerHTML = ''; - - this.cards.forEach((card, index) => { - const cardElement = document.createElement('div'); - cardElement.className = 'memory-card'; - cardElement.dataset.cardIndex = index; - - cardElement.innerHTML = ` -
-
- 🎯 -
-
- ${card.content} -
-
- `; - - cardElement.addEventListener('click', () => this.flipCard(index)); - grid.appendChild(cardElement); - }); - } - - setupEventListeners() { - document.getElementById('restart-btn').addEventListener('click', () => this.restart()); - document.getElementById('hint-btn').addEventListener('click', () => this.showHint()); - } - - flipCard(cardIndex) { - if (this.isFlipping) return; - - const card = this.cards[cardIndex]; - if (card.isFlipped || card.isMatched) return; - - // Flip the card - card.isFlipped = true; - this.updateCardDisplay(cardIndex); - this.flippedCards.push(cardIndex); - - if (this.flippedCards.length === 2) { - this.moves++; - this.updateStats(); - this.checkMatch(); - } - } - - updateCardDisplay(cardIndex) { - const cardElement = document.querySelector(`[data-card-index="${cardIndex}"]`); - const card = this.cards[cardIndex]; - - if (card.isFlipped || card.isMatched) { - cardElement.classList.add('flipped'); - } else { - cardElement.classList.remove('flipped'); - } - - if (card.isMatched) { - cardElement.classList.add('matched'); - } - } - - checkMatch() { - this.isFlipping = true; - - setTimeout(() => { - const [firstIndex, secondIndex] = this.flippedCards; - const firstCard = this.cards[firstIndex]; - const secondCard = this.cards[secondIndex]; - - if (firstCard.pairId === secondCard.pairId) { - // Match found! - firstCard.isMatched = true; - secondCard.isMatched = true; - this.updateCardDisplay(firstIndex); - this.updateCardDisplay(secondIndex); - - this.matchedPairs++; - this.score += 100; - this.showFeedback('Great match! 🎉', 'success'); - - // Trigger success animation - this.triggerSuccessAnimation(firstIndex, secondIndex); - - if (this.matchedPairs === this.totalPairs) { - setTimeout(() => this.gameComplete(), 800); - } - } else { - // No match, flip back and apply penalty - firstCard.isFlipped = false; - secondCard.isFlipped = false; - this.updateCardDisplay(firstIndex); - this.updateCardDisplay(secondIndex); - - // Apply penalty but don't go below 0 - this.score = Math.max(0, this.score - 10); - this.showFeedback('Try again! (-10 points)', 'warning'); - } - - this.flippedCards = []; - this.isFlipping = false; - this.updateStats(); - }, 1000); - } - - showHint() { - if (this.flippedCards.length > 0) { - this.showFeedback('Finish your current move first!', 'warning'); - return; - } - - // Find first unmatched pair - const unmatchedCards = this.cards.filter(card => !card.isMatched); - if (unmatchedCards.length === 0) return; - - // Group by pairId - const pairs = {}; - unmatchedCards.forEach((card, index) => { - const actualIndex = this.cards.indexOf(card); - if (!pairs[card.pairId]) { - pairs[card.pairId] = []; - } - pairs[card.pairId].push(actualIndex); - }); - - // Find first complete pair - const completePair = Object.values(pairs).find(pair => pair.length === 2); - if (completePair) { - // Briefly show the pair - completePair.forEach(cardIndex => { - const cardElement = document.querySelector(`[data-card-index="${cardIndex}"]`); - cardElement.classList.add('hint'); - }); - - setTimeout(() => { - completePair.forEach(cardIndex => { - const cardElement = document.querySelector(`[data-card-index="${cardIndex}"]`); - cardElement.classList.remove('hint'); - }); - }, 2000); - - this.showFeedback('Hint shown for 2 seconds!', 'info'); - } - } - - updateStats() { - document.getElementById('moves-counter').textContent = this.moves; - document.getElementById('pairs-counter').textContent = `${this.matchedPairs} / ${this.totalPairs}`; - document.getElementById('score-counter').textContent = this.score; - this.onScoreUpdate(this.score); - } - - gameComplete() { - // Calculate bonus based on moves - const perfectMoves = this.totalPairs; - if (this.moves <= perfectMoves + 5) { - this.score += 200; // Efficiency bonus - } - - this.updateStats(); - this.showFeedback('🎉 Congratulations! All pairs found!', 'success'); - - setTimeout(() => { - this.onGameEnd(this.score); - }, 2000); - } - - showFeedback(message, type = 'info') { - const feedbackArea = document.getElementById('feedback-area'); - feedbackArea.innerHTML = `
${message}
`; - } - - start() { - logSh('🧠 Memory Match: Starting', 'INFO'); - this.showFeedback('Find matching English-French pairs!', 'info'); - } - - restart() { - logSh('🔄 Memory Match: Restarting', 'INFO'); - this.reset(); - this.start(); - } - - reset() { - this.flippedCards = []; - this.matchedPairs = 0; - this.moves = 0; - this.score = 0; - this.isFlipping = false; - this.generateCards(); - this.updateStats(); - } - - triggerSuccessAnimation(cardIndex1, cardIndex2) { - // Get card elements - const card1 = document.querySelector(`[data-card-index="${cardIndex1}"]`); - const card2 = document.querySelector(`[data-card-index="${cardIndex2}"]`); - - if (!card1 || !card2) return; - - // Add success animation class - card1.classList.add('success-animation'); - card2.classList.add('success-animation'); - - // Create sparkle particles for both cards - this.createSparkleParticles(card1); - this.createSparkleParticles(card2); - - // Remove animation class after animation completes - setTimeout(() => { - card1.classList.remove('success-animation'); - card2.classList.remove('success-animation'); - }, 800); - } - - createSparkleParticles(cardElement) { - const rect = cardElement.getBoundingClientRect(); - - // Create 4 sparkle particles around the card - for (let i = 1; i <= 4; i++) { - const particle = document.createElement('div'); - particle.className = `success-particle particle-${i}`; - - // Position relative to card - particle.style.position = 'fixed'; - particle.style.left = (rect.left + rect.width / 2) + 'px'; - particle.style.top = (rect.top + rect.height / 2) + 'px'; - particle.style.pointerEvents = 'none'; - particle.style.zIndex = '1000'; - - document.body.appendChild(particle); - - // Remove particle after animation - setTimeout(() => { - if (particle.parentNode) { - particle.parentNode.removeChild(particle); - } - }, 1200); - } - } - - destroy() { - this.container.innerHTML = ''; - } -} - -// Module registration -window.GameModules = window.GameModules || {}; -window.GameModules.MemoryMatch = MemoryMatchGame; \ No newline at end of file diff --git a/src/games/quiz-game.js b/src/games/quiz-game.js deleted file mode 100644 index 7d59be5..0000000 --- a/src/games/quiz-game.js +++ /dev/null @@ -1,529 +0,0 @@ -// === MODULE QUIZ GAME === - -class QuizGame { - constructor(options) { - this.container = options.container; - this.content = options.content; - this.onScoreUpdate = options.onScoreUpdate || (() => {}); - this.onGameEnd = options.onGameEnd || (() => {}); - - // Game state - this.vocabulary = []; - this.currentQuestion = 0; - this.totalQuestions = 10; - this.score = 0; - this.correctAnswers = 0; - this.currentQuestionData = null; - this.hasAnswered = false; - this.quizDirection = 'original_to_translation'; // 'original_to_translation' or 'translation_to_original' - - // Extract vocabulary and additional words from texts/stories - this.vocabulary = this.extractVocabulary(this.content); - this.allWords = this.extractAllWords(this.content); - - this.init(); - } - - init() { - // Check if we have enough vocabulary - if (!this.vocabulary || this.vocabulary.length < 6) { - logSh('Not enough vocabulary for Quiz Game', 'ERROR'); - this.showInitError(); - return; - } - - // Adjust total questions based on available vocabulary - this.totalQuestions = Math.min(this.totalQuestions, this.vocabulary.length); - - this.createGameInterface(); - this.generateQuestion(); - } - - showInitError() { - this.container.innerHTML = ` -
-

❌ Error loading

-

This content doesn't have enough vocabulary for Quiz Game.

-

The game needs at least 6 vocabulary items.

- -
- `; - } - - extractVocabulary(content) { - let vocabulary = []; - - logSh('🔍 Extracting vocabulary from:', content?.name || 'content', 'INFO'); - - // Priority 1: Use raw module content (simple format) - if (content.rawContent) { - logSh('📦 Using raw module content', 'INFO'); - return this.extractVocabularyFromRaw(content.rawContent); - } - - // Priority 2: Ultra-modular format (vocabulary object) - ONLY format supported - if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) { - logSh('✨ Ultra-modular format detected (vocabulary object)', 'INFO'); - vocabulary = Object.entries(content.vocabulary).map(([word, data]) => { - // Support ultra-modular format ONLY - if (typeof data === 'object' && data.user_language) { - return { - original: word, // Clé = original_language - translation: data.user_language.split(';')[0], // First translation - fullTranslation: data.user_language, // Complete translation - type: data.type || 'general', - audio: data.audio, - image: data.image, - examples: data.examples, - pronunciation: data.pronunciation, - category: data.type || 'general' - }; - } - // Legacy fallback - simple string (temporary, will be removed) - else if (typeof data === 'string') { - return { - original: word, - translation: data.split(';')[0], - fullTranslation: data, - type: 'general', - category: 'general' - }; - } - return null; - }).filter(Boolean); - } - // No other formats supported - ultra-modular only - - return this.finalizeVocabulary(vocabulary); - } - - extractVocabularyFromRaw(rawContent) { - logSh('🔧 Extracting from raw content:', rawContent.name || 'Module', 'INFO'); - let vocabulary = []; - - // Ultra-modular format (vocabulary object) - ONLY format supported - if (rawContent.vocabulary && typeof rawContent.vocabulary === 'object' && !Array.isArray(rawContent.vocabulary)) { - vocabulary = Object.entries(rawContent.vocabulary).map(([word, data]) => { - // Support ultra-modular format ONLY - if (typeof data === 'object' && data.user_language) { - return { - original: word, // Clé = original_language - translation: data.user_language.split(';')[0], // First translation - fullTranslation: data.user_language, // Complete translation - type: data.type || 'general', - audio: data.audio, - image: data.image, - examples: data.examples, - pronunciation: data.pronunciation, - category: data.type || 'general' - }; - } - // Legacy fallback - simple string (temporary, will be removed) - else if (typeof data === 'string') { - return { - original: word, - translation: data.split(';')[0], - fullTranslation: data, - type: 'general', - category: 'general' - }; - } - return null; - }).filter(Boolean); - logSh(`✨ ${vocabulary.length} words extracted from ultra-modular vocabulary`, 'INFO'); - } - // No other formats supported - ultra-modular only - else { - logSh('⚠️ Content format not supported - ultra-modular format required', 'WARN'); - } - - return this.finalizeVocabulary(vocabulary); - } - - finalizeVocabulary(vocabulary) { - // Validation and cleanup for ultra-modular format - vocabulary = vocabulary.filter(word => - word && - typeof word.original === 'string' && - typeof word.translation === 'string' && - word.original.trim() !== '' && - word.translation.trim() !== '' - ); - - if (vocabulary.length === 0) { - logSh('❌ No valid vocabulary found', 'ERROR'); - // Demo vocabulary as last resort - vocabulary = [ - { original: 'hello', translation: 'bonjour', category: 'greetings' }, - { original: 'goodbye', translation: 'au revoir', category: 'greetings' }, - { original: 'thank you', translation: 'merci', category: 'greetings' }, - { original: 'cat', translation: 'chat', category: 'animals' }, - { original: 'dog', translation: 'chien', category: 'animals' }, - { original: 'house', translation: 'maison', category: 'objects' }, - { original: 'car', translation: 'voiture', category: 'objects' }, - { original: 'book', translation: 'livre', category: 'objects' } - ]; - logSh('🚨 Using demo vocabulary', 'WARN'); - } - - // Shuffle vocabulary for random questions - vocabulary = this.shuffleArray(vocabulary); - - logSh(`✅ Quiz Game: ${vocabulary.length} vocabulary words finalized`, 'INFO'); - return vocabulary; - } - - extractAllWords(content) { - let allWords = []; - - // Add vocabulary words first - allWords = [...this.vocabulary]; - - // Extract from stories/texts - if (content.rawContent?.story?.chapters) { - content.rawContent.story.chapters.forEach(chapter => { - if (chapter.sentences) { - chapter.sentences.forEach(sentence => { - if (sentence.words && Array.isArray(sentence.words)) { - sentence.words.forEach(wordObj => { - if (wordObj.word && wordObj.translation) { - allWords.push({ - original: wordObj.word, - translation: wordObj.translation, - type: wordObj.type || 'word', - pronunciation: wordObj.pronunciation - }); - } - }); - } - }); - } - }); - } - - // Extract from additional stories (like WTA1B1) - if (content.rawContent?.additionalStories) { - content.rawContent.additionalStories.forEach(story => { - if (story.chapters) { - story.chapters.forEach(chapter => { - if (chapter.sentences) { - chapter.sentences.forEach(sentence => { - if (sentence.words && Array.isArray(sentence.words)) { - sentence.words.forEach(wordObj => { - if (wordObj.word && wordObj.translation) { - allWords.push({ - original: wordObj.word, - translation: wordObj.translation, - type: wordObj.type || 'word', - pronunciation: wordObj.pronunciation - }); - } - }); - } - }); - } - }); - } - }); - } - - // Remove duplicates based on original word - const uniqueWords = []; - const seenWords = new Set(); - - allWords.forEach(word => { - const key = word.original.toLowerCase(); - if (!seenWords.has(key)) { - seenWords.add(key); - uniqueWords.push(word); - } - }); - - logSh(`📚 Extracted ${uniqueWords.length} total words for quiz options`, 'INFO'); - return uniqueWords; - } - - 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; - } - - createGameInterface() { - this.container.innerHTML = ` -
- -
- -
- - -
-
-
-
-
- 1 / ${this.totalQuestions} - Score: 0 -
-
- - -
-
- Loading question... -
-
- - -
- -
- - -
- -
- - - -
- `; - - // Add CSS for top controls - const style = document.createElement('style'); - style.textContent = ` - .quiz-top-controls { - position: absolute; - top: 10px; - left: 10px; - z-index: 10; - } - - .restart-top { - background: rgba(255, 255, 255, 0.9) !important; - border: 2px solid #ccc !important; - color: #666 !important; - font-size: 12px !important; - padding: 8px 12px !important; - border-radius: 6px !important; - box-shadow: 0 2px 4px rgba(0,0,0,0.1) !important; - } - - .restart-top:hover { - background: rgba(255, 255, 255, 1) !important; - border-color: #999 !important; - color: #333 !important; - } - `; - document.head.appendChild(style); - - this.setupEventListeners(); - } - - setupEventListeners() { - document.getElementById('next-btn').addEventListener('click', () => this.nextQuestion()); - document.getElementById('restart-btn').addEventListener('click', () => this.restart()); - } - - generateQuestion() { - if (this.currentQuestion >= this.totalQuestions) { - this.gameComplete(); - return; - } - - this.hasAnswered = false; - - // Get current vocabulary item - const correctAnswer = this.vocabulary[this.currentQuestion]; - - // Randomly choose quiz direction - this.quizDirection = Math.random() < 0.5 ? 'original_to_translation' : 'translation_to_original'; - - let questionText, correctAnswerText, sourceForWrongAnswers; - - if (this.quizDirection === 'original_to_translation') { - questionText = correctAnswer.original; - correctAnswerText = correctAnswer.translation; - sourceForWrongAnswers = 'translation'; - } else { - questionText = correctAnswer.translation; - correctAnswerText = correctAnswer.original; - sourceForWrongAnswers = 'original'; - } - - // Generate 5 wrong answers from allWords (which includes story words) - const availableWords = this.allWords.length >= 6 ? this.allWords : this.vocabulary; - const wrongAnswers = availableWords - .filter(item => item !== correctAnswer) - .sort(() => Math.random() - 0.5) - .slice(0, 5) - .map(item => sourceForWrongAnswers === 'translation' ? item.translation : item.original); - - // Combine and shuffle all options (1 correct + 5 wrong = 6 total) - const allOptions = [correctAnswerText, ...wrongAnswers].sort(() => Math.random() - 0.5); - - this.currentQuestionData = { - question: questionText, - correctAnswer: correctAnswerText, - options: allOptions, - direction: this.quizDirection - }; - - this.renderQuestion(); - this.updateProgress(); - } - - renderQuestion() { - const { question, options } = this.currentQuestionData; - - // Update question text with direction indicator - const direction = this.currentQuestionData.direction; - const directionText = direction === 'original_to_translation' ? - 'What is the translation of' : 'What is the original word for'; - - document.getElementById('question-text').innerHTML = ` - ${directionText} "${question}"? - `; - - // Clear and generate options - const optionsArea = document.getElementById('options-area'); - optionsArea.innerHTML = ''; - - options.forEach((option, index) => { - const optionButton = document.createElement('button'); - optionButton.className = 'quiz-option'; - optionButton.textContent = option; - optionButton.addEventListener('click', () => this.selectAnswer(option, optionButton)); - optionsArea.appendChild(optionButton); - }); - - // Hide next button - document.getElementById('next-btn').style.display = 'none'; - } - - selectAnswer(selectedAnswer, buttonElement) { - if (this.hasAnswered) return; - - this.hasAnswered = true; - const isCorrect = selectedAnswer === this.currentQuestionData.correctAnswer; - - // Disable all option buttons and show results - const allOptions = document.querySelectorAll('.quiz-option'); - allOptions.forEach(btn => { - btn.disabled = true; - - if (btn.textContent === this.currentQuestionData.correctAnswer) { - btn.classList.add('correct'); - } else if (btn === buttonElement && !isCorrect) { - btn.classList.add('wrong'); - } else if (btn !== buttonElement && btn.textContent !== this.currentQuestionData.correctAnswer) { - btn.classList.add('disabled'); - } - }); - - // Update score and feedback - if (isCorrect) { - this.correctAnswers++; - this.score += 10; - this.showFeedback('✅ Correct! Well done!', 'success'); - } else { - this.score = Math.max(0, this.score - 5); - this.showFeedback(`❌ Wrong! Correct answer: "${this.currentQuestionData.correctAnswer}"`, 'error'); - } - - this.updateScore(); - - // Show next button or finish - if (this.currentQuestion < this.totalQuestions - 1) { - document.getElementById('next-btn').style.display = 'block'; - } else { - setTimeout(() => this.gameComplete(), 250); - } - } - - nextQuestion() { - this.currentQuestion++; - this.generateQuestion(); - } - - updateProgress() { - const progressFill = document.getElementById('progress-fill'); - const progressPercent = ((this.currentQuestion + 1) / this.totalQuestions) * 100; - progressFill.style.width = `${progressPercent}%`; - - document.getElementById('question-counter').textContent = - `${this.currentQuestion + 1} / ${this.totalQuestions}`; - } - - updateScore() { - document.getElementById('score-display').textContent = `Score: ${this.score}`; - this.onScoreUpdate(this.score); - } - - gameComplete() { - const accuracy = Math.round((this.correctAnswers / this.totalQuestions) * 100); - - // Bonus for high accuracy - if (accuracy >= 90) { - this.score += 50; // Excellence bonus - } else if (accuracy >= 70) { - this.score += 20; // Good performance bonus - } - - this.updateScore(); - this.showFeedback( - `🎉 Quiz completed! ${this.correctAnswers}/${this.totalQuestions} correct (${accuracy}%)`, - 'success' - ); - - setTimeout(() => { - this.onGameEnd(this.score); - }, 3000); - } - - showFeedback(message, type = 'info') { - const feedbackArea = document.getElementById('feedback-area'); - feedbackArea.innerHTML = `
${message}
`; - } - - start() { - logSh('❓ Quiz Game: Starting', 'INFO'); - this.showFeedback('Choose the correct translation for each word!', 'info'); - } - - restart() { - logSh('🔄 Quiz Game: Restarting', 'INFO'); - this.reset(); - this.start(); - } - - reset() { - this.currentQuestion = 0; - this.score = 0; - this.correctAnswers = 0; - this.hasAnswered = false; - this.currentQuestionData = null; - - // Re-shuffle vocabulary - this.vocabulary = this.shuffleArray(this.vocabulary); - - this.generateQuestion(); - this.updateScore(); - } - - destroy() { - this.container.innerHTML = ''; - } -} - -// Module registration -window.GameModules = window.GameModules || {}; -window.GameModules.QuizGame = QuizGame; \ No newline at end of file diff --git a/src/games/river-run.js b/src/games/river-run.js deleted file mode 100644 index aef4f7c..0000000 --- a/src/games/river-run.js +++ /dev/null @@ -1,1001 +0,0 @@ -// === RIVER RUN GAME === -// Endless runner on a river with floating words - avoid obstacles, catch target words! - -class RiverRun { - constructor({ container, content, onScoreUpdate, onGameEnd }) { - this.container = container; - this.content = content; - this.onScoreUpdate = onScoreUpdate; - this.onGameEnd = onGameEnd; - - // Game state - this.isRunning = false; - this.score = 0; - this.lives = 3; - this.level = 1; - this.speed = 2; // River flow speed - this.wordsCollected = 0; - - // Player - this.player = { - x: 50, // Percentage from left - y: 80, // Percentage from top - targetX: 50, - targetY: 80, - size: 40 - }; - - // Game objects - this.floatingWords = []; - this.currentTarget = null; - this.targetQueue = []; - this.powerUps = []; - - // River animation - this.riverOffset = 0; - this.particles = []; - - // Timing - this.lastSpawn = 0; - this.spawnInterval = 1000; // ms between word spawns (2x faster) - this.gameStartTime = Date.now(); - - // Word management - this.availableWords = []; - this.usedTargets = []; - - // Target word guarantee system - this.wordsSpawnedSinceTarget = 0; - this.maxWordsBeforeTarget = 10; // Guarantee target within 10 words - - this.injectCSS(); - this.extractContent(); - this.init(); - } - - injectCSS() { - if (document.getElementById('river-run-styles')) return; - - const styleSheet = document.createElement('style'); - styleSheet.id = 'river-run-styles'; - styleSheet.textContent = ` - .river-run-wrapper { - background: linear-gradient(180deg, #87CEEB 0%, #4682B4 50%, #2F4F4F 100%); - position: relative; - overflow: hidden; - height: 100vh; - cursor: crosshair; - } - - .river-run-hud { - position: absolute; - top: 20px; - left: 20px; - right: 20px; - display: flex; - justify-content: space-between; - z-index: 100; - color: white; - font-weight: bold; - text-shadow: 0 2px 4px rgba(0,0,0,0.5); - } - - .hud-left, .hud-right { - display: flex; - gap: 20px; - align-items: center; - } - - .target-display { - background: rgba(255,255,255,0.9); - color: #333; - padding: 10px 20px; - border-radius: 25px; - font-size: 1.2em; - font-weight: bold; - box-shadow: 0 4px 15px rgba(0,0,0,0.2); - animation: targetGlow 2s ease-in-out infinite alternate; - } - - @keyframes targetGlow { - from { box-shadow: 0 4px 15px rgba(0,0,0,0.2); } - to { box-shadow: 0 4px 20px rgba(255,215,0,0.6); } - } - - .river-canvas { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: - radial-gradient(ellipse at center top, rgba(135,206,235,0.3) 0%, transparent 70%), - linear-gradient(0deg, - rgba(70,130,180,0.1) 0%, - rgba(135,206,235,0.05) 50%, - rgba(173,216,230,0.1) 100% - ); - } - - .river-waves { - position: absolute; - width: 120%; - height: 100%; - background: - repeating-linear-gradient( - 0deg, - transparent 0px, - rgba(255,255,255,0.1) 2px, - transparent 4px, - transparent 20px - ); - animation: riverFlow 3s linear infinite; - } - - @keyframes riverFlow { - from { transform: translateY(-20px); } - to { transform: translateY(0px); } - } - - .player { - position: absolute; - width: 40px; - height: 40px; - background: linear-gradient(45deg, #8B4513, #A0522D); - border-radius: 50% 50% 50% 50% / 60% 60% 40% 40%; - box-shadow: - 0 2px 10px rgba(0,0,0,0.3), - inset 0 2px 5px rgba(255,255,255,0.3); - transition: all 0.3s ease-out; - z-index: 50; - transform-origin: center; - } - - .player::before { - content: '🛶'; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - font-size: 20px; - filter: drop-shadow(0 1px 2px rgba(0,0,0,0.3)); - } - - .player.moving { - animation: playerRipple 0.5s ease-out; - } - - @keyframes playerRipple { - 0% { transform: scale(1); } - 50% { transform: scale(1.1); } - 100% { transform: scale(1); } - } - - .floating-word { - position: absolute; - background: rgba(255,255,255,0.95); - border: 3px solid #4682B4; - border-radius: 15px; - padding: 8px 15px; - font-size: 1.1em; - font-weight: bold; - color: #333; - cursor: pointer; - transition: all 0.2s ease; - z-index: 40; - box-shadow: - 0 4px 15px rgba(0,0,0,0.2), - 0 0 0 0 rgba(70,130,180,0.4); - animation: wordFloat 3s ease-in-out infinite alternate; - } - - @keyframes wordFloat { - from { transform: translateY(0px) rotate(-1deg); } - to { transform: translateY(-5px) rotate(1deg); } - } - - .floating-word:hover { - transform: scale(1.1) translateY(-3px); - box-shadow: - 0 6px 20px rgba(0,0,0,0.3), - 0 0 20px rgba(70,130,180,0.6); - } - - /* Words are neutral at spawn - styling happens at interaction */ - - .floating-word.collected { - animation: wordCollected 0.8s ease-out forwards; - } - - @keyframes wordCollected { - 0% { - transform: scale(1); - opacity: 1; - } - 50% { - transform: scale(1.3); - opacity: 0.8; - } - 100% { - transform: scale(0) translateY(-50px); - opacity: 0; - } - } - - .floating-word.missed { - animation: wordMissed 0.6s ease-out forwards; - } - - @keyframes wordMissed { - 0% { - transform: scale(1); - opacity: 1; - background: rgba(255,255,255,0.95); - } - 100% { - transform: scale(0.8); - opacity: 0; - background: rgba(220,20,60,0.8); - } - } - - .power-up { - position: absolute; - width: 35px; - height: 35px; - border-radius: 50%; - background: linear-gradient(45deg, #FF6B35, #F7931E); - display: flex; - align-items: center; - justify-content: center; - font-size: 1.2em; - cursor: pointer; - z-index: 45; - animation: powerUpFloat 2s ease-in-out infinite alternate; - box-shadow: 0 4px 15px rgba(255,107,53,0.4); - } - - @keyframes powerUpFloat { - from { transform: translateY(0px) scale(1); } - to { transform: translateY(-8px) scale(1.05); } - } - - .game-over-modal { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background: rgba(255,255,255,0.95); - padding: 40px; - border-radius: 20px; - text-align: center; - z-index: 200; - box-shadow: 0 10px 30px rgba(0,0,0,0.3); - backdrop-filter: blur(10px); - } - - .game-over-title { - font-size: 2.5em; - margin-bottom: 20px; - color: #4682B4; - text-shadow: 0 2px 4px rgba(0,0,0,0.1); - } - - .game-over-stats { - font-size: 1.3em; - margin-bottom: 30px; - line-height: 1.6; - color: #333; - } - - .river-btn { - background: linear-gradient(45deg, #4682B4, #5F9EA0); - color: white; - border: none; - padding: 15px 30px; - border-radius: 25px; - font-size: 1.1em; - font-weight: bold; - cursor: pointer; - margin: 0 10px; - transition: all 0.3s ease; - box-shadow: 0 4px 15px rgba(70,130,180,0.3); - } - - .river-btn:hover { - transform: translateY(-2px); - box-shadow: 0 6px 20px rgba(70,130,180,0.4); - } - - .particle { - position: absolute; - width: 4px; - height: 4px; - background: rgba(255,255,255,0.7); - border-radius: 50%; - pointer-events: none; - z-index: 30; - } - - .level-indicator { - position: absolute; - top: 70px; - left: 20px; - background: rgba(255,255,255,0.9); - color: #333; - padding: 5px 15px; - border-radius: 15px; - font-size: 0.9em; - font-weight: bold; - z-index: 100; - } - - /* Responsive */ - @media (max-width: 768px) { - .river-run-hud { - flex-direction: column; - gap: 10px; - } - - .floating-word { - font-size: 1em; - padding: 6px 12px; - } - - .target-display { - font-size: 1em; - padding: 8px 15px; - } - } - `; - document.head.appendChild(styleSheet); - } - - extractContent() { - logSh('🌊 River Run - Extracting vocabulary...', 'INFO'); - - // Extract words from various content formats - if (this.content.vocabulary) { - Object.keys(this.content.vocabulary).forEach(word => { - const wordData = this.content.vocabulary[word]; - this.availableWords.push({ - french: word, - english: typeof wordData === 'string' ? wordData : - wordData.translation || wordData.user_language || 'unknown', - pronunciation: wordData.pronunciation || wordData.prononciation - }); - }); - } - - // Fallback: extract from letter structure if available - if (this.content.letters && this.availableWords.length === 0) { - Object.values(this.content.letters).forEach(letterWords => { - letterWords.forEach(wordData => { - this.availableWords.push({ - french: wordData.word, - english: wordData.translation, - pronunciation: wordData.pronunciation - }); - }); - }); - } - - if (this.availableWords.length === 0) { - throw new Error('No vocabulary found for River Run'); - } - - logSh(`🎯 River Run ready: ${this.availableWords.length} words available`, 'INFO'); - this.generateTargetQueue(); - } - - generateTargetQueue() { - // Create queue of targets, ensuring variety - this.targetQueue = this.shuffleArray([...this.availableWords]).slice(0, Math.min(10, this.availableWords.length)); - this.usedTargets = []; - } - - init() { - this.container.innerHTML = ` -
-
-
-
Score: ${this.score}
-
Lives: ${this.lives}
-
Words: ${this.wordsCollected}
-
-
- Click to Start! -
-
-
Level: ${this.level}
-
Speed: ${this.speed.toFixed(1)}x
-
-
- -
-
-
-
-
- `; - - this.setupEventListeners(); - this.updateHUD(); - } - - setupEventListeners() { - const riverGame = document.getElementById('river-game'); - - riverGame.addEventListener('click', (e) => { - if (!this.isRunning) { - this.start(); - return; - } - - const rect = riverGame.getBoundingClientRect(); - const clickX = ((e.clientX - rect.left) / rect.width) * 100; - const clickY = ((e.clientY - rect.top) / rect.height) * 100; - - this.movePlayer(clickX, clickY); - }); - - // Handle floating word clicks - riverGame.addEventListener('click', (e) => { - if (e.target.classList.contains('floating-word')) { - e.stopPropagation(); - this.handleWordClick(e.target); - } - }); - } - - start() { - if (this.isRunning) return; - - this.isRunning = true; - this.gameStartTime = Date.now(); - this.setNextTarget(); - - // Start game loop - this.gameLoop(); - - logSh('🌊 River Run started!', 'INFO'); - } - - gameLoop() { - if (!this.isRunning) return; - - const now = Date.now(); - - // Spawn new words - if (now - this.lastSpawn > this.spawnInterval) { - this.spawnFloatingWord(); - this.lastSpawn = now; - } - - // Update game objects - this.updateFloatingWords(); - this.updatePlayer(); - this.updateParticles(); - this.checkCollisions(); - - // Increase difficulty over time - this.updateDifficulty(); - - // Update UI - this.updateHUD(); - - // Continue loop - requestAnimationFrame(() => this.gameLoop()); - } - - setNextTarget() { - if (this.targetQueue.length === 0) { - this.generateTargetQueue(); - } - - this.currentTarget = this.targetQueue.shift(); - this.usedTargets.push(this.currentTarget); - - // Reset the word counter for new target - this.wordsSpawnedSinceTarget = 0; - - const targetDisplay = document.getElementById('target-display'); - if (targetDisplay) { - targetDisplay.innerHTML = `Find: ${this.currentTarget.english}`; - } - } - - spawnFloatingWord() { - const riverCanvas = document.getElementById('river-canvas'); - if (!riverCanvas) return; - - // Determine if we should force the target word - let word; - if (this.wordsSpawnedSinceTarget >= this.maxWordsBeforeTarget) { - // Force target word to appear - word = this.currentTarget; - this.wordsSpawnedSinceTarget = 0; // Reset counter - logSh(`🎯 Forcing target word: ${word.french}`, 'DEBUG'); - } else { - // Spawn random word - word = this.getRandomWord(); - this.wordsSpawnedSinceTarget++; - } - - const wordElement = document.createElement('div'); - wordElement.className = 'floating-word'; // No target/obstacle class at spawn - - // Add spaces based on level for increased difficulty - const spacePadding = ' '.repeat(this.level * 2); // 2 spaces per level on each side - wordElement.textContent = spacePadding + word.french + spacePadding; - - wordElement.style.left = `${Math.random() * 80 + 10}%`; - wordElement.style.top = '-60px'; - - // Store word data only - wordElement.wordData = word; - - riverCanvas.appendChild(wordElement); - this.floatingWords.push({ - element: wordElement, - y: -60, - x: parseFloat(wordElement.style.left), - wordData: word - }); - - // Occasional power-up spawn - if (Math.random() < 0.1) { - this.spawnPowerUp(); - } - } - - getRandomWord() { - // Simply return any random word from available vocabulary - return this.availableWords[Math.floor(Math.random() * this.availableWords.length)]; - } - - spawnPowerUp() { - const riverCanvas = document.getElementById('river-canvas'); - if (!riverCanvas) return; - - const powerUpElement = document.createElement('div'); - powerUpElement.className = 'power-up'; - powerUpElement.innerHTML = '⚡'; - powerUpElement.style.left = `${Math.random() * 80 + 10}%`; - powerUpElement.style.top = '-40px'; - - riverCanvas.appendChild(powerUpElement); - this.powerUps.push({ - element: powerUpElement, - y: -40, - x: parseFloat(powerUpElement.style.left), - type: 'slowTime' - }); - } - - updateFloatingWords() { - this.floatingWords = this.floatingWords.filter(word => { - word.y += this.speed; - word.element.style.top = `${word.y}px`; - - // Remove words that went off screen - if (word.y > window.innerHeight + 60) { - // CHECK AT EXIT TIME: Was this the target word? - if (word.wordData.french === this.currentTarget.french) { - // Missed target word - lose life - this.loseLife(); - } - word.element.remove(); - return false; - } - - return true; - }); - - // Update power-ups - this.powerUps = this.powerUps.filter(powerUp => { - powerUp.y += this.speed; - powerUp.element.style.top = `${powerUp.y}px`; - - if (powerUp.y > window.innerHeight + 40) { - powerUp.element.remove(); - return false; - } - - return true; - }); - } - - movePlayer(targetX, targetY) { - this.player.targetX = Math.max(5, Math.min(95, targetX)); - this.player.targetY = Math.max(10, Math.min(90, targetY)); - - const playerElement = document.getElementById('player'); - if (playerElement) { - playerElement.classList.add('moving'); - setTimeout(() => { - playerElement.classList.remove('moving'); - }, 500); - } - - // Create ripple effect - this.createRippleEffect(targetX, targetY); - } - - updatePlayer() { - // Smooth movement towards target - const speed = 0.1; - this.player.x += (this.player.targetX - this.player.x) * speed; - this.player.y += (this.player.targetY - this.player.y) * speed; - - const playerElement = document.getElementById('player'); - if (playerElement) { - playerElement.style.left = `calc(${this.player.x}% - 20px)`; - playerElement.style.top = `calc(${this.player.y}% - 20px)`; - } - } - - createRippleEffect(x, y) { - for (let i = 0; i < 5; i++) { - setTimeout(() => { - const particle = document.createElement('div'); - particle.className = 'particle'; - particle.style.left = `${x}%`; - particle.style.top = `${y}%`; - particle.style.animation = `particleSpread 1s ease-out forwards`; - - const riverCanvas = document.getElementById('river-canvas'); - if (riverCanvas) { - riverCanvas.appendChild(particle); - - setTimeout(() => { - particle.remove(); - }, 1000); - } - }, i * 100); - } - } - - updateParticles() { - // Create water particles occasionally - if (Math.random() < 0.1) { - const particle = document.createElement('div'); - particle.className = 'particle'; - particle.style.left = `${Math.random() * 100}%`; - particle.style.top = '-5px'; - particle.style.animation = `particleFlow 3s linear forwards`; - - const riverCanvas = document.getElementById('river-canvas'); - if (riverCanvas) { - riverCanvas.appendChild(particle); - - setTimeout(() => { - particle.remove(); - }, 3000); - } - } - } - - checkCollisions() { - const playerRect = this.getPlayerRect(); - - // Check word collisions - this.floatingWords.forEach((word, index) => { - const wordRect = this.getElementRect(word.element); - - if (this.isColliding(playerRect, wordRect)) { - this.handleWordCollision(word, index); - } - }); - - // Check power-up collisions - this.powerUps.forEach((powerUp, index) => { - const powerUpRect = this.getElementRect(powerUp.element); - - if (this.isColliding(playerRect, powerUpRect)) { - this.handlePowerUpCollision(powerUp, index); - } - }); - } - - getPlayerRect() { - const playerElement = document.getElementById('player'); - if (!playerElement) return { x: 0, y: 0, width: 0, height: 0 }; - - const rect = playerElement.getBoundingClientRect(); - const canvas = document.getElementById('river-canvas').getBoundingClientRect(); - - return { - x: rect.left - canvas.left, - y: rect.top - canvas.top, - width: rect.width, - height: rect.height - }; - } - - getElementRect(element) { - const rect = element.getBoundingClientRect(); - const canvas = document.getElementById('river-canvas').getBoundingClientRect(); - - return { - x: rect.left - canvas.left, - y: rect.top - canvas.top, - width: rect.width, - height: rect.height - }; - } - - isColliding(rect1, rect2) { - return rect1.x < rect2.x + rect2.width && - rect1.x + rect1.width > rect2.x && - rect1.y < rect2.y + rect2.height && - rect1.y + rect1.height > rect2.y; - } - - handleWordClick(wordElement) { - const wordData = wordElement.wordData; - - // CHECK AT PICK TIME: Is this the target word? - if (wordData.french === this.currentTarget.french) { - // Correct target word clicked - this.collectWord(wordElement, true); - } else { - // Wrong word clicked - it's an obstacle - this.missWord(wordElement); - } - } - - handleWordCollision(word, index) { - // CHECK AT COLLISION TIME: Is this the target word? - if (word.wordData.french === this.currentTarget.french) { - this.collectWord(word.element, true); - } else { - // Collision with non-target word = obstacle hit - this.missWord(word.element); - } - - // Remove from array - this.floatingWords.splice(index, 1); - } - - collectWord(wordElement, isCorrect) { - wordElement.classList.add('collected'); - - if (isCorrect) { - this.score += 10 + (this.level * 2); - this.wordsCollected++; - this.onScoreUpdate(this.score); - - // Set next target - this.setNextTarget(); - - // Play success sound - this.playSuccessSound(wordElement.textContent); - } - - setTimeout(() => { - wordElement.remove(); - }, 800); - } - - missWord(wordElement) { - wordElement.classList.add('missed'); - this.loseLife(); - - setTimeout(() => { - wordElement.remove(); - }, 600); - } - - handlePowerUpCollision(powerUp, index) { - this.activatePowerUp(powerUp.type); - powerUp.element.remove(); - this.powerUps.splice(index, 1); - } - - activatePowerUp(type) { - switch (type) { - case 'slowTime': - this.speed *= 0.5; - setTimeout(() => { - this.speed *= 2; - }, 3000); - break; - } - } - - updateDifficulty() { - const timeElapsed = Date.now() - this.gameStartTime; - const newLevel = Math.floor(timeElapsed / 30000) + 1; // Level up every 30 seconds - - if (newLevel > this.level) { - this.level = newLevel; - this.speed += 0.5; - this.spawnInterval = Math.max(500, this.spawnInterval - 100); // More aggressive spawn increase - } - } - - playSuccessSound(word) { - if (window.SettingsManager && window.SettingsManager.speak) { - window.SettingsManager.speak(word, { - lang: this.content.language || 'fr-FR', - rate: 1.0 - }).catch(error => { - console.warn('🔊 TTS failed:', error); - }); - } - } - - loseLife() { - this.lives--; - - if (this.lives <= 0) { - this.gameOver(); - } - } - - gameOver() { - this.isRunning = false; - - const riverGame = document.getElementById('river-game'); - const accuracy = this.wordsCollected > 0 ? Math.round((this.wordsCollected / (this.wordsCollected + (3 - this.lives))) * 100) : 0; - - const gameOverModal = document.createElement('div'); - gameOverModal.className = 'game-over-modal'; - gameOverModal.innerHTML = ` -
🌊 River Complete!
-
- Final Score: ${this.score}
- Words Collected: ${this.wordsCollected}
- Level Reached: ${this.level}
- Accuracy: ${accuracy}% -
-
- - -
- `; - - riverGame.appendChild(gameOverModal); - - // Store reference for button callbacks - window.currentRiverGame = this; - - setTimeout(() => { - this.onGameEnd(this.score); - }, 5000); - } - - updateHUD() { - const scoreDisplay = document.getElementById('score-display'); - const livesDisplay = document.getElementById('lives-display'); - const wordsDisplay = document.getElementById('words-display'); - const levelDisplay = document.getElementById('level-display'); - const speedDisplay = document.getElementById('speed-display'); - - if (scoreDisplay) scoreDisplay.textContent = this.score; - if (livesDisplay) livesDisplay.textContent = this.lives; - if (wordsDisplay) wordsDisplay.textContent = this.wordsCollected; - if (levelDisplay) levelDisplay.textContent = this.level; - if (speedDisplay) speedDisplay.textContent = this.speed.toFixed(1) + 'x'; - } - - 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; - } - - restart() { - // Reset game state - this.isRunning = false; - this.score = 0; - this.lives = 3; - this.level = 1; - this.speed = 2; - this.wordsCollected = 0; - this.riverOffset = 0; - - // Reset player position - this.player.x = 50; - this.player.y = 80; - this.player.targetX = 50; - this.player.targetY = 80; - - // Clear game objects - this.floatingWords = []; - this.powerUps = []; - this.particles = []; - - // Reset timing - this.lastSpawn = 0; - this.spawnInterval = 1000; // 2x faster spawn rate - this.gameStartTime = Date.now(); - - // Reset targets and word counter - this.wordsSpawnedSinceTarget = 0; - this.generateTargetQueue(); - - // Cleanup DOM - const riverCanvas = document.getElementById('river-canvas'); - if (riverCanvas) { - const words = riverCanvas.querySelectorAll('.floating-word'); - const powerUps = riverCanvas.querySelectorAll('.power-up'); - const particles = riverCanvas.querySelectorAll('.particle'); - - words.forEach(word => word.remove()); - powerUps.forEach(powerUp => powerUp.remove()); - particles.forEach(particle => particle.remove()); - } - - const gameOverModal = document.querySelector('.game-over-modal'); - if (gameOverModal) { - gameOverModal.remove(); - } - - // Reset target display - const targetDisplay = document.getElementById('target-display'); - if (targetDisplay) { - targetDisplay.textContent = 'Click to Start!'; - } - - this.updateHUD(); - - logSh('🔄 River Run restarted', 'INFO'); - } - - destroy() { - this.isRunning = false; - - // Cleanup - if (window.currentRiverGame === this) { - delete window.currentRiverGame; - } - - const styleSheet = document.getElementById('river-run-styles'); - if (styleSheet) { - styleSheet.remove(); - } - } -} - -// Add CSS animations -const additionalCSS = ` - @keyframes particleSpread { - 0% { - transform: scale(1) translate(0, 0); - opacity: 1; - } - 100% { - transform: scale(0) translate(${Math.random() * 100 - 50}px, ${Math.random() * 100 - 50}px); - opacity: 0; - } - } - - @keyframes particleFlow { - 0% { - transform: translateY(0); - opacity: 0.7; - } - 100% { - transform: translateY(100vh); - opacity: 0; - } - } -`; - -// Inject additional CSS -const additionalStyleSheet = document.createElement('style'); -additionalStyleSheet.textContent = additionalCSS; -document.head.appendChild(additionalStyleSheet); - -// Register the game module -window.GameModules = window.GameModules || {}; -window.GameModules.RiverRun = RiverRun; \ No newline at end of file diff --git a/src/games/story-builder.js b/src/games/story-builder.js deleted file mode 100644 index 49eef8a..0000000 --- a/src/games/story-builder.js +++ /dev/null @@ -1,979 +0,0 @@ -// === STORY BUILDER GAME - STORY CONSTRUCTOR === - -class StoryBuilderGame { - constructor(options) { - this.container = options.container; - this.content = options.content; - this.contentEngine = options.contentEngine; - this.onScoreUpdate = options.onScoreUpdate || (() => {}); - this.onGameEnd = options.onGameEnd || (() => {}); - - // Game state - this.score = 0; - this.currentStory = []; - this.availableElements = []; - this.storyTarget = null; - this.gameMode = 'vocabulary'; // 'vocabulary', 'sequence', 'dialogue', 'scenario' - - // Extract vocabulary using ultra-modular format - this.vocabulary = this.extractVocabulary(this.content); - this.wordsByType = this.groupVocabularyByType(this.vocabulary); - - // Configuration - this.maxElements = 6; - this.timeLimit = 180; // 3 minutes - this.timeLeft = this.timeLimit; - this.isRunning = false; - - // Timers - this.gameTimer = null; - - this.init(); - } - - init() { - // Check if we have enough vocabulary - if (!this.vocabulary || this.vocabulary.length < 6) { - logSh('Not enough vocabulary for Story Builder', 'ERROR'); - this.showInitError(); - return; - } - - this.createGameBoard(); - this.setupEventListeners(); - this.loadStoryContent(); - } - - showInitError() { - this.container.innerHTML = ` -
-

❌ Error loading

-

This content doesn't have enough vocabulary for Story Builder.

-

The game needs at least 6 vocabulary words with types (noun, verb, adjective, etc.).

- -
- `; - } - - createGameBoard() { - this.container.innerHTML = ` -
- -
- - - - -
- - -
-
-

Objective:

-

Choose a mode and let's start!

-
-
-
- ${this.timeLeft} - Time -
-
- 0/${this.maxElements} - Progress -
-
-
- - -
-
- -
- -
-
Drag elements here to build your story
-
-
- - -
- -
- - -
- - - - -
- - - -
- `; - } - - setupEventListeners() { - // Mode selection - document.querySelectorAll('.mode-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - if (this.isRunning) return; - - document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - this.gameMode = btn.dataset.mode; - - this.loadStoryContent(); - }); - }); - - // Game controls - document.getElementById('start-btn').addEventListener('click', () => this.start()); - document.getElementById('check-btn').addEventListener('click', () => this.checkStory()); - document.getElementById('hint-btn').addEventListener('click', () => this.showHint()); - document.getElementById('restart-btn').addEventListener('click', () => this.restart()); - - // Drag and Drop setup - this.setupDragAndDrop(); - } - - loadStoryContent() { - logSh('🎮 Loading story content for mode:', this.gameMode, 'INFO'); - - switch (this.gameMode) { - case 'vocabulary': - this.setupVocabularyMode(); - break; - case 'sequence': - this.setupSequenceMode(); - break; - case 'dialogue': - this.setupDialogueMode(); - break; - case 'scenario': - this.setupScenarioMode(); - break; - default: - this.setupVocabularyMode(); - } - } - - extractVocabulary(content) { - let vocabulary = []; - - logSh('📝 Extracting vocabulary from:', content?.name || 'content', 'INFO'); - - // Use raw module content if available - if (content.rawContent) { - logSh('📦 Using raw module content', 'INFO'); - return this.extractVocabularyFromRaw(content.rawContent); - } - - // Ultra-modular format (vocabulary object) - ONLY format supported - if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) { - logSh('✨ Ultra-modular format detected (vocabulary object)', 'INFO'); - vocabulary = Object.entries(content.vocabulary).map(([word, data]) => { - // Support ultra-modular format ONLY - if (typeof data === 'object' && data.user_language) { - return { - original: word, // Clé = original_language - translation: data.user_language.split(';')[0], // First translation - fullTranslation: data.user_language, // Complete translation - type: data.type || 'general', - audio: data.audio, - image: data.image, - examples: data.examples, - pronunciation: data.pronunciation, - category: data.type || 'general' - }; - } - // No legacy fallback - ultra-modular only - return null; - }).filter(Boolean); - } - // No other formats supported - ultra-modular only - - return this.finalizeVocabulary(vocabulary); - } - - extractVocabularyFromRaw(rawContent) { - logSh('🔧 Extracting from raw content:', rawContent.name || 'Module', 'INFO'); - let vocabulary = []; - - // Ultra-modular format (vocabulary object) - ONLY format supported - if (rawContent.vocabulary && typeof rawContent.vocabulary === 'object' && !Array.isArray(rawContent.vocabulary)) { - vocabulary = Object.entries(rawContent.vocabulary).map(([word, data]) => { - // Support ultra-modular format ONLY - if (typeof data === 'object' && data.user_language) { - return { - original: word, // Clé = original_language - translation: data.user_language.split(';')[0], // First translation - fullTranslation: data.user_language, // Complete translation - type: data.type || 'general', - audio: data.audio, - image: data.image, - examples: data.examples, - pronunciation: data.pronunciation, - category: data.type || 'general' - }; - } - // No legacy fallback - ultra-modular only - return null; - }).filter(Boolean); - } - // No other formats supported - ultra-modular only - - return this.finalizeVocabulary(vocabulary); - } - - finalizeVocabulary(vocabulary) { - // Filter out invalid entries - vocabulary = vocabulary.filter(item => - item && - typeof item.original === 'string' && - typeof item.translation === 'string' && - item.original.trim() !== '' && - item.translation.trim() !== '' - ); - - logSh(`📊 Finalized ${vocabulary.length} vocabulary items`, 'INFO'); - return vocabulary; - } - - groupVocabularyByType(vocabulary) { - const grouped = {}; - - vocabulary.forEach(word => { - const type = word.type || 'general'; - if (!grouped[type]) { - grouped[type] = []; - } - grouped[type].push(word); - }); - - logSh('📊 Words grouped by type:', Object.keys(grouped).map(type => `${type}: ${grouped[type].length}`).join(', '), 'INFO'); - return grouped; - } - - setupVocabularyMode() { - if (Object.keys(this.wordsByType).length === 0) { - this.setupFallbackContent(); - return; - } - - // Create a story template using different word types - this.storyTarget = this.createStoryTemplate(); - this.availableElements = this.selectWordsForStory(); - - document.getElementById('objective-text').textContent = - 'Build a coherent story using these words! Use different types: nouns, verbs, adjectives...'; - } - - createStoryTemplate() { - const types = Object.keys(this.wordsByType); - - // Common story templates based on available word types - const templates = [ - { pattern: ['noun', 'verb', 'adjective', 'noun'], name: 'Simple Story' }, - { pattern: ['adjective', 'noun', 'verb', 'noun'], name: 'Descriptive Story' }, - { pattern: ['noun', 'verb', 'adjective', 'noun', 'verb'], name: 'Action Story' }, - { pattern: ['article', 'adjective', 'noun', 'verb', 'adverb'], name: 'Rich Story' } - ]; - - // Find the best template based on available word types - const availableTemplate = templates.find(template => - template.pattern.every(type => - types.includes(type) && this.wordsByType[type].length > 0 - ) - ); - - if (availableTemplate) { - return { - template: availableTemplate, - requiredTypes: availableTemplate.pattern - }; - } - - // Fallback: use available types - return { - template: { pattern: types.slice(0, 4), name: 'Custom Story' }, - requiredTypes: types.slice(0, 4) - }; - } - - selectWordsForStory() { - const words = []; - - if (this.storyTarget && this.storyTarget.requiredTypes) { - // Select words for each required type - this.storyTarget.requiredTypes.forEach(type => { - if (this.wordsByType[type] && this.wordsByType[type].length > 0) { - // Add 2-3 words of each type for choice - const typeWords = this.shuffleArray([...this.wordsByType[type]]).slice(0, 3); - words.push(...typeWords); - } - }); - } - - // Add some random extra words for distraction - const allTypes = Object.keys(this.wordsByType); - allTypes.forEach(type => { - if (this.wordsByType[type] && this.wordsByType[type].length > 0) { - const extraWords = this.shuffleArray([...this.wordsByType[type]]).slice(0, 1); - words.push(...extraWords); - } - }); - - // Remove duplicates and shuffle - const uniqueWords = words.filter((word, index, self) => - self.findIndex(w => w.original === word.original) === index - ); - - return this.shuffleArray(uniqueWords).slice(0, this.maxElements); - } - - setupSequenceMode() { - // Use vocabulary to create a logical sequence - const actionWords = this.wordsByType.verb || []; - const objectWords = this.wordsByType.noun || []; - - if (actionWords.length >= 2 && objectWords.length >= 2) { - this.storyTarget = { - type: 'sequence', - steps: [ - { order: 1, text: `First: ${actionWords[0].original}`, word: actionWords[0] }, - { order: 2, text: `Then: ${actionWords[1].original}`, word: actionWords[1] }, - { order: 3, text: `With: ${objectWords[0].original}`, word: objectWords[0] }, - { order: 4, text: `Finally: ${objectWords[1].original}`, word: objectWords[1] } - ] - }; - - this.availableElements = this.shuffleArray([...this.storyTarget.steps]); - document.getElementById('objective-text').textContent = - 'Put these actions in logical order!'; - } else { - this.setupVocabularyMode(); // Fallback - } - } - - setupDialogueMode() { - // Create a simple dialogue using available vocabulary - const greetings = this.wordsByType.greeting || []; - const nouns = this.wordsByType.noun || []; - const verbs = this.wordsByType.verb || []; - - if (greetings.length >= 1 && (nouns.length >= 2 || verbs.length >= 2)) { - const dialogue = [ - { speaker: 'A', text: greetings[0].original, word: greetings[0] }, - { speaker: 'B', text: greetings[0].translation, word: greetings[0] } - ]; - - if (verbs.length >= 1) { - dialogue.push({ speaker: 'A', text: verbs[0].original, word: verbs[0] }); - } - if (nouns.length >= 1) { - dialogue.push({ speaker: 'B', text: nouns[0].original, word: nouns[0] }); - } - - this.storyTarget = { type: 'dialogue', conversation: dialogue }; - this.availableElements = this.shuffleArray([...dialogue]); - - document.getElementById('objective-text').textContent = - 'Reconstruct this dialogue in the right order!'; - } else { - this.setupVocabularyMode(); // Fallback - } - } - - setupScenarioMode() { - // Create a scenario using mixed vocabulary types - const allWords = Object.values(this.wordsByType).flat(); - - if (allWords.length >= 4) { - const scenario = { - context: 'Daily Life', - elements: this.shuffleArray(allWords).slice(0, 6) - }; - - this.storyTarget = { type: 'scenario', scenario }; - this.availableElements = [...scenario.elements]; - - document.getElementById('objective-text').textContent = - `Create a story about: "${scenario.context}" using these words!`; - } else { - this.setupVocabularyMode(); // Fallback - } - } - - setupFallbackContent() { - // Use any available vocabulary - if (this.vocabulary.length >= 4) { - this.availableElements = this.shuffleArray([...this.vocabulary]).slice(0, 6); - this.gameMode = 'vocabulary'; - - document.getElementById('objective-text').textContent = - 'Build a story with these words!'; - } else { - document.getElementById('objective-text').textContent = - 'Not enough vocabulary available. Please select different content.'; - } - } - - start() { - if (this.isRunning || this.availableElements.length === 0) return; - - this.isRunning = true; - this.score = 0; - this.currentStory = []; - this.timeLeft = this.timeLimit; - - this.renderElements(); - this.startTimer(); - this.updateUI(); - - document.getElementById('start-btn').disabled = true; - document.getElementById('check-btn').disabled = false; - document.getElementById('hint-btn').disabled = false; - - this.showFeedback('Drag the elements in order to build your story!', 'info'); - } - - renderElements() { - const elementsBank = document.getElementById('elements-bank'); - elementsBank.innerHTML = '

Available elements:

'; - - this.availableElements.forEach((element, index) => { - const elementDiv = this.createElement(element, index); - elementsBank.appendChild(elementDiv); - }); - } - - createElement(element, index) { - const div = document.createElement('div'); - div.className = 'story-element'; - div.draggable = true; - div.dataset.index = index; - - // Ultra-modular format display - if (element.original && element.translation) { - // Vocabulary word with type - div.innerHTML = ` -
-
${element.original}
-
${element.translation}
- ${element.type ? `
${element.type}
` : ''} -
- `; - } else if (element.text || element.original) { - // Dialogue or sequence element - div.innerHTML = ` -
-
${element.text || element.original}
- ${element.translation ? `
${element.translation}
` : ''} - ${element.speaker ? `
${element.speaker}:
` : ''} -
- `; - } else if (element.word) { - // Element containing a word object - div.innerHTML = ` -
-
${element.word.original}
-
${element.word.translation}
- ${element.word.type ? `
${element.word.type}
` : ''} -
- `; - } else if (typeof element === 'string') { - // Simple text - div.innerHTML = `
${element}
`; - } - - // Add type-based styling - if (element.type) { - div.classList.add(`type-${element.type}`); - } - - return div; - } - - setupDragAndDrop() { - let draggedElement = null; - - document.addEventListener('dragstart', (e) => { - if (e.target.classList.contains('story-element')) { - draggedElement = e.target; - e.target.style.opacity = '0.5'; - } - }); - - document.addEventListener('dragend', (e) => { - if (e.target.classList.contains('story-element')) { - e.target.style.opacity = '1'; - draggedElement = null; - } - }); - - const dropZone = document.getElementById('drop-zone'); - - dropZone.addEventListener('dragover', (e) => { - e.preventDefault(); - dropZone.classList.add('drag-over'); - }); - - dropZone.addEventListener('dragleave', () => { - dropZone.classList.remove('drag-over'); - }); - - dropZone.addEventListener('drop', (e) => { - e.preventDefault(); - dropZone.classList.remove('drag-over'); - - if (draggedElement && this.isRunning) { - this.addToStory(draggedElement); - } - }); - } - - addToStory(elementDiv) { - const index = parseInt(elementDiv.dataset.index); - const element = this.availableElements[index]; - - // Add to the story - this.currentStory.push({ element, originalIndex: index }); - - // Create element in construction zone - const storyElement = elementDiv.cloneNode(true); - storyElement.classList.add('in-story'); - storyElement.draggable = false; - - // Ajouter bouton de suppression - const removeBtn = document.createElement('button'); - removeBtn.className = 'remove-element'; - removeBtn.innerHTML = '×'; - removeBtn.onclick = () => this.removeFromStory(storyElement, element); - storyElement.appendChild(removeBtn); - - document.getElementById('drop-zone').appendChild(storyElement); - - // Masquer l'élément original - elementDiv.style.display = 'none'; - - this.updateProgress(); - } - - removeFromStory(storyElement, element) { - // Remove from story - this.currentStory = this.currentStory.filter(item => item.element !== element); - - // Supprimer visuellement - storyElement.remove(); - - // Réafficher l'élément original - const originalElement = document.querySelector(`[data-index="${this.availableElements.indexOf(element)}"]`); - if (originalElement) { - originalElement.style.display = 'block'; - } - - this.updateProgress(); - } - - checkStory() { - if (this.currentStory.length === 0) { - this.showFeedback('Add at least one element to your story!', 'error'); - return; - } - - const isCorrect = this.validateStory(); - - if (isCorrect) { - this.score += this.currentStory.length * 10; - this.showFeedback('Bravo! Perfect story! 🎉', 'success'); - this.onScoreUpdate(this.score); - - setTimeout(() => { - this.nextChallenge(); - }, 2000); - } else { - this.score = Math.max(0, this.score - 5); - this.showFeedback('Almost! Check the order of your story 🤔', 'warning'); - this.onScoreUpdate(this.score); - } - } - - validateStory() { - switch (this.gameMode) { - case 'vocabulary': - return this.validateVocabularyStory(); - case 'sequence': - return this.validateSequence(); - case 'dialogue': - return this.validateDialogue(); - case 'scenario': - return this.validateScenario(); - default: - return true; // Free mode - } - } - - validateVocabularyStory() { - if (this.currentStory.length < 3) return false; - - // Check for variety in word types - const typesUsed = new Set(); - this.currentStory.forEach(item => { - const element = item.element; - if (element.type) { - typesUsed.add(element.type); - } - }); - - // Require at least 2 different word types for a good story - return typesUsed.size >= 2; - } - - validateSequence() { - if (!this.storyTarget?.steps) return true; - - const expectedOrder = this.storyTarget.steps.sort((a, b) => a.order - b.order); - - if (this.currentStory.length !== expectedOrder.length) return false; - - return this.currentStory.every((item, index) => { - const expected = expectedOrder[index]; - return item.element.order === expected.order; - }); - } - - validateDialogue() { - // Flexible dialogue validation (logical order of replies) - return this.currentStory.length >= 2; - } - - validateScenario() { - // Flexible scenario validation (contextual coherence) - return this.currentStory.length >= 3; - } - - showHint() { - switch (this.gameMode) { - case 'vocabulary': - const typesAvailable = Object.keys(this.wordsByType); - this.showFeedback(`Tip: Try using different word types: ${typesAvailable.join(', ')}`, 'info'); - break; - case 'sequence': - if (this.storyTarget?.steps) { - const nextStep = this.storyTarget.steps.find(step => - !this.currentStory.some(item => item.element.order === step.order) - ); - if (nextStep) { - this.showFeedback(`Next step: "${nextStep.text}"`, 'info'); - } - } - break; - case 'dialogue': - this.showFeedback('Think about the natural order of a conversation!', 'info'); - break; - case 'scenario': - this.showFeedback('Create a coherent story in this context!', 'info'); - break; - default: - this.showFeedback('Tip: Think about the logical order of events!', 'info'); - } - } - - nextChallenge() { - // Load a new challenge - this.loadStoryContent(); - this.currentStory = []; - document.getElementById('drop-zone').innerHTML = '
Drag elements here to build your story
'; - this.renderElements(); - this.updateProgress(); - } - - startTimer() { - this.gameTimer = setInterval(() => { - this.timeLeft--; - this.updateUI(); - - if (this.timeLeft <= 0) { - this.endGame(); - } - }, 1000); - } - - endGame() { - this.isRunning = false; - if (this.gameTimer) { - clearInterval(this.gameTimer); - this.gameTimer = null; - } - - document.getElementById('start-btn').disabled = false; - document.getElementById('check-btn').disabled = true; - document.getElementById('hint-btn').disabled = true; - - this.onGameEnd(this.score); - } - - restart() { - this.endGame(); - this.score = 0; - this.currentStory = []; - this.timeLeft = this.timeLimit; - this.onScoreUpdate(0); - - document.getElementById('drop-zone').innerHTML = '
Drag elements here to build your story
'; - this.loadStoryContent(); - this.updateUI(); - } - - updateProgress() { - document.getElementById('story-progress').textContent = - `${this.currentStory.length}/${this.maxElements}`; - } - - updateUI() { - document.getElementById('time-left').textContent = this.timeLeft; - } - - showFeedback(message, type = 'info') { - const feedbackArea = document.getElementById('feedback-area'); - feedbackArea.innerHTML = `
${message}
`; - } - - 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; - } - - destroy() { - this.endGame(); - this.container.innerHTML = ''; - } -} - -// CSS pour Story Builder -const storyBuilderStyles = ` - -`; - -// Ajouter les styles -document.head.insertAdjacentHTML('beforeend', storyBuilderStyles); - -// Enregistrement du module -window.GameModules = window.GameModules || {}; -window.GameModules.StoryBuilder = StoryBuilderGame; \ No newline at end of file diff --git a/src/games/story-reader.js b/src/games/story-reader.js deleted file mode 100644 index d552d6d..0000000 --- a/src/games/story-reader.js +++ /dev/null @@ -1,1366 +0,0 @@ -// === STORY READER GAME === -// Prototype for reading long stories with sentence chunking and word-by-word translation - -class StoryReader { - constructor(options) { - this.container = options.container; - this.content = options.content; - this.onScoreUpdate = options.onScoreUpdate || (() => {}); - this.onGameEnd = options.onGameEnd || (() => {}); - - // Reading state - this.currentChapter = 0; - this.currentSentence = 0; - this.totalSentences = 0; - this.readingSessions = 0; - this.wordsRead = 0; - this.comprehensionScore = 0; - - // Story data - this.story = null; - this.availableStories = []; - this.currentStoryIndex = 0; - this.vocabulary = {}; - - // UI state - this.showTranslations = false; - this.showPronunciations = false; - this.readingMode = 'sentence'; // 'sentence' or 'paragraph' - this.fontSize = 'medium'; - - // Reading time tracking - this.startTime = Date.now(); - this.totalReadingTime = 0; - this.readingTimer = null; - - // TTS settings - this.autoPlayTTS = true; - this.ttsEnabled = true; - - // Expose content globally for SettingsManager TTS language detection - window.currentGameContent = this.content; - - this.init(); - } - - init() { - logSh(`🔍 Story Reader content received:`, this.content, 'DEBUG'); - logSh(`🔍 Story field exists: ${!!this.content.story}`, 'DEBUG'); - logSh(`🔍 RawContent exists: ${!!this.content.rawContent}`, 'DEBUG'); - - // Discover all available stories - this.discoverAvailableStories(); - - if (this.availableStories.length === 0) { - logSh('No story content found in content or rawContent', 'ERROR'); - this.showError('This content does not contain any stories for reading.'); - return; - } - - // Get URL params to check if specific story is requested - const urlParams = new URLSearchParams(window.location.search); - const requestedStory = urlParams.get('story'); - - if (requestedStory) { - const storyIndex = this.availableStories.findIndex(story => - story.id === requestedStory || story.title.toLowerCase().includes(requestedStory.toLowerCase()) - ); - if (storyIndex !== -1) { - this.currentStoryIndex = storyIndex; - } - } - - this.selectStory(this.currentStoryIndex); - this.vocabulary = this.content.rawContent?.vocabulary || this.content.vocabulary || {}; - - logSh(`📖 Story Reader initialized: "${this.story.title}" (${this.totalSentences} sentences)`, 'INFO'); - - this.createInterface(); - this.loadProgress(); - this.renderCurrentSentence(); - } - - discoverAvailableStories() { - this.availableStories = []; - - // Check main story field - const mainStory = this.content.rawContent?.story || this.content.story; - if (mainStory && mainStory.title) { - this.availableStories.push({ - id: 'main', - title: mainStory.title, - data: mainStory, - source: 'main' - }); - } - - // Check additionalStories field (like in WTA1B1) - const additionalStories = this.content.rawContent?.additionalStories || this.content.additionalStories; - if (additionalStories && Array.isArray(additionalStories)) { - additionalStories.forEach((story, index) => { - if (story && story.title) { - this.availableStories.push({ - id: `additional_${index}`, - title: story.title, - data: story, - source: 'additional' - }); - } - }); - } - - // NEW: Check for simple texts and convert them to stories - const texts = this.content.rawContent?.texts || this.content.texts; - if (texts && Array.isArray(texts)) { - texts.forEach((text, index) => { - if (text && (text.title || text.original_language)) { - const convertedStory = this.convertTextToStory(text, index); - this.availableStories.push({ - id: `text_${index}`, - title: text.title || `Text ${index + 1}`, - data: convertedStory, - source: 'text' - }); - } - }); - } - - // NEW: Check for sentences and create a story from them - const sentences = this.content.rawContent?.sentences || this.content.sentences; - if (sentences && Array.isArray(sentences) && sentences.length > 0 && this.availableStories.length === 0) { - const sentencesStory = this.convertSentencesToStory(sentences); - this.availableStories.push({ - id: 'sentences', - title: 'Reading Practice', - data: sentencesStory, - source: 'sentences' - }); - } - - logSh(`📚 Discovered ${this.availableStories.length} stories:`, this.availableStories.map(s => s.title), 'INFO'); - } - - selectStory(storyIndex) { - if (storyIndex >= 0 && storyIndex < this.availableStories.length) { - this.currentStoryIndex = storyIndex; - this.story = this.availableStories[storyIndex].data; - this.calculateTotalSentences(); - - // Reset reading position for new story - this.currentSentence = 0; - this.wordsRead = 0; - - // Update URL to include story parameter - this.updateUrlForStory(); - - logSh(`📖 Selected story: "${this.story.title}" (${this.totalSentences} sentences)`, 'INFO'); - } - } - - updateUrlForStory() { - const urlParams = new URLSearchParams(window.location.search); - urlParams.set('story', this.availableStories[this.currentStoryIndex].id); - const newUrl = `${window.location.pathname}?${urlParams.toString()}`; - window.history.replaceState({}, '', newUrl); - } - - showError(message) { - this.container.innerHTML = ` -
-

❌ Error

-

${message}

- -
- `; - } - - calculateTotalSentences() { - this.totalSentences = 0; - this.story.chapters.forEach(chapter => { - this.totalSentences += chapter.sentences.length; - }); - } - - createInterface() { - // Create story selector dropdown if multiple stories available - const storySelector = this.availableStories.length > 1 ? ` -
- - -
- ` : ''; - - this.container.innerHTML = ` -
- ${storySelector} - - -
-
-

${this.story.title}

-
- Sentence 1 of ${this.totalSentences} -
-
-
-
-
- -
- - - -
-
- - - - - -
- Chapter 1: Loading... -
- - -
-
-
Loading story...
- -
- - - -
- - -
- - - - -
- - -
-
- Words Read: - 0 -
-
- Reading Time: - 00:00 -
-
- Progress: - 0% -
-
-
- `; - - this.addStyles(); - this.setupEventListeners(); - } - - addStyles() { - const style = document.createElement('style'); - style.textContent = ` - .story-reader-wrapper { - max-width: 800px; - margin: 0 auto; - padding: 20px; - font-family: 'Georgia', serif; - line-height: 1.6; - } - - .story-selector { - background: #f8fafc; - border: 2px solid #e2e8f0; - border-radius: 10px; - padding: 15px 20px; - margin-bottom: 25px; - display: flex; - align-items: center; - gap: 15px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); - } - - .story-selector label { - font-weight: 600; - color: #2d3748; - font-size: 1.1em; - min-width: 120px; - } - - .story-selector select { - flex: 1; - padding: 8px 12px; - border: 2px solid #cbd5e0; - border-radius: 6px; - background: white; - font-size: 1em; - color: #2d3748; - cursor: pointer; - transition: border-color 0.2s; - } - - .story-selector select:focus { - outline: none; - border-color: #3b82f6; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); - } - - .story-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 20px; - padding-bottom: 15px; - border-bottom: 2px solid #e2e8f0; - } - - .story-title h2 { - margin: 0 0 10px 0; - color: #2d3748; - font-size: 1.8em; - } - - .reading-progress { - display: flex; - align-items: center; - gap: 10px; - } - - .progress-bar { - width: 200px; - height: 8px; - background: #e2e8f0; - border-radius: 4px; - overflow: hidden; - } - - .progress-fill { - height: 100%; - background: linear-gradient(90deg, #3b82f6, #10b981); - width: 0%; - transition: width 0.3s ease; - } - - .story-controls { - display: flex; - gap: 10px; - flex-wrap: wrap; - } - - .control-btn { - padding: 8px 12px; - border: 2px solid #e2e8f0; - background: white; - border-radius: 6px; - cursor: pointer; - font-size: 0.9em; - transition: all 0.2s; - white-space: nowrap; - } - - .control-btn:hover { - background: #f7fafc; - border-color: #cbd5e0; - } - - .control-btn.secondary { - background: #f8fafc; - color: #4a5568; - } - - .control-btn.secondary:hover { - background: #e2e8f0; - color: #2d3748; - } - - .settings-panel { - background: #f7fafc; - border: 1px solid #e2e8f0; - border-radius: 8px; - padding: 15px; - margin-bottom: 20px; - } - - .setting-group { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 10px; - } - - .setting-group label { - font-weight: 600; - min-width: 100px; - } - - .chapter-info { - background: #edf2f7; - padding: 10px 15px; - border-radius: 6px; - margin-bottom: 20px; - font-style: italic; - color: #4a5568; - } - - .reading-area { - position: relative; - background: white; - border: 2px solid #e2e8f0; - border-radius: 12px; - padding: 30px; - margin-bottom: 20px; - min-height: 200px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); - } - - .sentence-display { - text-align: center; - } - - .original-text { - font-size: 1.2em; - color: #2d3748; - margin-bottom: 15px; - cursor: pointer; - padding: 15px; - border-radius: 8px; - transition: background-color 0.2s; - } - - .original-text:hover { - background-color: #f7fafc; - } - - .original-text.small { font-size: 1em; } - .original-text.medium { font-size: 1.2em; } - .original-text.large { font-size: 1.4em; } - .original-text.extra-large { font-size: 1.6em; } - - .translation-text { - font-style: italic; - color: #718096; - font-size: 1em; - padding: 10px; - background: #f0fff4; - border-radius: 6px; - border-left: 4px solid #10b981; - } - - .clickable-word { - cursor: pointer; - padding: 2px 4px; - border-radius: 3px; - transition: background-color 0.2s; - position: relative; - display: inline-block; - } - - .clickable-word:hover { - background-color: #fef5e7; - color: #d69e2e; - } - - .punctuation { - color: #2d3748; - font-weight: normal; - cursor: default; - user-select: none; - } - - .word-with-pronunciation { - position: relative; - display: inline-block; - margin: 0 2px; - vertical-align: top; - line-height: 1.8; - } - - .pronunciation-text { - position: absolute; - top: -16px; - left: 50%; - transform: translateX(-50%); - font-size: 0.7em; - color: #718096; - font-style: italic; - white-space: nowrap; - pointer-events: none; - z-index: 10; - display: none; - } - - .pronunciation-text.show { - display: block; - } - - .reading-area { - position: relative; - background: white; - border: 2px solid #e2e8f0; - border-radius: 12px; - padding: 40px 30px 30px 30px; - margin-bottom: 20px; - min-height: 200px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); - line-height: 2.2; - } - - .word-popup { - position: fixed; - background: white; - border: 2px solid #3b82f6; - border-radius: 6px; - padding: 8px 12px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - z-index: 9999; - max-width: 200px; - min-width: 120px; - font-size: 0.9em; - line-height: 1.3; - } - - .word-original { - font-weight: bold; - color: #2d3748; - font-size: 1em; - margin-bottom: 3px; - } - - .word-translation { - color: #10b981; - font-size: 0.9em; - margin-bottom: 2px; - } - - .word-type { - font-size: 0.75em; - color: #718096; - font-style: italic; - } - - .word-tts-btn { - position: absolute; - top: 5px; - right: 5px; - background: #3b82f6; - color: white; - border: none; - border-radius: 50%; - width: 24px; - height: 24px; - cursor: pointer; - font-size: 12px; - display: flex; - align-items: center; - justify-content: center; - transition: background 0.2s; - } - - .word-tts-btn:hover { - background: #2563eb; - } - - .story-navigation { - display: flex; - justify-content: center; - gap: 15px; - margin-bottom: 20px; - } - - .nav-btn { - padding: 12px 24px; - border: 2px solid #e2e8f0; - background: white; - border-radius: 8px; - cursor: pointer; - font-size: 1em; - transition: all 0.2s; - } - - .nav-btn:hover:not(:disabled) { - background: #f7fafc; - border-color: #cbd5e0; - } - - .nav-btn:disabled { - opacity: 0.5; - cursor: not-allowed; - } - - .nav-btn.primary { - background: #3b82f6; - color: white; - border-color: #3b82f6; - } - - .nav-btn.primary:hover { - background: #2563eb; - } - - .reading-stats { - display: flex; - justify-content: space-around; - background: #f7fafc; - padding: 15px; - border-radius: 8px; - border: 1px solid #e2e8f0; - } - - .stat { - text-align: center; - } - - .stat-label { - display: block; - font-size: 0.9em; - color: #718096; - margin-bottom: 5px; - } - - .stat-value { - display: block; - font-weight: bold; - font-size: 1.1em; - color: #2d3748; - } - - @media (max-width: 768px) { - .story-reader-wrapper { - padding: 10px; - } - - .story-header { - flex-direction: column; - gap: 15px; - } - - .reading-stats { - flex-direction: column; - gap: 10px; - } - } - `; - document.head.appendChild(style); - } - - setupEventListeners() { - // Story selector (if multiple stories) - const storySelect = document.getElementById('story-select'); - if (storySelect) { - storySelect.addEventListener('change', (e) => this.changeStory(parseInt(e.target.value))); - } - - // Navigation - document.getElementById('prev-btn').addEventListener('click', () => this.previousSentence()); - document.getElementById('next-btn').addEventListener('click', () => this.nextSentence()); - document.getElementById('bookmark-btn').addEventListener('click', () => this.saveBookmark()); - - // Controls - document.getElementById('play-sentence-btn').addEventListener('click', () => this.playSentenceTTS()); - document.getElementById('settings-btn').addEventListener('click', () => this.toggleSettings()); - document.getElementById('toggle-translation-btn').addEventListener('click', () => this.toggleTranslations()); - document.getElementById('pronunciation-toggle-btn').addEventListener('click', () => this.togglePronunciations()); - - // Settings - document.getElementById('font-size-select').addEventListener('change', (e) => this.changeFontSize(e.target.value)); - document.getElementById('reading-mode-select').addEventListener('change', (e) => this.changeReadingMode(e.target.value)); - document.getElementById('auto-play-tts').addEventListener('change', (e) => this.toggleAutoPlayTTS(e.target.checked)); - document.getElementById('tts-speed-select').addEventListener('change', (e) => this.changeTTSSpeed(e.target.value)); - - // Keyboard shortcuts - document.addEventListener('keydown', (e) => { - if (e.key === 'ArrowLeft') this.previousSentence(); - if (e.key === 'ArrowRight') this.nextSentence(); - if (e.key === 'Space') { - e.preventDefault(); - this.nextSentence(); - } - if (e.key === 't' || e.key === 'T') this.toggleTranslations(); - if (e.key === 's' || e.key === 'S') this.playSentenceTTS(); - }); - - // Click outside to close word popup - document.addEventListener('click', (e) => { - if (!e.target.closest('.word-popup') && !e.target.closest('.clickable-word')) { - this.hideWordPopup(); - } - }); - } - - getCurrentSentenceData() { - let sentenceCount = 0; - for (let chapterIndex = 0; chapterIndex < this.story.chapters.length; chapterIndex++) { - const chapter = this.story.chapters[chapterIndex]; - if (sentenceCount + chapter.sentences.length > this.currentSentence) { - const sentenceInChapter = this.currentSentence - sentenceCount; - return { - chapter: chapterIndex, - sentence: sentenceInChapter, - data: chapter.sentences[sentenceInChapter], - chapterTitle: chapter.title - }; - } - sentenceCount += chapter.sentences.length; - } - return null; - } - - // Match words from sentence with centralized vocabulary - matchWordsWithVocabulary(sentence) { - const words = sentence.split(/(\s+|[.,!?;:"'()[\]{}\-–—])/); - const matchedWords = []; - - words.forEach(token => { - // Handle whitespace tokens - if (/^\s+$/.test(token)) { - matchedWords.push({ - original: token, - hasVocab: false, - isWhitespace: true - }); - return; - } - - // Handle pure punctuation tokens (preserve them as non-clickable) - if (/^[.,!?;:"'()[\]{}\-–—]+$/.test(token)) { - matchedWords.push({ - original: token, - hasVocab: false, - isPunctuation: true - }); - return; - } - - // Clean word (remove punctuation for matching) - const cleanWord = token.toLowerCase().replace(/[.,!?;:"'()[\]{}\-–—]/g, ''); - - // Skip empty tokens - if (!cleanWord) return; - - // Check if word exists in vocabulary (try exact match first, then stems) - let vocabEntry = this.content.vocabulary[cleanWord]; - - // Try common variations if exact match not found - if (!vocabEntry) { - // Try without 's' for plurals - if (cleanWord.endsWith('s')) { - vocabEntry = this.content.vocabulary[cleanWord.slice(0, -1)]; - } - // Try without 'ed' for past tense - if (!vocabEntry && cleanWord.endsWith('ed')) { - vocabEntry = this.content.vocabulary[cleanWord.slice(0, -2)]; - } - // Try without 'ing' for present participle - if (!vocabEntry && cleanWord.endsWith('ing')) { - vocabEntry = this.content.vocabulary[cleanWord.slice(0, -3)]; - } - } - - if (vocabEntry) { - // Word found in vocabulary - matchedWords.push({ - original: token, - hasVocab: true, - word: cleanWord, - translation: vocabEntry.translation || vocabEntry.user_language, - pronunciation: vocabEntry.pronunciation, - type: vocabEntry.type || 'unknown' - }); - } else { - // Word not in vocabulary - render as plain text - matchedWords.push({ - original: token, - hasVocab: false - }); - } - }); - - return matchedWords; - } - - renderCurrentSentence() { - const sentenceData = this.getCurrentSentenceData(); - if (!sentenceData) return; - - const { data, chapterTitle } = sentenceData; - - // Update chapter info - document.getElementById('chapter-info').innerHTML = ` - ${chapterTitle} - `; - - // Update progress - const progress = ((this.currentSentence + 1) / this.totalSentences) * 100; - document.getElementById('progress-fill').style.width = `${progress}%`; - document.getElementById('progress-text').textContent = `Sentence ${this.currentSentence + 1} of ${this.totalSentences}`; - document.getElementById('reading-percentage').textContent = `${Math.round(progress)}%`; - - // Check if sentence has word-by-word data (old format) or needs automatic matching - let wordsHtml; - - console.log('🔍 DEBUG: sentence data:', data); - console.log('🔍 DEBUG: data.words exists?', !!data.words); - console.log('🔍 DEBUG: data.words length:', data.words ? data.words.length : 'N/A'); - - if (data.words && data.words.length > 0) { - // Old format with word-by-word data - wordsHtml = data.words.map(wordData => { - const pronunciation = wordData.pronunciation || ''; - const pronunciationHtml = pronunciation ? - `${pronunciation}` : ''; - - return ` - ${pronunciationHtml} - ${wordData.word} - `; - }).join(' '); - } else { - // New format with centralized vocabulary - use automatic matching - const matchedWords = this.matchWordsWithVocabulary(data.original); - - wordsHtml = matchedWords.map(wordInfo => { - if (wordInfo.isWhitespace) { - return wordInfo.original; - } else if (wordInfo.isPunctuation) { - // Render punctuation as non-clickable text - return `${wordInfo.original}`; - } else if (wordInfo.hasVocab) { - const pronunciation = this.showPronunciation && wordInfo.pronunciation ? - wordInfo.pronunciation : ''; - const pronunciationHtml = pronunciation ? - `${pronunciation}` : ''; - - return ` - ${pronunciationHtml} - ${wordInfo.original} - `; - } else { - // No vocabulary entry - render as plain text - return wordInfo.original; - } - }).join(''); - } - - document.getElementById('original-text').innerHTML = wordsHtml; - document.getElementById('translation-text').textContent = data.translation; - - // Add word click listeners - document.querySelectorAll('.clickable-word').forEach(word => { - word.addEventListener('click', (e) => this.showWordPopup(e)); - }); - - // Update navigation buttons - document.getElementById('prev-btn').disabled = this.currentSentence === 0; - document.getElementById('next-btn').disabled = this.currentSentence >= this.totalSentences - 1; - - // Update stats - this.updateStats(); - - // Auto-play TTS if enabled - if (this.autoPlayTTS && this.ttsEnabled) { - // Small delay to let the sentence render - setTimeout(() => this.playSentenceTTS(), 300); - } - } - - showWordPopup(event) { - const word = event.target.dataset.word; - const translation = event.target.dataset.translation; - const type = event.target.dataset.type; - const pronunciation = event.target.dataset.pronunciation; - - logSh(`🔍 Word clicked: ${word}, translation: ${translation}`, 'DEBUG'); - - const popup = document.getElementById('word-popup'); - if (!popup) { - logSh('❌ Word popup element not found!', 'ERROR'); - return; - } - - // Store reference to story reader for TTS button - popup.storyReader = this; - popup.currentWord = word; - - document.getElementById('popup-word').textContent = word; - document.getElementById('popup-translation').textContent = translation; - - // Show pronunciation in popup if available - const typeText = pronunciation ? `${pronunciation} (${type})` : `(${type})`; - document.getElementById('popup-type').textContent = typeText; - - // Position popup ABOVE the clicked word - const rect = event.target.getBoundingClientRect(); - popup.style.display = 'block'; - - // Center horizontally on the word, show above it - const popupLeft = rect.left + (rect.width / 2) - 100; // Center popup (200px wide / 2) - const popupTop = rect.top - 10; // Above the word with small gap - - popup.style.left = `${popupLeft}px`; - popup.style.top = `${popupTop}px`; - popup.style.transform = 'translateY(-100%)'; // Move up by its own height - - // Ensure popup stays within viewport - if (popupLeft < 10) { - popup.style.left = '10px'; - } - if (popupLeft + 200 > window.innerWidth) { - popup.style.left = `${window.innerWidth - 210}px`; - } - if (popupTop - 80 < 10) { - // If no room above, show below instead - popup.style.top = `${rect.bottom + 10}px`; - popup.style.transform = 'translateY(0)'; - } - - logSh(`📍 Popup positioned at: ${rect.left}px, ${rect.bottom + 10}px`, 'DEBUG'); - } - - hideWordPopup() { - document.getElementById('word-popup').style.display = 'none'; - } - - previousSentence() { - if (this.currentSentence > 0) { - this.currentSentence--; - this.renderCurrentSentence(); - this.saveProgress(); - } - } - - nextSentence() { - if (this.currentSentence < this.totalSentences - 1) { - this.currentSentence++; - this.renderCurrentSentence(); - this.saveProgress(); - } else { - this.completeReading(); - } - } - - toggleSettings() { - const panel = document.getElementById('settings-panel'); - panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; - } - - toggleTranslations() { - this.showTranslations = !this.showTranslations; - const translationText = document.getElementById('translation-text'); - translationText.style.display = this.showTranslations ? 'block' : 'none'; - - const btn = document.getElementById('toggle-translation-btn'); - btn.textContent = this.showTranslations ? '🌐 Hide Translations' : '🌐 Show Translations'; - } - - togglePronunciations() { - this.showPronunciations = !this.showPronunciations; - const pronunciations = document.querySelectorAll('.pronunciation-text'); - - pronunciations.forEach(pronunciation => { - if (this.showPronunciations) { - pronunciation.classList.add('show'); - } else { - pronunciation.classList.remove('show'); - } - }); - - const btn = document.getElementById('pronunciation-toggle-btn'); - btn.textContent = this.showPronunciations ? '🔇 Hide Pronunciations' : '🔊 Show Pronunciations'; - } - - changeFontSize(size) { - this.fontSize = size; - document.getElementById('original-text').className = `original-text ${size}`; - } - - changeReadingMode(mode) { - this.readingMode = mode; - // Mode implementation can be extended later - } - - changeStory(storyIndex) { - if (storyIndex !== this.currentStoryIndex) { - // Save progress for current story before switching - this.saveProgress(); - - // Select new story - this.selectStory(storyIndex); - - // Load progress for new story - this.loadProgress(); - - // Update the interface title and progress - this.updateStoryTitle(); - this.renderCurrentSentence(); - - logSh(`📖 Switched to story: "${this.story.title}"`, 'INFO'); - } - } - - updateStoryTitle() { - const titleElement = document.querySelector('.story-title h2'); - if (titleElement) { - titleElement.textContent = this.story.title; - } - } - - // NEW: Convert simple text to story format - convertTextToStory(text, index) { - // Split text into sentences for easier reading - const sentences = this.splitTextIntoSentences(text.original_language, text.user_language); - - return { - title: text.title || `Text ${index + 1}`, - totalSentences: sentences.length, - chapters: [{ - title: "Reading Text", - sentences: sentences - }] - }; - } - - // NEW: Convert array of sentences to story format - convertSentencesToStory(sentences) { - const storyTitle = this.content.name || "Reading Practice"; - - const convertedSentences = sentences.map((sentence, index) => ({ - id: index + 1, - original: sentence.original_language || sentence.english || sentence.original || '', - translation: sentence.user_language || sentence.chinese || sentence.french || sentence.translation || '', - words: this.breakSentenceIntoWords( - sentence.original_language || sentence.english || sentence.original || '', - sentence.user_language || sentence.chinese || sentence.french || sentence.translation || '' - ) - })); - - return { - title: storyTitle, - totalSentences: convertedSentences.length, - chapters: [{ - title: "Reading Sentences", - sentences: convertedSentences - }] - }; - } - - // NEW: Split long text into manageable sentences - splitTextIntoSentences(originalText, translationText) { - // Split by sentence endings - const originalSentences = originalText.split(/[.!?]+/).filter(s => s.trim().length > 0); - const translationSentences = translationText.split(/[.!?]+/).filter(s => s.trim().length > 0); - - const sentences = []; - const maxSentences = Math.max(originalSentences.length, translationSentences.length); - - for (let i = 0; i < maxSentences; i++) { - const original = (originalSentences[i] || '').trim(); - const translation = (translationSentences[i] || '').trim(); - - if (original || translation) { - sentences.push({ - id: i + 1, - original: original + (original && !original.match(/[.!?]$/) ? '.' : ''), - translation: translation + (translation && !translation.match(/[.!?]$/) ? '.' : ''), - words: this.breakSentenceIntoWords(original, translation) - }); - } - } - - return sentences; - } - - // NEW: Break sentence into word-by-word format for Story Reader - breakSentenceIntoWords(original, translation) { - if (!original) return []; - - // First, separate punctuation from words while preserving spaces - const preprocessed = original.replace(/([.,!?;:"'()[\]{}\-–—])/g, ' $1 '); - const words = preprocessed.split(/\s+/).filter(word => word.trim().length > 0); - - // Do the same for translation - const translationPreprocessed = translation ? translation.replace(/([.,!?;:"'()[\]{}\-–—])/g, ' $1 ') : ''; - const translationWords = translationPreprocessed ? translationPreprocessed.split(/\s+/).filter(word => word.trim().length > 0) : []; - - return words.map((word, index) => { - // Clean punctuation for word lookup, but preserve punctuation in display - const cleanWord = word.replace(/[.,!?;:"'()[\]{}\-–—]/g, '').toLowerCase(); - - // Try to find in vocabulary - let wordTranslation = translationWords[index] || ''; - let wordType = 'word'; - let pronunciation = ''; - - // Special handling for letter pairs (like "Aa", "Bb", etc.) - if (/^[A-Za-z]{1,2}$/.test(cleanWord)) { - wordType = 'letter'; - wordTranslation = word; // Keep the letter as is - } - - // Special handling for punctuation marks - if (/^[.,!?;:"'()[\]{}]$/.test(word)) { - wordType = 'punctuation'; - wordTranslation = word; // Keep punctuation as is - } - - // Look up in content vocabulary if available - if (this.vocabulary && this.vocabulary[cleanWord]) { - const vocabEntry = this.vocabulary[cleanWord]; - wordTranslation = vocabEntry.user_language || vocabEntry.translation || wordTranslation; - wordType = vocabEntry.type || wordType; - pronunciation = vocabEntry.pronunciation || ''; - } - - return { - word: word, - translation: wordTranslation, - type: wordType, - pronunciation: pronunciation - }; - }); - } - - // TTS Methods - playSentenceTTS() { - const sentenceData = this.getCurrentSentenceData(); - if (!sentenceData || !this.ttsEnabled) return; - - const text = sentenceData.data.original; - this.speakText(text); - } - - speakText(text, options = {}) { - if (!text || !this.ttsEnabled) return; - - // Use SettingsManager if available for better language support - if (window.SettingsManager && window.SettingsManager.speak) { - const ttsOptions = { - lang: this.getContentLanguage(), - rate: parseFloat(document.getElementById('tts-speed-select')?.value || '0.8'), - ...options - }; - - window.SettingsManager.speak(text, ttsOptions) - .catch(error => { - console.warn('🔊 SettingsManager TTS failed:', error); - this.fallbackTTS(text, ttsOptions); - }); - } else { - this.fallbackTTS(text, options); - } - } - - fallbackTTS(text, options = {}) { - if ('speechSynthesis' in window && text) { - // Cancel any ongoing speech - speechSynthesis.cancel(); - - const utterance = new SpeechSynthesisUtterance(text); - utterance.lang = this.getContentLanguage(); - utterance.rate = options.rate || 0.8; - utterance.volume = 1.0; - - speechSynthesis.speak(utterance); - } - } - - getContentLanguage() { - // Get language from content or use sensible defaults - if (this.content.language) { - const langMap = { - 'chinese': 'zh-CN', - 'english': 'en-US', - 'french': 'fr-FR', - 'spanish': 'es-ES' - }; - return langMap[this.content.language] || this.content.language; - } - return 'en-US'; // Default fallback - } - - toggleAutoPlayTTS(enabled) { - this.autoPlayTTS = enabled; - logSh(`🔊 Auto-play TTS ${enabled ? 'enabled' : 'disabled'}`, 'INFO'); - } - - changeTTSSpeed(speed) { - logSh(`🔊 TTS speed changed to ${speed}x`, 'INFO'); - } - - speakWordFromPopup() { - const popup = document.getElementById('word-popup'); - if (popup && popup.currentWord) { - this.speakText(popup.currentWord, { rate: 0.7 }); // Slower for individual words - } - } - - updateStats() { - const sentenceData = this.getCurrentSentenceData(); - if (sentenceData) { - this.wordsRead += sentenceData.data.words.length; - document.getElementById('words-read').textContent = this.wordsRead; - } - } - - saveProgress() { - const progressData = { - currentSentence: this.currentSentence, - wordsRead: this.wordsRead, - timestamp: Date.now() - }; - const progressKey = this.getProgressKey(); - localStorage.setItem(progressKey, JSON.stringify(progressData)); - } - - loadProgress() { - const progressKey = this.getProgressKey(); - const saved = localStorage.getItem(progressKey); - if (saved) { - try { - const data = JSON.parse(saved); - this.currentSentence = data.currentSentence || 0; - this.wordsRead = data.wordsRead || 0; - } catch (error) { - logSh('Error loading progress:', error, 'WARN'); - this.currentSentence = 0; - this.wordsRead = 0; - } - } else { - // No saved progress - start fresh - this.currentSentence = 0; - this.wordsRead = 0; - } - } - - getProgressKey() { - const storyId = this.availableStories[this.currentStoryIndex]?.id || 'main'; - return `story_progress_${this.content.name}_${storyId}`; - } - - saveBookmark() { - this.saveProgress(); - const toast = document.createElement('div'); - toast.textContent = '🔖 Bookmark saved!'; - toast.style.cssText = ` - position: fixed; - top: 20px; - right: 20px; - background: #10b981; - color: white; - padding: 10px 20px; - border-radius: 6px; - z-index: 1000; - `; - document.body.appendChild(toast); - setTimeout(() => toast.remove(), 2000); - } - - completeReading() { - this.onGameEnd(this.wordsRead); - - const completionMessage = ` -
-

🎉 Story Complete!

-

You've finished reading "${this.story.title}"

-

Words read: ${this.wordsRead}

-

Total sentences: ${this.totalSentences}

- - -
- `; - - document.getElementById('reading-area').innerHTML = completionMessage; - } - - start() { - logSh('📖 Story Reader: Starting', 'INFO'); - this.startReadingTimer(); - } - - startReadingTimer() { - this.startTime = Date.now(); - this.readingTimer = setInterval(() => { - this.updateReadingTime(); - }, 1000); - } - - updateReadingTime() { - const currentTime = Date.now(); - this.totalReadingTime = Math.floor((currentTime - this.startTime) / 1000); - - const minutes = Math.floor(this.totalReadingTime / 60); - const seconds = this.totalReadingTime % 60; - const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - - document.getElementById('reading-time').textContent = timeString; - } - - restart() { - this.currentSentence = 0; - this.wordsRead = 0; - // Restart reading timer - if (this.readingTimer) { - clearInterval(this.readingTimer); - } - this.startReadingTimer(); - this.renderCurrentSentence(); - this.saveProgress(); - } - - destroy() { - // Clean up timer - if (this.readingTimer) { - clearInterval(this.readingTimer); - } - this.container.innerHTML = ''; - } -} - -// Module registration -window.GameModules = window.GameModules || {}; -window.GameModules.StoryReader = StoryReader; \ No newline at end of file diff --git a/src/games/whack-a-mole-hard.js b/src/games/whack-a-mole-hard.js deleted file mode 100644 index 6eee5dc..0000000 --- a/src/games/whack-a-mole-hard.js +++ /dev/null @@ -1,703 +0,0 @@ -// === MODULE WHACK-A-MOLE HARD === - -class WhackAMoleHardGame { - constructor(options) { - this.container = options.container; - this.content = options.content; - this.onScoreUpdate = options.onScoreUpdate || (() => {}); - this.onGameEnd = options.onGameEnd || (() => {}); - - // Game state - this.score = 0; - this.errors = 0; - this.maxErrors = 3; - this.gameTime = 60; // 60 seconds - this.timeLeft = this.gameTime; - this.isRunning = false; - this.gameMode = 'translation'; // 'translation', 'image', 'sound' - this.showPronunciation = false; // Track pronunciation display state - - // Mole configuration - this.holes = []; - this.activeMoles = []; - this.moleAppearTime = 3000; // 3 seconds display time (longer) - this.spawnRate = 2000; // New wave every 2 seconds - this.molesPerWave = 3; // 3 moles per wave - - // Timers - this.gameTimer = null; - this.spawnTimer = null; - - // Vocabulary for this game - adapted for the new system - this.vocabulary = this.extractVocabulary(this.content); - this.currentWords = []; - this.targetWord = null; - - // Target word guarantee system - this.spawnsSinceTarget = 0; - this.maxSpawnsWithoutTarget = 10; // Target word must appear in the next 10 moles (1/10 chance) - - this.init(); - } - - init() { - // Check that we have vocabulary - if (!this.vocabulary || this.vocabulary.length === 0) { - logSh('No vocabulary available for Whack-a-Mole', 'ERROR'); - this.showInitError(); - return; - } - - this.createGameBoard(); - this.createGameUI(); - this.setupEventListeners(); - } - - showInitError() { - this.container.innerHTML = ` -
-

❌ Loading Error

-

This content does not contain vocabulary compatible with Whack-a-Mole.

-

The game requires words with their translations.

- -
- `; - } - - createGameBoard() { - this.container.innerHTML = ` -
- -
- - - -
- - -
-
-
- ${this.timeLeft} - Time -
-
- ${this.errors} - Errors -
-
- --- - Find -
-
-
- - - - -
-
- - -
- -
- - - -
- `; - - this.createHoles(); - } - - createHoles() { - const gameBoard = document.getElementById('game-board'); - gameBoard.innerHTML = ''; - - for (let i = 0; i < 15; i++) { // 5x3 = 15 holes - const hole = document.createElement('div'); - hole.className = 'whack-hole'; - hole.dataset.holeId = i; - - hole.innerHTML = ` -
- -
-
- `; - - gameBoard.appendChild(hole); - this.holes.push({ - element: hole, - mole: hole.querySelector('.whack-mole'), - wordElement: hole.querySelector('.word'), - pronunciationElement: hole.querySelector('.pronunciation'), - isActive: false, - word: null, - timer: null - }); - } - } - - createGameUI() { - // UI elements are already created in createGameBoard - } - - setupEventListeners() { - // Mode selection - document.querySelectorAll('.mode-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - if (this.isRunning) return; - - document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - this.gameMode = btn.dataset.mode; - - if (this.gameMode !== 'translation') { - this.showFeedback('This mode will be available soon!', 'info'); - // Return to translation mode - document.querySelector('.mode-btn[data-mode="translation"]').classList.add('active'); - btn.classList.remove('active'); - this.gameMode = 'translation'; - } - }); - }); - - // Game controls - document.getElementById('pronunciation-btn').addEventListener('click', () => this.togglePronunciation()); - document.getElementById('start-btn').addEventListener('click', () => this.start()); - document.getElementById('pause-btn').addEventListener('click', () => this.pause()); - document.getElementById('restart-btn').addEventListener('click', () => this.restart()); - - // Mole clicks - this.holes.forEach((hole, index) => { - hole.mole.addEventListener('click', () => this.hitMole(index)); - }); - } - - start() { - if (this.isRunning) return; - - this.isRunning = true; - this.score = 0; - this.errors = 0; - this.timeLeft = this.gameTime; - - this.updateUI(); - this.setNewTarget(); - this.startTimers(); - - document.getElementById('start-btn').disabled = true; - document.getElementById('pause-btn').disabled = false; - - this.showFeedback(`Find the word: "${this.targetWord.translation}"`, 'info'); - - // Show loaded content info - const contentName = this.content.name || 'Content'; - logSh(`🎮 Whack-a-Mole started with: ${contentName} (${this.vocabulary.length} words, 'INFO');`); - } - - pause() { - if (!this.isRunning) return; - - this.isRunning = false; - this.stopTimers(); - this.hideAllMoles(); - - document.getElementById('start-btn').disabled = false; - document.getElementById('pause-btn').disabled = true; - - this.showFeedback('Game paused', 'info'); - } - - restart() { - this.stopWithoutEnd(); // Stop without triggering game end - this.resetGame(); - setTimeout(() => this.start(), 100); - } - - togglePronunciation() { - this.showPronunciation = !this.showPronunciation; - const btn = document.getElementById('pronunciation-btn'); - - if (this.showPronunciation) { - btn.textContent = '🔊 Pronunciation ON'; - btn.classList.add('active'); - } else { - btn.textContent = '🔊 Pronunciation OFF'; - btn.classList.remove('active'); - } - - // Update currently visible moles - this.updateMoleDisplay(); - } - - updateMoleDisplay() { - // Update pronunciation display for all active moles - this.holes.forEach(hole => { - if (hole.isActive && hole.word) { - if (this.showPronunciation && hole.word.pronunciation) { - hole.pronunciationElement.textContent = hole.word.pronunciation; - hole.pronunciationElement.style.display = 'block'; - } else { - hole.pronunciationElement.style.display = 'none'; - } - } - }); - } - - stop() { - this.stopWithoutEnd(); - this.onGameEnd(this.score); // Trigger game end only here - } - - stopWithoutEnd() { - this.isRunning = false; - this.stopTimers(); - this.hideAllMoles(); - - document.getElementById('start-btn').disabled = false; - document.getElementById('pause-btn').disabled = true; - } - - resetGame() { - // Ensure everything is completely stopped - this.stopWithoutEnd(); - - // Reset all state variables - this.score = 0; - this.errors = 0; - this.timeLeft = this.gameTime; - this.isRunning = false; - this.targetWord = null; - this.activeMoles = []; - this.spawnsSinceTarget = 0; // Reset guarantee counter - - // Ensure all timers are properly stopped - this.stopTimers(); - - // Reset UI - this.updateUI(); - this.onScoreUpdate(0); - - // Clear feedback - document.getElementById('target-word').textContent = '---'; - this.showFeedback('Select a mode and click Start!', 'info'); - - // Reset buttons - document.getElementById('start-btn').disabled = false; - document.getElementById('pause-btn').disabled = true; - - // Clear all holes with verification - this.holes.forEach(hole => { - if (hole.timer) { - clearTimeout(hole.timer); - hole.timer = null; - } - hole.isActive = false; - hole.word = null; - if (hole.wordElement) { - hole.wordElement.textContent = ''; - } - if (hole.pronunciationElement) { - hole.pronunciationElement.textContent = ''; - hole.pronunciationElement.style.display = 'none'; - } - if (hole.mole) { - hole.mole.classList.remove('active', 'hit'); - } - }); - - logSh('🔄 Game completely reset', 'INFO'); - } - - startTimers() { - // Main game timer - this.gameTimer = setInterval(() => { - this.timeLeft--; - this.updateUI(); - - if (this.timeLeft <= 0 && this.isRunning) { - this.stop(); - } - }, 1000); - - // Mole spawn timer - this.spawnTimer = setInterval(() => { - if (this.isRunning) { - this.spawnMole(); - } - }, this.spawnRate); - - // First immediate mole - setTimeout(() => this.spawnMole(), 500); - } - - stopTimers() { - if (this.gameTimer) { - clearInterval(this.gameTimer); - this.gameTimer = null; - } - if (this.spawnTimer) { - clearInterval(this.spawnTimer); - this.spawnTimer = null; - } - } - - spawnMole() { - // Hard mode: Spawn 3 moles at once - this.spawnMultipleMoles(); - } - - spawnMultipleMoles() { - // Find all free holes - const availableHoles = this.holes.filter(hole => !hole.isActive); - - // Spawn up to 3 moles (or fewer if not enough free holes) - const molesToSpawn = Math.min(this.molesPerWave, availableHoles.length); - - if (molesToSpawn === 0) return; - - // Shuffle available holes - const shuffledHoles = this.shuffleArray(availableHoles); - - // Spawn the moles - for (let i = 0; i < molesToSpawn; i++) { - const hole = shuffledHoles[i]; - const holeIndex = this.holes.indexOf(hole); - - // Choose a word according to guarantee strategy - const word = this.getWordWithTargetGuarantee(); - - // Activate the mole with a small delay for visual effect - setTimeout(() => { - if (this.isRunning && !hole.isActive) { - this.activateMole(holeIndex, word); - } - }, i * 200); // 200ms delay between each mole - } - } - - getWordWithTargetGuarantee() { - // Increment spawn counter since last target word - this.spawnsSinceTarget++; - - // If we've reached the limit, force the target word - if (this.spawnsSinceTarget >= this.maxSpawnsWithoutTarget) { - logSh(`🎯 Forced target word spawn after ${this.spawnsSinceTarget} attempts`, 'INFO'); - this.spawnsSinceTarget = 0; - return this.targetWord; - } - - // Otherwise, 10% chance for target word (1/10 instead of 1/2) - if (Math.random() < 0.1) { - logSh('🎯 Natural target word spawn (1/10)', 'INFO'); - this.spawnsSinceTarget = 0; - return this.targetWord; - } else { - return this.getRandomWord(); - } - } - - activateMole(holeIndex, word) { - const hole = this.holes[holeIndex]; - if (hole.isActive) return; - - hole.isActive = true; - hole.word = word; - hole.wordElement.textContent = word.original; - - // Show pronunciation if enabled and available - if (this.showPronunciation && word.pronunciation) { - hole.pronunciationElement.textContent = word.pronunciation; - hole.pronunciationElement.style.display = 'block'; - } else { - hole.pronunciationElement.style.display = 'none'; - } - - hole.mole.classList.add('active'); - - // Add to active moles list - this.activeMoles.push(holeIndex); - - // Timer to make the mole disappear - hole.timer = setTimeout(() => { - this.deactivateMole(holeIndex); - }, this.moleAppearTime); - } - - deactivateMole(holeIndex) { - const hole = this.holes[holeIndex]; - if (!hole.isActive) return; - - hole.isActive = false; - hole.word = null; - hole.wordElement.textContent = ''; - hole.pronunciationElement.textContent = ''; - hole.pronunciationElement.style.display = 'none'; - hole.mole.classList.remove('active'); - - if (hole.timer) { - clearTimeout(hole.timer); - hole.timer = null; - } - - // Remove from active moles list - const activeIndex = this.activeMoles.indexOf(holeIndex); - if (activeIndex > -1) { - this.activeMoles.splice(activeIndex, 1); - } - } - - hitMole(holeIndex) { - if (!this.isRunning) return; - - const hole = this.holes[holeIndex]; - if (!hole.isActive || !hole.word) return; - - const isCorrect = hole.word.translation === this.targetWord.translation; - - if (isCorrect) { - // Correct answer - this.score += 10; - this.deactivateMole(holeIndex); - this.setNewTarget(); - this.showScorePopup(holeIndex, '+10', true); - this.showFeedback(`Well done! Now find: "${this.targetWord.translation}"`, 'success'); - - // Success animation - hole.mole.classList.add('hit'); - setTimeout(() => hole.mole.classList.remove('hit'), 500); - - } else { - // Wrong answer - this.errors++; - this.score = Math.max(0, this.score - 2); - this.showScorePopup(holeIndex, '-2', false); - this.showFeedback(`Oops! "${hole.word.translation}" ≠ "${this.targetWord.translation}"`, 'error'); - } - - this.updateUI(); - this.onScoreUpdate(this.score); - - // Check game end by errors - if (this.errors >= this.maxErrors) { - this.showFeedback('Too many errors! Game over.', 'error'); - setTimeout(() => { - if (this.isRunning) { // Check if game is still running - this.stop(); - } - }, 1500); - } - } - - setNewTarget() { - // Choose a new target word - const availableWords = this.vocabulary.filter(word => - !this.activeMoles.some(moleIndex => - this.holes[moleIndex].word && - this.holes[moleIndex].word.original === word.original - ) - ); - - if (availableWords.length > 0) { - this.targetWord = availableWords[Math.floor(Math.random() * availableWords.length)]; - } else { - this.targetWord = this.vocabulary[Math.floor(Math.random() * this.vocabulary.length)]; - } - - // Reset counter for new target word - this.spawnsSinceTarget = 0; - logSh(`🎯 New target word: ${this.targetWord.original} -> ${this.targetWord.translation}`, 'INFO'); - - document.getElementById('target-word').textContent = this.targetWord.translation; - } - - getRandomWord() { - return this.vocabulary[Math.floor(Math.random() * this.vocabulary.length)]; - } - - hideAllMoles() { - this.holes.forEach((hole, index) => { - if (hole.isActive) { - this.deactivateMole(index); - } - }); - this.activeMoles = []; - } - - showScorePopup(holeIndex, scoreText, isPositive) { - const hole = this.holes[holeIndex]; - const popup = document.createElement('div'); - popup.className = `score-popup ${isPositive ? 'correct-answer' : 'wrong-answer'}`; - popup.textContent = scoreText; - - const rect = hole.element.getBoundingClientRect(); - popup.style.left = rect.left + rect.width / 2 + 'px'; - popup.style.top = rect.top + 'px'; - - document.body.appendChild(popup); - - setTimeout(() => { - if (popup.parentNode) { - popup.parentNode.removeChild(popup); - } - }, 1000); - } - - showFeedback(message, type = 'info') { - const feedbackArea = document.getElementById('feedback-area'); - feedbackArea.innerHTML = `
${message}
`; - } - - updateUI() { - document.getElementById('time-left').textContent = this.timeLeft; - document.getElementById('errors-count').textContent = this.errors; - } - - extractVocabulary(content) { - let vocabulary = []; - - logSh('🔍 Extracting vocabulary from:', content?.name || 'content', 'INFO'); - - // Priority 1: Use raw module content (simple format) - if (content.rawContent) { - logSh('📦 Using raw module content', 'INFO'); - return this.extractVocabularyFromRaw(content.rawContent); - } - - // Priority 2: Ultra-modular format (vocabulary object) - ONLY format supported - if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) { - logSh('✨ Ultra-modular format detected (vocabulary object)', 'INFO'); - vocabulary = Object.entries(content.vocabulary).map(([word, data]) => { - // Support ultra-modular format ONLY - if (typeof data === 'object' && data.user_language) { - return { - original: word, // Clé = original_language - translation: data.user_language.split(';')[0], // First translation - fullTranslation: data.user_language, // Complete translation - type: data.type || 'general', - audio: data.audio, - image: data.image, - examples: data.examples, - pronunciation: data.pronunciation, - category: data.type || 'general' - }; - } - // Legacy fallback - simple string (temporary, will be removed) - else if (typeof data === 'string') { - return { - original: word, - translation: data.split(';')[0], - fullTranslation: data, - type: 'general', - category: 'general' - }; - } - return null; - }).filter(Boolean); - } - // No other formats supported - ultra-modular only - - return this.finalizeVocabulary(vocabulary); - } - - extractVocabularyFromRaw(rawContent) { - logSh('🔧 Extracting from raw content:', rawContent.name || 'Module', 'INFO'); - let vocabulary = []; - - // Ultra-modular format (vocabulary object) - ONLY format supported - if (rawContent.vocabulary && typeof rawContent.vocabulary === 'object' && !Array.isArray(rawContent.vocabulary)) { - vocabulary = Object.entries(rawContent.vocabulary).map(([word, data]) => { - // Support ultra-modular format ONLY - if (typeof data === 'object' && data.user_language) { - return { - original: word, // Clé = original_language - translation: data.user_language.split(';')[0], // First translation - fullTranslation: data.user_language, // Complete translation - type: data.type || 'general', - audio: data.audio, - image: data.image, - examples: data.examples, - pronunciation: data.pronunciation, - category: data.type || 'general' - }; - } - // Legacy fallback - simple string (temporary, will be removed) - else if (typeof data === 'string') { - return { - original: word, - translation: data.split(';')[0], - fullTranslation: data, - type: 'general', - category: 'general' - }; - } - return null; - }).filter(Boolean); - logSh(`✨ ${vocabulary.length} words extracted from ultra-modular vocabulary`, 'INFO'); - } - // No other formats supported - ultra-modular only - else { - logSh('⚠️ Content format not supported - ultra-modular format required', 'WARN'); - } - - return this.finalizeVocabulary(vocabulary); - } - - finalizeVocabulary(vocabulary) { - // Validation and cleanup for ultra-modular format - vocabulary = vocabulary.filter(word => - word && - typeof word.original === 'string' && - typeof word.translation === 'string' && - word.original.trim() !== '' && - word.translation.trim() !== '' - ); - - if (vocabulary.length === 0) { - logSh('❌ No valid vocabulary found', 'ERROR'); - // Demo vocabulary as last resort - vocabulary = [ - { original: 'hello', translation: 'bonjour', category: 'greetings' }, - { original: 'goodbye', translation: 'au revoir', category: 'greetings' }, - { original: 'thank you', translation: 'merci', category: 'greetings' }, - { original: 'cat', translation: 'chat', category: 'animals' }, - { original: 'dog', translation: 'chien', category: 'animals' } - ]; - logSh('🚨 Using demo vocabulary', 'WARN'); - } - - logSh(`✅ Whack-a-Mole: ${vocabulary.length} vocabulary words finalized`, 'INFO'); - return this.shuffleArray(vocabulary); - } - - 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; - } - - destroy() { - this.stop(); - this.container.innerHTML = ''; - } -} - -// Module registration -window.GameModules = window.GameModules || {}; -window.GameModules.WhackAMoleHard = WhackAMoleHardGame; \ No newline at end of file diff --git a/src/games/whack-a-mole.js b/src/games/whack-a-mole.js deleted file mode 100644 index fc69242..0000000 --- a/src/games/whack-a-mole.js +++ /dev/null @@ -1,685 +0,0 @@ -// === MODULE WHACK-A-MOLE === - -class WhackAMoleGame { - constructor(options) { - this.container = options.container; - this.content = options.content; - this.onScoreUpdate = options.onScoreUpdate || (() => {}); - this.onGameEnd = options.onGameEnd || (() => {}); - - // Game state - this.score = 0; - this.errors = 0; - this.maxErrors = 3; - this.gameTime = 60; // 60 secondes - this.timeLeft = this.gameTime; - this.isRunning = false; - this.gameMode = 'translation'; // 'translation', 'image', 'sound' - this.showPronunciation = false; // Track pronunciation display state - - // Mole configuration - this.holes = []; - this.activeMoles = []; - this.moleAppearTime = 2000; // 2 seconds display time - this.spawnRate = 1500; // New mole every 1.5 seconds - - // Timers - this.gameTimer = null; - this.spawnTimer = null; - - // Vocabulary for this game - adapted for the new system - this.vocabulary = this.extractVocabulary(this.content); - this.currentWords = []; - this.targetWord = null; - - // Target word guarantee system - this.spawnsSinceTarget = 0; - this.maxSpawnsWithoutTarget = 3; // Target word must appear in the next 3 moles - - this.init(); - } - - init() { - // Check that we have vocabulary - if (!this.vocabulary || this.vocabulary.length === 0) { - logSh('No vocabulary available for Whack-a-Mole', 'ERROR'); - this.showInitError(); - return; - } - - this.createGameBoard(); - this.createGameUI(); - this.setupEventListeners(); - } - - showInitError() { - this.container.innerHTML = ` -
-

❌ Loading Error

-

This content does not contain vocabulary compatible with Whack-a-Mole.

-

The game requires words with their translations.

- -
- `; - } - - createGameBoard() { - this.container.innerHTML = ` -
- -
- - - -
- - -
-
-
- ${this.timeLeft} - Time -
-
- ${this.errors} - Errors -
-
- --- - Find -
-
-
- - - - -
-
- - -
- -
- - - -
- `; - - this.createHoles(); - } - - createHoles() { - const gameBoard = document.getElementById('game-board'); - gameBoard.innerHTML = ''; - - for (let i = 0; i < 9; i++) { - const hole = document.createElement('div'); - hole.className = 'whack-hole'; - hole.dataset.holeId = i; - - hole.innerHTML = ` -
- -
-
- `; - - gameBoard.appendChild(hole); - this.holes.push({ - element: hole, - mole: hole.querySelector('.whack-mole'), - wordElement: hole.querySelector('.word'), - pronunciationElement: hole.querySelector('.pronunciation'), - isActive: false, - word: null, - timer: null - }); - } - } - - createGameUI() { - // UI elements are already created in createGameBoard - } - - setupEventListeners() { - // Mode selection - document.querySelectorAll('.mode-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - if (this.isRunning) return; - - document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - this.gameMode = btn.dataset.mode; - - if (this.gameMode !== 'translation') { - this.showFeedback('This mode will be available soon!', 'info'); - // Return to translation mode - document.querySelector('.mode-btn[data-mode="translation"]').classList.add('active'); - btn.classList.remove('active'); - this.gameMode = 'translation'; - } - }); - }); - - // Game controls - document.getElementById('pronunciation-btn').addEventListener('click', () => this.togglePronunciation()); - document.getElementById('start-btn').addEventListener('click', () => this.start()); - document.getElementById('pause-btn').addEventListener('click', () => this.pause()); - document.getElementById('restart-btn').addEventListener('click', () => this.restart()); - - // Mole clicks - this.holes.forEach((hole, index) => { - hole.mole.addEventListener('click', () => this.hitMole(index)); - }); - } - - start() { - if (this.isRunning) return; - - this.isRunning = true; - this.score = 0; - this.errors = 0; - this.timeLeft = this.gameTime; - - this.updateUI(); - this.setNewTarget(); - this.startTimers(); - - document.getElementById('start-btn').disabled = true; - document.getElementById('pause-btn').disabled = false; - - this.showFeedback(`Find the word: "${this.targetWord.translation}"`, 'info'); - - // Show loaded content info - const contentName = this.content.name || 'Content'; - logSh(`🎮 Whack-a-Mole started with: ${contentName} (${this.vocabulary.length} words, 'INFO');`); - } - - pause() { - if (!this.isRunning) return; - - this.isRunning = false; - this.stopTimers(); - this.hideAllMoles(); - - document.getElementById('start-btn').disabled = false; - document.getElementById('pause-btn').disabled = true; - - this.showFeedback('Game paused', 'info'); - } - - restart() { - this.stopWithoutEnd(); // Stop without triggering game end - this.resetGame(); - setTimeout(() => this.start(), 100); - } - - togglePronunciation() { - this.showPronunciation = !this.showPronunciation; - const btn = document.getElementById('pronunciation-btn'); - - if (this.showPronunciation) { - btn.textContent = '🔊 Pronunciation ON'; - btn.classList.add('active'); - } else { - btn.textContent = '🔊 Pronunciation OFF'; - btn.classList.remove('active'); - } - - // Update currently visible moles - this.updateMoleDisplay(); - } - - updateMoleDisplay() { - // Update pronunciation display for all active moles - this.holes.forEach(hole => { - if (hole.isActive && hole.word) { - if (this.showPronunciation && hole.word.pronunciation) { - hole.pronunciationElement.textContent = hole.word.pronunciation; - hole.pronunciationElement.style.display = 'block'; - } else { - hole.pronunciationElement.style.display = 'none'; - } - } - }); - } - - stop() { - this.stopWithoutEnd(); - this.onGameEnd(this.score); // Trigger game end only here - } - - stopWithoutEnd() { - this.isRunning = false; - this.stopTimers(); - this.hideAllMoles(); - - document.getElementById('start-btn').disabled = false; - document.getElementById('pause-btn').disabled = true; - } - - resetGame() { - // Ensure everything is completely stopped - this.stopWithoutEnd(); - - // Reset all state variables - this.score = 0; - this.errors = 0; - this.timeLeft = this.gameTime; - this.isRunning = false; - this.targetWord = null; - this.activeMoles = []; - this.spawnsSinceTarget = 0; // Reset guarantee counter - - // Ensure all timers are properly stopped - this.stopTimers(); - - // Reset UI - this.updateUI(); - this.onScoreUpdate(0); - - // Clear feedback - document.getElementById('target-word').textContent = '---'; - this.showFeedback('Select a mode and click Start!', 'info'); - - // Reset buttons - document.getElementById('start-btn').disabled = false; - document.getElementById('pause-btn').disabled = true; - - // Clear all holes with verification - this.holes.forEach(hole => { - if (hole.timer) { - clearTimeout(hole.timer); - hole.timer = null; - } - hole.isActive = false; - hole.word = null; - if (hole.wordElement) { - hole.wordElement.textContent = ''; - } - if (hole.pronunciationElement) { - hole.pronunciationElement.textContent = ''; - hole.pronunciationElement.style.display = 'none'; - } - if (hole.mole) { - hole.mole.classList.remove('active', 'hit'); - } - }); - - logSh('🔄 Game completely reset', 'INFO'); - } - - startTimers() { - // Main game timer - this.gameTimer = setInterval(() => { - this.timeLeft--; - this.updateUI(); - - if (this.timeLeft <= 0 && this.isRunning) { - this.stop(); - } - }, 1000); - - // Mole spawn timer - this.spawnTimer = setInterval(() => { - if (this.isRunning) { - this.spawnMole(); - } - }, this.spawnRate); - - // First immediate mole - setTimeout(() => this.spawnMole(), 500); - } - - stopTimers() { - if (this.gameTimer) { - clearInterval(this.gameTimer); - this.gameTimer = null; - } - if (this.spawnTimer) { - clearInterval(this.spawnTimer); - this.spawnTimer = null; - } - } - - spawnMole() { - // Find a free hole - const availableHoles = this.holes.filter(hole => !hole.isActive); - if (availableHoles.length === 0) return; - - const randomHole = availableHoles[Math.floor(Math.random() * availableHoles.length)]; - const holeIndex = this.holes.indexOf(randomHole); - - // Choose a word according to guarantee strategy - const word = this.getWordWithTargetGuarantee(); - - // Activate the mole - this.activateMole(holeIndex, word); - } - - getWordWithTargetGuarantee() { - // Increment spawn counter since last target word - this.spawnsSinceTarget++; - - // If we've reached the limit, force the target word - if (this.spawnsSinceTarget >= this.maxSpawnsWithoutTarget) { - logSh(`🎯 Forced target word spawn after ${this.spawnsSinceTarget} attempts`, 'INFO'); - this.spawnsSinceTarget = 0; - return this.targetWord; - } - - // Otherwise, 50% chance for target word, 50% random word - if (Math.random() < 0.5) { - logSh('🎯 Natural target word spawn', 'INFO'); - this.spawnsSinceTarget = 0; - return this.targetWord; - } else { - return this.getRandomWord(); - } - } - - activateMole(holeIndex, word) { - const hole = this.holes[holeIndex]; - if (hole.isActive) return; - - hole.isActive = true; - hole.word = word; - hole.wordElement.textContent = word.original; - - // Show pronunciation if enabled and available - if (this.showPronunciation && word.pronunciation) { - hole.pronunciationElement.textContent = word.pronunciation; - hole.pronunciationElement.style.display = 'block'; - } else { - hole.pronunciationElement.style.display = 'none'; - } - - hole.mole.classList.add('active'); - - // Add to active moles list - this.activeMoles.push(holeIndex); - - // Timer to make the mole disappear - hole.timer = setTimeout(() => { - this.deactivateMole(holeIndex); - }, this.moleAppearTime); - } - - deactivateMole(holeIndex) { - const hole = this.holes[holeIndex]; - if (!hole.isActive) return; - - hole.isActive = false; - hole.word = null; - hole.wordElement.textContent = ''; - hole.pronunciationElement.textContent = ''; - hole.pronunciationElement.style.display = 'none'; - hole.mole.classList.remove('active'); - - if (hole.timer) { - clearTimeout(hole.timer); - hole.timer = null; - } - - // Remove from active moles list - const activeIndex = this.activeMoles.indexOf(holeIndex); - if (activeIndex > -1) { - this.activeMoles.splice(activeIndex, 1); - } - } - - hitMole(holeIndex) { - if (!this.isRunning) return; - - const hole = this.holes[holeIndex]; - if (!hole.isActive || !hole.word) return; - - const isCorrect = hole.word.translation === this.targetWord.translation; - - if (isCorrect) { - // Correct answer - this.score += 10; - this.deactivateMole(holeIndex); - this.setNewTarget(); - this.showScorePopup(holeIndex, '+10', true); - this.showFeedback(`Well done! Now find: "${this.targetWord.translation}"`, 'success'); - - // Success animation - hole.mole.classList.add('hit'); - setTimeout(() => hole.mole.classList.remove('hit'), 500); - - } else { - // Wrong answer - this.errors++; - this.score = Math.max(0, this.score - 2); - this.showScorePopup(holeIndex, '-2', false); - this.showFeedback(`Oops! "${hole.word.translation}" ≠ "${this.targetWord.translation}"`, 'error'); - } - - this.updateUI(); - this.onScoreUpdate(this.score); - - // Check game end by errors - if (this.errors >= this.maxErrors) { - this.showFeedback('Too many errors! Game over.', 'error'); - setTimeout(() => { - if (this.isRunning) { // Check if game is still running - this.stop(); - } - }, 1500); - } - } - - setNewTarget() { - // Choose a new target word - const availableWords = this.vocabulary.filter(word => - !this.activeMoles.some(moleIndex => - this.holes[moleIndex].word && - this.holes[moleIndex].word.original === word.original - ) - ); - - if (availableWords.length > 0) { - this.targetWord = availableWords[Math.floor(Math.random() * availableWords.length)]; - } else { - this.targetWord = this.vocabulary[Math.floor(Math.random() * this.vocabulary.length)]; - } - - // Reset counter for new target word - this.spawnsSinceTarget = 0; - logSh(`🎯 New target word: ${this.targetWord.original} -> ${this.targetWord.translation}`, 'INFO'); - - document.getElementById('target-word').textContent = this.targetWord.translation; - } - - getRandomWord() { - return this.vocabulary[Math.floor(Math.random() * this.vocabulary.length)]; - } - - hideAllMoles() { - this.holes.forEach((hole, index) => { - if (hole.isActive) { - this.deactivateMole(index); - } - }); - this.activeMoles = []; - } - - showScorePopup(holeIndex, scoreText, isPositive) { - const hole = this.holes[holeIndex]; - const popup = document.createElement('div'); - popup.className = `score-popup ${isPositive ? 'correct-answer' : 'wrong-answer'}`; - popup.textContent = scoreText; - - const rect = hole.element.getBoundingClientRect(); - popup.style.left = rect.left + rect.width / 2 + 'px'; - popup.style.top = rect.top + 'px'; - - document.body.appendChild(popup); - - setTimeout(() => { - if (popup.parentNode) { - popup.parentNode.removeChild(popup); - } - }, 1000); - } - - showFeedback(message, type = 'info') { - const feedbackArea = document.getElementById('feedback-area'); - feedbackArea.innerHTML = `
${message}
`; - } - - updateUI() { - document.getElementById('time-left').textContent = this.timeLeft; - document.getElementById('errors-count').textContent = this.errors; - } - - extractVocabulary(content) { - let vocabulary = []; - - logSh('🔍 Extracting vocabulary from:', content?.name || 'content', 'INFO'); - - // Priority 1: Use raw module content (simple format) - if (content.rawContent) { - logSh('📦 Using raw module content', 'INFO'); - return this.extractVocabularyFromRaw(content.rawContent); - } - - // Priority 2: Ultra-modular format (vocabulary object) - ONLY format supported - if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) { - logSh('✨ Ultra-modular format detected (vocabulary object)', 'INFO'); - vocabulary = Object.entries(content.vocabulary).map(([word, data]) => { - // Support ultra-modular format and new centralized vocabulary format - if (typeof data === 'object' && (data.user_language || data.translation)) { - const translationText = data.user_language || data.translation; - return { - original: word, // Clé = original_language - translation: translationText.split(';')[0], // First translation - fullTranslation: translationText, // Complete translation - type: data.type || 'general', - audio: data.audio, - image: data.image, - examples: data.examples, - pronunciation: data.pronunciation, - category: data.type || 'general' - }; - } - // Legacy fallback - simple string (temporary, will be removed) - else if (typeof data === 'string') { - return { - original: word, - translation: data.split(';')[0], - fullTranslation: data, - type: 'general', - category: 'general' - }; - } - return null; - }).filter(Boolean); - } - // No other formats supported - ultra-modular only - - return this.finalizeVocabulary(vocabulary); - } - - extractVocabularyFromRaw(rawContent) { - logSh('🔧 Extracting from raw content:', rawContent.name || 'Module', 'INFO'); - let vocabulary = []; - - // Ultra-modular format (vocabulary object) - ONLY format supported - if (rawContent.vocabulary && typeof rawContent.vocabulary === 'object' && !Array.isArray(rawContent.vocabulary)) { - vocabulary = Object.entries(rawContent.vocabulary).map(([word, data]) => { - // Support ultra-modular format and new centralized vocabulary format - if (typeof data === 'object' && (data.user_language || data.translation)) { - const translationText = data.user_language || data.translation; - return { - original: word, // Clé = original_language - translation: translationText.split(';')[0], // First translation - fullTranslation: translationText, // Complete translation - type: data.type || 'general', - audio: data.audio, - image: data.image, - examples: data.examples, - pronunciation: data.pronunciation, - category: data.type || 'general' - }; - } - // Legacy fallback - simple string (temporary, will be removed) - else if (typeof data === 'string') { - return { - original: word, - translation: data.split(';')[0], - fullTranslation: data, - type: 'general', - category: 'general' - }; - } - return null; - }).filter(Boolean); - logSh(`✨ ${vocabulary.length} words extracted from ultra-modular vocabulary`, 'INFO'); - } - // No other formats supported - ultra-modular only - else { - logSh('⚠️ Content format not supported - ultra-modular format required', 'WARN'); - } - - return this.finalizeVocabulary(vocabulary); - } - - finalizeVocabulary(vocabulary) { - // Validation and cleanup for ultra-modular format - vocabulary = vocabulary.filter(word => - word && - typeof word.original === 'string' && - typeof word.translation === 'string' && - word.original.trim() !== '' && - word.translation.trim() !== '' - ); - - if (vocabulary.length === 0) { - logSh('❌ No valid vocabulary found', 'ERROR'); - // Demo vocabulary as last resort - vocabulary = [ - { original: 'hello', translation: 'bonjour', category: 'greetings' }, - { original: 'goodbye', translation: 'au revoir', category: 'greetings' }, - { original: 'thank you', translation: 'merci', category: 'greetings' }, - { original: 'cat', translation: 'chat', category: 'animals' }, - { original: 'dog', translation: 'chien', category: 'animals' } - ]; - logSh('🚨 Using demo vocabulary', 'WARN'); - } - - logSh(`✅ Whack-a-Mole: ${vocabulary.length} vocabulary words finalized`, 'INFO'); - return this.shuffleArray(vocabulary); - } - - 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; - } - - destroy() { - this.stop(); - this.container.innerHTML = ''; - } -} - -// Module registration -window.GameModules = window.GameModules || {}; -window.GameModules.WhackAMole = WhackAMoleGame; \ No newline at end of file diff --git a/src/games/word-discovery.js b/src/games/word-discovery.js deleted file mode 100644 index b16cbd4..0000000 --- a/src/games/word-discovery.js +++ /dev/null @@ -1,1046 +0,0 @@ -class WordDiscovery { - constructor({ container, content, onScoreUpdate, onGameEnd }) { - this.container = container; - this.content = content; - this.onScoreUpdate = onScoreUpdate; - this.onGameEnd = onGameEnd; - - // Expose content globally for SettingsManager TTS language detection - window.currentGameContent = content; - - this.currentWordIndex = 0; - this.discoveredWords = []; - this.currentPhase = 'discovery'; // discovery, practice - this.score = 0; - this.lives = 3; - this.wordsToLearn = []; - - // Practice system - Global practice after all words discovered - this.practiceLevel = 1; // 1=Easy, 2=Medium, 3=Hard, 4=Expert - this.practiceRound = 0; - this.maxPracticeRounds = 6; // More rounds for mixed practice - this.practiceCorrectAnswers = 0; - this.practiceErrors = 0; - this.currentPracticeWords = []; // Mixed selection of all discovered words - - this.injectCSS(); - this.extractContent(); - this.init(); - } - - injectCSS() { - if (document.getElementById('word-discovery-styles')) return; - - const styleSheet = document.createElement('style'); - styleSheet.id = 'word-discovery-styles'; - styleSheet.textContent = ` - .word-discovery-wrapper { - display: flex; - flex-direction: column; - height: 100%; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - } - - .discovery-hud { - display: flex; - justify-content: space-between; - align-items: center; - padding: 15px 20px; - background: rgba(0,0,0,0.2); - backdrop-filter: blur(10px); - } - - .discovery-progress { - display: flex; - align-items: center; - gap: 15px; - } - - .progress-bar { - width: 200px; - height: 8px; - background: rgba(255,255,255,0.2); - border-radius: 4px; - overflow: hidden; - } - - .progress-fill { - height: 100%; - background: linear-gradient(90deg, #00ff88, #00cc6a); - transition: width 0.3s ease; - } - - .discovery-main { - flex: 1; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - padding: 20px; - position: relative; - } - - .word-card { - background: white; - border-radius: 20px; - padding: 40px; - box-shadow: 0 20px 40px rgba(0,0,0,0.3); - text-align: center; - max-width: 500px; - width: 100%; - color: #333; - transform: scale(0.9); - opacity: 0; - animation: cardAppear 0.5s ease forwards; - } - - @keyframes cardAppear { - to { - transform: scale(1); - opacity: 1; - } - } - - .word-image { - width: 200px; - height: 200px; - object-fit: cover; - border-radius: 15px; - margin-bottom: 20px; - box-shadow: 0 10px 20px rgba(0,0,0,0.2); - } - - .word-text { - font-size: 2.5em; - font-weight: bold; - color: #2c3e50; - margin-bottom: 10px; - } - - .word-pronunciation { - font-size: 1.2em; - color: #7f8c8d; - margin-bottom: 15px; - font-style: italic; - } - - .word-translation { - font-size: 1.8em; - color: #e74c3c; - font-weight: 600; - margin-bottom: 20px; - } - - .discovery-controls { - display: flex; - gap: 15px; - margin-top: 20px; - } - - .discovery-btn { - padding: 12px 25px; - border: none; - border-radius: 25px; - font-size: 1.1em; - font-weight: 600; - cursor: pointer; - transition: all 0.3s ease; - min-width: 120px; - } - - .btn-primary { - background: linear-gradient(45deg, #667eea, #764ba2); - color: white; - } - - .btn-secondary { - background: linear-gradient(45deg, #f093fb, #f5576c); - color: white; - } - - .btn-success { - background: linear-gradient(45deg, #4facfe, #00f2fe); - color: white; - } - - .discovery-btn:hover { - transform: translateY(-2px); - box-shadow: 0 10px 20px rgba(0,0,0,0.2); - } - - .association-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 30px; - max-width: 800px; - width: 100%; - } - - .association-item { - background: white; - border-radius: 15px; - padding: 20px; - text-align: center; - cursor: pointer; - transition: all 0.3s ease; - color: #333; - border: 3px solid transparent; - } - - .association-item:hover { - transform: translateY(-5px); - box-shadow: 0 15px 30px rgba(0,0,0,0.2); - } - - .association-item.selected { - border-color: #667eea; - background: #f8f9ff; - } - - .association-item.correct { - border-color: #00ff88; - background: #f0fff4; - } - - .association-item.incorrect { - border-color: #ff4757; - background: #fff0f0; - } - - .association-image { - width: 120px; - height: 120px; - object-fit: cover; - border-radius: 10px; - margin-bottom: 15px; - } - - .association-text { - font-size: 1.4em; - font-weight: 600; - } - - .phase-indicator { - position: absolute; - top: 20px; - left: 20px; - background: rgba(255,255,255,0.2); - padding: 8px 16px; - border-radius: 20px; - font-weight: 600; - backdrop-filter: blur(10px); - } - - .practice-progress { - position: absolute; - top: 20px; - right: 20px; - background: rgba(255,255,255,0.2); - padding: 8px 16px; - border-radius: 20px; - font-weight: 600; - backdrop-filter: blur(10px); - font-size: 0.9em; - } - - .difficulty-badge { - display: inline-block; - padding: 4px 12px; - border-radius: 15px; - font-size: 0.8em; - font-weight: bold; - margin-left: 10px; - } - - .difficulty-easy { background: #4CAF50; color: white; } - .difficulty-medium { background: #FF9800; color: white; } - .difficulty-hard { background: #F44336; color: white; } - .difficulty-expert { background: #9C27B0; color: white; } - - .practice-grid-6 { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 20px; - max-width: 900px; - width: 100%; - } - - .practice-grid-8 { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 15px; - max-width: 1000px; - width: 100%; - } - - .practice-challenge { - text-align: center; - margin-bottom: 30px; - padding: 20px; - background: rgba(255,255,255,0.1); - border-radius: 15px; - backdrop-filter: blur(10px); - } - - .challenge-timer { - font-size: 2em; - font-weight: bold; - color: #FFD700; - margin-bottom: 10px; - } - - .challenge-text { - font-size: 1.2em; - margin-bottom: 15px; - } - - .practice-stats { - display: flex; - justify-content: space-around; - margin-top: 20px; - font-size: 1.1em; - } - - .stat-item { - text-align: center; - padding: 10px; - background: rgba(255,255,255,0.1); - border-radius: 10px; - backdrop-filter: blur(5px); - } - - .association-item.time-pressure { - animation: timePressure 0.5s ease-in-out infinite alternate; - } - - @keyframes timePressure { - from { box-shadow: 0 0 10px rgba(255,215,0,0.5); } - to { box-shadow: 0 0 20px rgba(255,215,0,0.8); } - } - - .audio-btn { - background: none; - border: none; - font-size: 2em; - cursor: pointer; - color: #667eea; - margin-left: 10px; - transition: all 0.3s ease; - } - - .audio-btn:hover { - transform: scale(1.2); - color: #764ba2; - } - - .completion-message { - text-align: center; - padding: 40px; - background: rgba(255,255,255,0.1); - border-radius: 20px; - backdrop-filter: blur(10px); - } - - .completion-title { - font-size: 2.5em; - margin-bottom: 20px; - color: #00ff88; - } - - .completion-stats { - font-size: 1.3em; - margin-bottom: 30px; - line-height: 1.6; - } - - .content-warning { - background: rgba(255, 193, 7, 0.2); - border: 2px solid #FFC107; - border-radius: 10px; - padding: 15px; - margin: 20px 0; - color: #856404; - font-size: 0.9em; - } - - .feature-missing { - opacity: 0.6; - position: relative; - } - - .feature-missing::after { - content: '📵'; - position: absolute; - top: 5px; - right: 5px; - font-size: 0.8em; - } - `; - document.head.appendChild(styleSheet); - } - - extractContent() { - if (!this.content || !this.content.vocabulary) { - this.wordsToLearn = []; - return; - } - - this.wordsToLearn = Object.entries(this.content.vocabulary).map(([word, data]) => ({ - word: word, - translation: typeof data === 'string' ? data : data.translation, - pronunciation: typeof data === 'object' ? data.pronunciation : null, - image: typeof data === 'object' ? data.image : null, - type: typeof data === 'object' ? data.type : 'word' - })).filter(item => item.translation); - - // Shuffle words for variety - this.wordsToLearn = this.shuffleArray([...this.wordsToLearn]); - } - - shuffleArray(array) { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } - return array; - } - - init() { - this.container.innerHTML = ` -
-
-
- Progress: -
-
-
- 0/${this.wordsToLearn.length} -
-
- Score: 0 - ❤️ 3 -
-
-
-
Discovery Phase
-
-
-
- `; - - if (this.wordsToLearn.length === 0) { - this.showNoContent(); - return; - } - - this.updateHUD(); - this.startDiscoveryPhase(); - } - - updateHUD() { - const progressFill = this.container.querySelector('.progress-fill'); - const progressText = this.container.querySelector('.progress-text'); - const scoreDisplay = this.container.querySelector('.score-display'); - const livesDisplay = this.container.querySelector('.lives-display'); - - const progressPercent = (this.currentWordIndex / this.wordsToLearn.length) * 100; - progressFill.style.width = `${progressPercent}%`; - progressText.textContent = `${this.currentWordIndex}/${this.wordsToLearn.length}`; - scoreDisplay.textContent = this.score; - livesDisplay.textContent = this.lives; - } - - startDiscoveryPhase() { - this.currentPhase = 'discovery'; - this.container.querySelector('.phase-indicator').textContent = 'Discovery Phase'; - this.showWordCard(); - } - - showWordCard() { - if (this.currentWordIndex >= this.wordsToLearn.length) { - // All words discovered - start global practice phase - this.startGlobalPractice(); - return; - } - - const word = this.wordsToLearn[this.currentWordIndex]; - const gameContent = this.container.querySelector('.game-content'); - - // Check what features are missing for this word - const missingFeatures = []; - if (!word.image) missingFeatures.push('image'); - if (!word.pronunciation) missingFeatures.push('pronunciation'); - - gameContent.innerHTML = ` -
- ${word.image ? - `${word.word}` : - `
📷 No Image
` - } -
${word.word}
- ${word.pronunciation ? - `
/${word.pronunciation}/
` : - `
No pronunciation guide
` - } -
${word.translation}
- ${missingFeatures.length > 0 ? - `
- ⚠️ Missing: ${missingFeatures.join(', ')}. Practice questions will be adapted accordingly. -
` : '' - } -
- - -
-
- `; - - // Store game reference for button access - this.container.querySelector('.word-discovery-wrapper').game = this; - - // Auto-play TTS when new word appears (with delay for card animation) - setTimeout(() => { - this.hearPronunciation(); - }, 800); - } - - async hearPronunciation(options = {}) { - let wordToSpeak; - - if (this.currentPhase === 'practice') { - // In practice phase, use current practice word - wordToSpeak = this.currentPracticeWords[this.practiceRound % this.currentPracticeWords.length]; - } else { - // In discovery phase, use current word being learned - wordToSpeak = this.wordsToLearn[this.currentWordIndex]; - } - - if (!wordToSpeak) return; - - // Try to play audio file first if available - if (wordToSpeak.audioFile || wordToSpeak.pronunciation) { - const audioPath = wordToSpeak.audioFile; - if (audioPath) { - try { - const audio = new Audio(audioPath); - - // Handle audio loading errors - audio.onerror = () => { - console.warn(`Audio file not found: ${audioPath}, falling back to TTS`); - this.fallbackToTTS(wordToSpeak, options); - }; - - // Handle successful audio loading - audio.oncanplaythrough = () => { - // Adjust playback rate if supported - if (options.rate && audio.playbackRate !== undefined) { - audio.playbackRate = options.rate; - } - audio.play().catch(error => { - console.warn('Audio playback failed:', error); - this.fallbackToTTS(wordToSpeak, options); - }); - }; - - // Load the audio - audio.load(); - - // Timeout fallback if audio takes too long - setTimeout(() => { - if (audio.readyState === 0) { - console.warn('Audio loading timeout, falling back to TTS'); - this.fallbackToTTS(wordToSpeak, options); - } - }, 2000); - - return; // Don't proceed to TTS if we're trying audio - } catch (error) { - console.warn('Audio creation failed:', error); - } - } - } - - // Fallback to TTS immediately if no audio file - this.fallbackToTTS(wordToSpeak, options); - } - - fallbackToTTS(wordToSpeak, options = {}) { - // Use SettingsManager if available, otherwise fallback to basic TTS - if (window.SettingsManager) { - // Pass custom rate if specified - const ttsOptions = {}; - if (options.rate) { - ttsOptions.rate = options.rate; - } - - window.SettingsManager.speak(wordToSpeak.word, ttsOptions) - .catch(error => { - console.warn('SettingsManager TTS failed:', error); - this.basicTTS(wordToSpeak, options); - }); - } else { - this.basicTTS(wordToSpeak, options); - } - } - - basicTTS(wordToSpeak, options = {}) { - // Try to speak the word using Web Speech API - if ('speechSynthesis' in window && wordToSpeak) { - const utterance = new SpeechSynthesisUtterance(wordToSpeak.word); - utterance.lang = 'en-US'; - utterance.rate = options.rate || 0.8; - speechSynthesis.speak(utterance); - } else { - // Last resort: show pronunciation text if available - if (wordToSpeak.pronunciation) { - alert(`Pronunciation: /${wordToSpeak.pronunciation}/`); - } else { - alert(`Word: ${wordToSpeak.word}`); - } - } - } - - - updatePhaseIndicator() { - const phaseIndicator = this.container.querySelector('.phase-indicator'); - const difficultyNames = ['', 'Easy', 'Medium', 'Hard', 'Expert']; - const difficultyClasses = ['', 'difficulty-easy', 'difficulty-medium', 'difficulty-hard', 'difficulty-expert']; - - if (this.currentPhase === 'practice') { - phaseIndicator.innerHTML = `Mixed Practice ${difficultyNames[this.practiceLevel]}`; - } else { - phaseIndicator.textContent = 'Discovery Phase'; - } - - // Update or create practice progress indicator - let progressIndicator = this.container.querySelector('.practice-progress'); - if (!progressIndicator) { - progressIndicator = document.createElement('div'); - progressIndicator.className = 'practice-progress'; - this.container.querySelector('.discovery-main').appendChild(progressIndicator); - } - - if (this.currentPhase === 'practice') { - progressIndicator.textContent = `Round ${this.practiceRound + 1}/${this.maxPracticeRounds}`; - } else { - progressIndicator.textContent = ''; - } - } - - showMixedPracticeChallenge() { - // Get a random word from discovered words for this challenge - const currentWord = this.currentPracticeWords[this.practiceRound % this.currentPracticeWords.length]; - const gameContent = this.container.querySelector('.game-content'); - - // Check available content features - const hasImages = this.discoveredWords.some(word => word.image); - const hasPronunciation = this.discoveredWords.some(word => word.pronunciation); - const hasAudioFiles = this.discoveredWords.some(word => word.audioFile); - const currentWordHasImage = currentWord.image; - const currentWordHasPronunciation = currentWord.pronunciation; - const currentWordHasAudio = currentWord.audioFile; - - // Determine challenge based on practice level and available content - const challenges = { - 1: { options: 4, time: null, question: 'translation' }, // Easy: 4 options, no timer - 2: { options: 6, time: 15, question: 'translation' }, // Medium: 6 options, 15s timer - 3: { options: 6, time: 10, question: hasImages ? 'mixed' : 'translation' }, // Hard: mixed if images available - 4: { options: 8, time: 8, question: (hasAudioFiles || hasPronunciation) ? 'audio' : 'translation' } // Expert: audio if available - }; - - const challenge = challenges[this.practiceLevel]; - const numOptions = challenge.options; - - // Create options: current word + random others from ALL discovered words - const options = [currentWord]; - const otherWords = this.discoveredWords.filter(word => word.word !== currentWord.word); - const randomOthers = this.shuffleArray([...otherWords]).slice(0, numOptions - 1); - options.push(...randomOthers); - - // Shuffle the options - const shuffledOptions = this.shuffleArray([...options]); - - // Determine question type - TEST FOREIGN WORD KNOWLEDGE, NOT NATIVE LANGUAGE - let questionText = ''; - let showImages = true; - let showText = true; - - if (challenge.question === 'translation') { - // Test: Show foreign word, find translation/image - questionText = `Which one means "${currentWord.word}"?`; - } else if (challenge.question === 'mixed') { - // Build available question types based on content - const questionTypes = [`Which one means "${currentWord.word}"?`]; - - // Add pronunciation question if available (text or audio) - if (currentWordHasPronunciation || currentWordHasAudio) { - questionTypes.push(`Find the word that sounds like "${currentWord.pronunciation || currentWord.word}"`); - } - - // Add image question if current word has image AND other words have images for comparison - if (currentWordHasImage && hasImages) { - questionTypes.push(`Which image represents "${currentWord.word}"?`); - } - - questionText = questionTypes[Math.floor(Math.random() * questionTypes.length)]; - if (questionText.includes('image')) { - showText = false; - // Ensure we only show options that have images - const imageOptions = [currentWord]; - const otherWordsWithImages = this.discoveredWords.filter(word => - word.word !== currentWord.word && word.image - ); - if (otherWordsWithImages.length >= numOptions - 1) { - const randomOthers = this.shuffleArray([...otherWordsWithImages]).slice(0, numOptions - 1); - options.length = 1; // Reset to just current word - options.push(...randomOthers); - } - } - } else if (challenge.question === 'audio') { - if (currentWordHasPronunciation || currentWordHasAudio) { - questionText = 'Listen and find the correct word!'; - showImages = false; - // Auto-play pronunciation - setTimeout(() => this.hearPronunciation(), 500); - } else { - // Fallback to translation if no audio - questionText = `Which one means "${currentWord.word}"?`; - } - } - - const gridClass = numOptions <= 4 ? 'association-grid' : - numOptions <= 6 ? 'practice-grid-6' : 'practice-grid-8'; - - gameContent.innerHTML = ` -
- ${challenge.time ? `
${challenge.time}
` : ''} -
${questionText}
-
-
Correct: ${this.practiceCorrectAnswers}
-
Errors: ${this.practiceErrors}
-
Level: ${this.practiceLevel}/4
-
-
-
- ${shuffledOptions.map((option, index) => ` -
- ${showImages && option.image ? `${option.word}` : ''} - ${showText ? `
${option.translation}
` : ''} - ${!showText && !showImages ? `
?
` : ''} -
- `).join('')} -
- `; - - // Start timer if needed - if (challenge.time) { - this.startPracticeTimer(challenge.time); - } - - // Auto-play TTS based on practice level with appropriate speed - setTimeout(() => { - let ttsSpeed; - switch (this.practiceLevel) { - case 1: // Easy - 0.7 speed - ttsSpeed = 0.7; - break; - case 2: // Medium - 0.9 speed - ttsSpeed = 0.9; - break; - case 3: // Hard - 1.0 speed - ttsSpeed = 1.0; - break; - case 4: // Expert - 1.1 speed (already has audio auto-play) - ttsSpeed = 1.1; - break; - default: - ttsSpeed = 0.8; // Fallback - } - - // Don't auto-play if it's an audio-only challenge (Expert mode already handles this) - if (challenge.question !== 'audio') { - this.hearPronunciation({ rate: ttsSpeed }); - } - }, 1000); // Delay to let interface render - } - - startPracticeTimer(seconds) { - this.practiceTimer = seconds; - this.practiceTimerInterval = setInterval(() => { - this.practiceTimer--; - const timerElement = document.getElementById('practice-timer'); - if (timerElement) { - timerElement.textContent = this.practiceTimer; - if (this.practiceTimer <= 3) { - timerElement.style.color = '#FF4444'; - timerElement.style.animation = 'pulse 0.5s infinite'; - } - } - - if (this.practiceTimer <= 0) { - this.clearPracticeTimer(); - this.selectPractice(-1, 'TIMEOUT'); // Handle timeout - } - }, 1000); - } - - clearPracticeTimer() { - if (this.practiceTimerInterval) { - clearInterval(this.practiceTimerInterval); - this.practiceTimerInterval = null; - } - } - - selectMixedPractice(selectedIndex, selectedWord) { - this.clearPracticeTimer(); - - const currentWord = this.currentPracticeWords[this.practiceRound % this.currentPracticeWords.length]; - const items = this.container.querySelectorAll('.association-item'); - - let isCorrect = false; - - if (selectedWord === 'TIMEOUT') { - // Timer expired - this.practiceErrors++; - // Show correct answer - items.forEach((item) => { - const text = item.querySelector('.association-text'); - if (text && text.textContent === currentWord.word) { - item.classList.add('correct'); - } - }); - } else if (selectedWord === currentWord.word) { - // Correct answer - isCorrect = true; - items[selectedIndex].classList.add('correct'); - this.practiceCorrectAnswers++; - - // Score based on difficulty level - const scoreBonus = [0, 5, 10, 15, 25][this.practiceLevel]; - this.score += scoreBonus; - this.onScoreUpdate(this.score); - } else { - // Wrong answer - items[selectedIndex].classList.add('incorrect'); - this.practiceErrors++; - - // Show correct answer - items.forEach((item) => { - const text = item.querySelector('.association-text'); - if (text && text.textContent === currentWord.word) { - item.classList.add('correct'); - } - }); - } - - this.updateHUD(); - - // Continue to next practice round or advance - setTimeout(() => { - this.practiceRound++; - - if (this.practiceRound >= this.maxPracticeRounds) { - // Check if ready for next level - const accuracy = this.practiceCorrectAnswers / this.maxPracticeRounds; - - if (accuracy >= 0.75 && this.practiceLevel < 4) { - // Advance to next difficulty level - this.practiceLevel++; - this.practiceRound = 0; - this.practiceCorrectAnswers = 0; - this.practiceErrors = 0; - - // SHUFFLE words again for new difficulty level - this.currentPracticeWords = this.shuffleArray([...this.discoveredWords]); - console.log(`🔀 Shuffled words for Level ${this.practiceLevel} - new variation order`); - - this.updatePhaseIndicator(); - - setTimeout(() => { - this.showLevelUpMessage(); - }, 500); - } else if (accuracy >= 0.5) { - // Passed all practice levels - show completion - this.showCompletion(); - } else { - // Failed practice - retry current level - this.practiceRound = Math.max(0, this.practiceRound - 2); // Go back 2 rounds - this.practiceCorrectAnswers = 0; - this.practiceErrors = 0; - this.lives--; - if (this.lives <= 0) { - this.endGame(); - return; - } - } - } else { - // Continue current difficulty level with next random word - this.updatePhaseIndicator(); - this.showMixedPracticeChallenge(); - } - }, 1500); - } - - showLevelUpMessage() { - const gameContent = this.container.querySelector('.game-content'); - const difficultyNames = ['', 'Easy', 'Medium', 'Hard', 'Expert']; - - gameContent.innerHTML = ` -
-
🎉 Level Up!
-
- Advanced to ${difficultyNames[this.practiceLevel]} difficulty!
- Keep practicing to master this word! -
-
- `; - - setTimeout(() => { - this.showMixedPracticeChallenge(); - }, 2000); - } - - markAsLearned() { - this.discoveredWords.push(this.wordsToLearn[this.currentWordIndex]); - this.currentWordIndex++; - this.score += 5; - this.onScoreUpdate(this.score); - this.updateHUD(); - - setTimeout(() => { - this.startDiscoveryPhase(); - }, 300); - } - - startGlobalPractice() { - // Transition message - const gameContent = this.container.querySelector('.game-content'); - gameContent.innerHTML = ` -
-
🏆 Discovery Complete!
-
- You've discovered all ${this.discoveredWords.length} words!
- Now let's practice with mixed vocabulary challenges! -
-
- - -
-
- `; - } - - startMixedPractice() { - this.currentPhase = 'practice'; - this.practiceLevel = 1; - this.practiceRound = 0; - this.practiceCorrectAnswers = 0; - this.practiceErrors = 0; - - // SHUFFLE discovered words for varied practice order - this.currentPracticeWords = this.shuffleArray([...this.discoveredWords]); - console.log(`🔀 Shuffled ${this.currentPracticeWords.length} words for practice variation`); - - this.updatePhaseIndicator(); - this.showMixedPracticeChallenge(); - } - - skipToCompletion() { - this.showCompletion(); - } - - showCompletion() { - const gameContent = this.container.querySelector('.game-content'); - const accuracy = Math.round((this.discoveredWords.length / this.wordsToLearn.length) * 100); - const practiceAccuracy = this.practiceRound > 0 ? Math.round((this.practiceCorrectAnswers / this.practiceRound) * 100) : 0; - - gameContent.innerHTML = ` -
-
🏆 Vocabulary Mastered!
-
- Words Discovered: ${this.discoveredWords.length}/${this.wordsToLearn.length}
- Practice Accuracy: ${practiceAccuracy}%
- Final Score: ${this.score}
- Practice Level Reached: ${this.practiceLevel}/4 -
-
- - -
-
- `; - } - - showNoContent() { - const gameContent = this.container.querySelector('.game-content'); - gameContent.innerHTML = ` -
-
📚 No Vocabulary Found
-
- This content doesn't have vocabulary for the Word Discovery game.
- Note: Images and audio are optional but enhance the experience! -
-
- -
-
- `; - } - - start() { - // Game starts automatically in constructor - } - - restart() { - this.currentWordIndex = 0; - this.discoveredWords = []; - this.score = 0; - this.lives = 3; - this.practiceLevel = 1; - this.practiceRound = 0; - this.practiceCorrectAnswers = 0; - this.practiceErrors = 0; - this.currentPracticeWords = []; - this.clearPracticeTimer(); - this.wordsToLearn = this.shuffleArray([...this.wordsToLearn]); - this.updateHUD(); - this.startDiscoveryPhase(); - } - - endGame() { - this.onGameEnd(this.score); - } - - destroy() { - this.clearPracticeTimer(); - - // Clean up global content reference - if (window.currentGameContent === this.content) { - window.currentGameContent = null; - } - - const styleSheet = document.getElementById('word-discovery-styles'); - if (styleSheet) { - styleSheet.remove(); - } - } -} - -// Register the game module -window.GameModules = window.GameModules || {}; -window.GameModules.WordDiscovery = WordDiscovery; \ No newline at end of file diff --git a/src/games/word-storm.js b/src/games/word-storm.js deleted file mode 100644 index a3add1a..0000000 --- a/src/games/word-storm.js +++ /dev/null @@ -1,656 +0,0 @@ -// === WORD STORM GAME === -// Game where words fall from the sky like meteorites! - -class WordStormGame { - constructor(options) { - logSh('Word Storm constructor called', 'DEBUG'); - - this.container = options.container; - this.content = options.content; - this.onScoreUpdate = options.onScoreUpdate || (() => {}); - this.onGameEnd = options.onGameEnd || (() => {}); - - // Inject game-specific CSS - this.injectCSS(); - - logSh('Options processed, initializing game state...', 'DEBUG'); - - // Game state - this.score = 0; - this.level = 1; - this.lives = 3; - this.combo = 0; - this.isGamePaused = false; - this.isGameOver = false; - - // Game mechanics - this.fallingWords = []; - this.gameInterval = null; - this.spawnInterval = null; - this.currentWordIndex = 0; - - // Game settings - this.fallSpeed = 8000; // ms to fall from top to bottom (very slow) - this.spawnRate = 4000; // ms between spawns (not frequent) - this.wordLifetime = 15000; // ms before word disappears (long time) - - logSh('Game state initialized, extracting vocabulary...', 'DEBUG'); - - // Content extraction - try { - this.vocabulary = this.extractVocabulary(this.content); - this.shuffledVocab = [...this.vocabulary]; - this.shuffleArray(this.shuffledVocab); - - logSh(`Word Storm initialized with ${this.vocabulary.length} words`, 'INFO'); - } catch (error) { - logSh(`Error extracting vocabulary: ${error.message}`, 'ERROR'); - throw error; - } - - logSh('Calling init()...', 'DEBUG'); - this.init(); - } - - injectCSS() { - // Avoid injecting CSS multiple times - if (document.getElementById('word-storm-styles')) return; - - const styleSheet = document.createElement('style'); - styleSheet.id = 'word-storm-styles'; - styleSheet.textContent = ` - .falling-word { - position: absolute; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - padding: 20px 30px; - border-radius: 25px; - font-size: 2rem; - font-weight: 600; - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3), 0 0 20px rgba(102, 126, 234, 0.4); - cursor: default; - user-select: none; - transform: translateX(-50%); - animation: wordGlow 2s ease-in-out infinite; - } - - .falling-word.exploding { - animation: explode 0.8s ease-out forwards; - } - - .falling-word.wrong-shake { - animation: wrongShake 0.6s ease-in-out forwards; - } - - .answer-panel.wrong-flash { - animation: wrongFlash 0.5s ease-in-out; - } - - @keyframes wordGlow { - 0%, 100% { box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3), 0 0 20px rgba(102, 126, 234, 0.4); } - 50% { box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3), 0 0 30px rgba(102, 126, 234, 0.6); } - } - - @keyframes explode { - 0% { - transform: translateX(-50%) scale(1) rotate(0deg); - opacity: 1; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3), 0 0 20px rgba(102, 126, 234, 0.4); - } - 25% { - transform: translateX(-50%) scale(1.3) rotate(5deg); - opacity: 0.9; - background: linear-gradient(135deg, #10b981 0%, #059669 100%); - box-shadow: 0 8px 25px rgba(16, 185, 129, 0.5), 0 0 40px rgba(16, 185, 129, 0.8); - } - 50% { - transform: translateX(-50%) scale(1.5) rotate(-3deg); - opacity: 0.7; - background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); - box-shadow: 0 12px 35px rgba(245, 158, 11, 0.6), 0 0 60px rgba(245, 158, 11, 0.9); - } - 75% { - transform: translateX(-50%) scale(0.8) rotate(2deg); - opacity: 0.4; - background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); - } - 100% { - transform: translateX(-50%) scale(0.1) rotate(0deg); - opacity: 0; - } - } - - @keyframes wrongShake { - 0%, 100% { - transform: translateX(-50%) scale(1); - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - } - 10%, 30%, 50%, 70%, 90% { - transform: translateX(-60%) scale(0.95); - background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); - box-shadow: 0 4px 15px rgba(239, 68, 68, 0.6), 0 0 25px rgba(239, 68, 68, 0.8); - } - 20%, 40%, 60%, 80% { - transform: translateX(-40%) scale(0.95); - background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); - box-shadow: 0 4px 15px rgba(239, 68, 68, 0.6), 0 0 25px rgba(239, 68, 68, 0.8); - } - } - - @keyframes wrongFlash { - 0%, 100% { - background: transparent; - box-shadow: none; - } - 50% { - background: rgba(239, 68, 68, 0.4); - box-shadow: 0 0 20px rgba(239, 68, 68, 0.6), inset 0 0 20px rgba(239, 68, 68, 0.3); - } - } - - @keyframes screenShake { - 0%, 100% { transform: translateX(0); } - 10% { transform: translateX(-3px) translateY(1px); } - 20% { transform: translateX(3px) translateY(-1px); } - 30% { transform: translateX(-2px) translateY(2px); } - 40% { transform: translateX(2px) translateY(-2px); } - 50% { transform: translateX(-1px) translateY(1px); } - 60% { transform: translateX(1px) translateY(-1px); } - 70% { transform: translateX(-2px) translateY(0px); } - 80% { transform: translateX(2px) translateY(1px); } - 90% { transform: translateX(-1px) translateY(-1px); } - } - - @keyframes pointsFloat { - 0% { - transform: translateY(0) scale(1); - opacity: 1; - } - 30% { - transform: translateY(-20px) scale(1.3); - opacity: 1; - } - 100% { - transform: translateY(-80px) scale(0.5); - opacity: 0; - } - } - - @media (max-width: 768px) { - .falling-word { - padding: 18px 25px; - font-size: 1.8rem; - } - } - - @media (max-width: 480px) { - .falling-word { - font-size: 1.5rem; - padding: 15px 20px; - } - } - `; - - document.head.appendChild(styleSheet); - logSh('Word Storm CSS injected', 'DEBUG'); - } - - extractVocabulary(content) { - let vocabulary = []; - - logSh(`Word Storm extracting vocabulary from content`, 'DEBUG'); - - // Support Dragon's Pearl and other formats - if (content.vocabulary && typeof content.vocabulary === 'object') { - vocabulary = Object.entries(content.vocabulary).map(([original, vocabData]) => { - if (typeof vocabData === 'string') { - return { - original: original, - translation: vocabData - }; - } else if (typeof vocabData === 'object') { - return { - original: original, - translation: vocabData.user_language || vocabData.translation || 'No translation', - pronunciation: vocabData.pronunciation - }; - } - return null; - }).filter(item => item !== null); - - logSh(`Extracted ${vocabulary.length} words from content.vocabulary`, 'DEBUG'); - } - - // Support rawContent format - if (content.rawContent && content.rawContent.vocabulary) { - const rawVocab = Object.entries(content.rawContent.vocabulary).map(([original, vocabData]) => { - if (typeof vocabData === 'string') { - return { original: original, translation: vocabData }; - } else if (typeof vocabData === 'object') { - return { - original: original, - translation: vocabData.user_language || vocabData.translation, - pronunciation: vocabData.pronunciation - }; - } - return null; - }).filter(item => item !== null); - - vocabulary = vocabulary.concat(rawVocab); - logSh(`Added ${rawVocab.length} words from rawContent.vocabulary, total: ${vocabulary.length}`, 'DEBUG'); - } - - // Limit to 50 words max for performance - return vocabulary.slice(0, 50); - } - - init() { - if (this.vocabulary.length === 0) { - this.showNoVocabularyMessage(); - return; - } - - this.container.innerHTML = ` -
-
-
-
Score: 0
-
Level: 1
-
-
-
Lives: 3
-
Combo: 0
-
-
- -
-
- -
-
- -
-
- -
-
-
- `; - - this.setupEventListeners(); - this.generateAnswerOptions(); - } - - setupEventListeners() { - const pauseBtn = document.getElementById('pause-btn'); - if (pauseBtn) { - pauseBtn.addEventListener('click', () => this.togglePause()); - } - - // Answer button clicks - document.addEventListener('click', (e) => { - if (e.target.classList.contains('answer-btn')) { - const answer = e.target.textContent; - this.checkAnswer(answer); - } - }); - - // Keyboard support - document.addEventListener('keydown', (e) => { - if (e.key >= '1' && e.key <= '4') { - const btnIndex = parseInt(e.key) - 1; - const buttons = document.querySelectorAll('.answer-btn'); - if (buttons[btnIndex]) { - buttons[btnIndex].click(); - } - } - }); - } - - start() { - logSh('Word Storm game started', 'INFO'); - this.startSpawning(); - } - - startSpawning() { - this.spawnInterval = setInterval(() => { - if (!this.isGamePaused && !this.isGameOver) { - this.spawnFallingWord(); - } - }, this.spawnRate); - } - - spawnFallingWord() { - if (this.vocabulary.length === 0) return; - - const word = this.vocabulary[this.currentWordIndex % this.vocabulary.length]; - this.currentWordIndex++; - - const gameArea = document.getElementById('game-area'); - const wordElement = document.createElement('div'); - wordElement.className = 'falling-word'; - wordElement.textContent = word.original; - wordElement.style.left = Math.random() * 80 + 10 + '%'; - wordElement.style.top = '-60px'; - - gameArea.appendChild(wordElement); - - this.fallingWords.push({ - element: wordElement, - word: word, - startTime: Date.now() - }); - - // Generate new answer options when word spawns - this.generateAnswerOptions(); - - // Animate falling - this.animateFalling(wordElement); - - // Remove after lifetime - setTimeout(() => { - if (wordElement.parentNode) { - this.missWord(wordElement); - } - }, this.wordLifetime); - } - - animateFalling(wordElement) { - wordElement.style.transition = `top ${this.fallSpeed}ms linear`; - setTimeout(() => { - wordElement.style.top = '100vh'; - }, 50); - } - - generateAnswerOptions() { - if (this.vocabulary.length === 0) return; - - const buttons = []; - const correctWord = this.fallingWords.length > 0 ? - this.fallingWords[this.fallingWords.length - 1].word : - this.vocabulary[0]; - - // Add correct answer - buttons.push(correctWord.translation); - - // Add 3 random incorrect answers - while (buttons.length < 4) { - const randomWord = this.vocabulary[Math.floor(Math.random() * this.vocabulary.length)]; - if (!buttons.includes(randomWord.translation)) { - buttons.push(randomWord.translation); - } - } - - // Shuffle buttons - this.shuffleArray(buttons); - - // Update answer panel - const answerButtons = document.getElementById('answer-buttons'); - if (answerButtons) { - answerButtons.innerHTML = buttons.map(answer => - `` - ).join(''); - } - } - - checkAnswer(selectedAnswer) { - const activeFallingWords = this.fallingWords.filter(fw => fw.element.parentNode); - - for (let i = 0; i < activeFallingWords.length; i++) { - const fallingWord = activeFallingWords[i]; - if (fallingWord.word.translation === selectedAnswer) { - this.correctAnswer(fallingWord); - return; - } - } - - // Wrong answer - this.wrongAnswer(); - } - - correctAnswer(fallingWord) { - // Remove from game with epic explosion - if (fallingWord.element.parentNode) { - fallingWord.element.classList.add('exploding'); - - // Add screen shake effect - const gameArea = document.getElementById('game-area'); - if (gameArea) { - gameArea.style.animation = 'none'; - gameArea.offsetHeight; // Force reflow - gameArea.style.animation = 'screenShake 0.3s ease-in-out'; - setTimeout(() => { - gameArea.style.animation = ''; - }, 300); - } - - setTimeout(() => { - if (fallingWord.element.parentNode) { - fallingWord.element.remove(); - } - }, 800); - } - - // Remove from tracking - this.fallingWords = this.fallingWords.filter(fw => fw !== fallingWord); - - // Update score - this.combo++; - const points = 10 + (this.combo * 2); - this.score += points; - this.onScoreUpdate(this.score); - - // Update display with animation - document.getElementById('score').textContent = this.score; - document.getElementById('combo').textContent = this.combo; - - // Add points popup animation - this.showPointsPopup(points, fallingWord.element); - - // Vibration feedback (if supported) - if (navigator.vibrate) { - navigator.vibrate([50, 30, 50]); - } - - // Level up check - if (this.score > 0 && this.score % 100 === 0) { - this.levelUp(); - } - } - - wrongAnswer() { - this.combo = 0; - document.getElementById('combo').textContent = this.combo; - - // Enhanced wrong answer animation - const answerPanel = document.getElementById('answer-panel'); - if (answerPanel) { - answerPanel.classList.add('wrong-flash'); - setTimeout(() => { - answerPanel.classList.remove('wrong-flash'); - }, 500); - } - - // Shake all falling words to show disappointment - this.fallingWords.forEach(fw => { - if (fw.element.parentNode && !fw.element.classList.contains('exploding')) { - fw.element.classList.add('wrong-shake'); - setTimeout(() => { - fw.element.classList.remove('wrong-shake'); - }, 600); - } - }); - - // Screen flash red - const gameArea = document.getElementById('game-area'); - if (gameArea) { - const overlay = document.createElement('div'); - overlay.style.cssText = ` - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(239, 68, 68, 0.3); - pointer-events: none; - animation: wrongFlash 0.4s ease-in-out; - z-index: 100; - `; - gameArea.appendChild(overlay); - setTimeout(() => { - if (overlay.parentNode) overlay.remove(); - }, 400); - } - - // Wrong answer vibration (stronger/longer) - if (navigator.vibrate) { - navigator.vibrate([200, 100, 200, 100, 200]); - } - } - - showPointsPopup(points, wordElement) { - const popup = document.createElement('div'); - popup.textContent = `+${points}`; - popup.style.cssText = ` - position: absolute; - left: ${wordElement.style.left}; - top: ${wordElement.offsetTop}px; - font-size: 2rem; - font-weight: bold; - color: #10b981; - pointer-events: none; - z-index: 1000; - text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); - animation: pointsFloat 1.5s ease-out forwards; - `; - - const gameArea = document.getElementById('game-area'); - if (gameArea) { - gameArea.appendChild(popup); - setTimeout(() => { - if (popup.parentNode) popup.remove(); - }, 1500); - } - } - - missWord(wordElement) { - // Remove word - if (wordElement.parentNode) { - wordElement.remove(); - } - - // Remove from tracking - this.fallingWords = this.fallingWords.filter(fw => fw.element !== wordElement); - - // Lose life - this.lives--; - this.combo = 0; - - document.getElementById('lives').textContent = this.lives; - document.getElementById('combo').textContent = this.combo; - - if (this.lives <= 0) { - this.gameOver(); - } - } - - levelUp() { - this.level++; - document.getElementById('level').textContent = this.level; - - // Increase difficulty - this.fallSpeed = Math.max(1000, this.fallSpeed * 0.9); - this.spawnRate = Math.max(800, this.spawnRate * 0.95); - - // Restart intervals with new timing - if (this.spawnInterval) { - clearInterval(this.spawnInterval); - this.startSpawning(); - } - - // Show level up message - const gameArea = document.getElementById('game-area'); - const levelUpMsg = document.createElement('div'); - levelUpMsg.innerHTML = ` -
-

⚡ LEVEL UP! ⚡

-

Level ${this.level}

-
- `; - gameArea.appendChild(levelUpMsg); - - setTimeout(() => { - if (levelUpMsg.parentNode) { - levelUpMsg.remove(); - } - }, 2000); - } - - togglePause() { - this.isGamePaused = !this.isGamePaused; - const pauseBtn = document.getElementById('pause-btn'); - if (pauseBtn) { - pauseBtn.textContent = this.isGamePaused ? '▶️ Resume' : '⏸️ Pause'; - } - } - - gameOver() { - this.isGameOver = true; - - // Clear intervals - if (this.spawnInterval) { - clearInterval(this.spawnInterval); - } - - // Clear falling words - this.fallingWords.forEach(fw => { - if (fw.element.parentNode) { - fw.element.remove(); - } - }); - - this.onGameEnd(this.score); - } - - showNoVocabularyMessage() { - this.container.innerHTML = ` -
-
-

🌪️ Word Storm

-

❌ No vocabulary found in this content.

-

This game requires content with vocabulary words.

- -
-
- `; - } - - shuffleArray(array) { - for (let i = array.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [array[i], array[j]] = [array[j], array[i]]; - } - } - - destroy() { - if (this.spawnInterval) { - clearInterval(this.spawnInterval); - } - - // Remove CSS - const styleSheet = document.getElementById('word-storm-styles'); - if (styleSheet) { - styleSheet.remove(); - } - - logSh('Word Storm destroyed', 'INFO'); - } -} - -// Export to global namespace -window.GameModules = window.GameModules || {}; -window.GameModules.WordStorm = WordStormGame; \ No newline at end of file diff --git a/src/styles/base.css b/src/styles/base.css index 3261b54..0056051 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -18,7 +18,8 @@ html { body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; color: #2d3748; - background-color: #f7fafc; + background: linear-gradient(180deg, #4299e1 0%, #3182ce 50%, #2b77cb 100%); + min-height: 100vh; overflow-x: hidden; } @@ -30,36 +31,50 @@ body { } .app-header { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 50%, #1e40af 100%); color: white; - padding: 1rem 0; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 0.4rem 0; + box-shadow: + 0 2px 4px rgba(0, 0, 0, 0.1), + 0 4px 12px rgba(37, 99, 235, 0.15); position: sticky; top: 0; z-index: 100; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); } .header-content { max-width: 1200px; margin: 0 auto; - padding: 0 1rem; + padding: 0 0.75rem; display: flex; justify-content: space-between; align-items: center; + min-height: 0; } .app-title { - font-size: 1.5rem; - font-weight: 700; + font-size: 1.125rem; + font-weight: 600; letter-spacing: -0.025em; } .app-main { flex: 1; max-width: 1200px; - margin: 0 auto; - padding: 2rem 1rem; - width: 100%; + margin: 1rem auto; + padding: 1rem; + width: calc(100% - 2rem); + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(20px); + border-radius: 16px; + box-shadow: + 0 10px 20px rgba(0, 0, 0, 0.1), + 0 4px 8px rgba(0, 0, 0, 0.05), + inset 0 1px 0 rgba(255, 255, 255, 0.8); + border: 1px solid rgba(255, 255, 255, 0.2); + position: relative; + transform: translateZ(0); } /* Loading Screen */ diff --git a/src/styles/components-ui.css b/src/styles/components-ui.css new file mode 100644 index 0000000..d591d69 --- /dev/null +++ b/src/styles/components-ui.css @@ -0,0 +1,548 @@ +/** + * Component UI Styles - Unified styling for extracted DRS components + * Button, ProgressBar, Card, Panel components with consistent theming + */ + +/* ============================================================================= + BUTTON COMPONENT STYLES + ============================================================================= */ + +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border: none; + border-radius: 6px; + font-family: inherit; + font-size: 14px; + font-weight: 500; + line-height: 1.4; + text-decoration: none; + cursor: pointer; + transition: all 0.2s ease; + user-select: none; + white-space: nowrap; + outline: none; + position: relative; + overflow: hidden; +} + +.btn:focus { + outline: 2px solid #4f46e5; + outline-offset: 2px; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; + pointer-events: none; +} + +/* Button sizes */ +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +.btn-lg { + padding: 14px 20px; + font-size: 16px; +} + +/* Button types */ +.btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + box-shadow: 0 2px 4px rgba(102, 126, 234, 0.3); +} + +.btn-primary:hover:not(:disabled) { + background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); + transform: translateY(-1px); +} + +.btn-secondary { + background: #f3f4f6; + color: #374151; + border: 1px solid #d1d5db; +} + +.btn-secondary:hover:not(:disabled) { + background: #e5e7eb; + border-color: #9ca3af; +} + +.btn-outline { + background: transparent; + color: #667eea; + border: 2px solid #667eea; +} + +.btn-outline:hover:not(:disabled) { + background: #667eea; + color: white; +} + +.btn-success { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + box-shadow: 0 2px 4px rgba(16, 185, 129, 0.3); +} + +.btn-success:hover:not(:disabled) { + background: linear-gradient(135deg, #059669 0%, #047857 100%); + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4); + transform: translateY(-1px); +} + +.btn-danger { + background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); + color: white; + box-shadow: 0 2px 4px rgba(239, 68, 68, 0.3); +} + +.btn-danger:hover:not(:disabled) { + background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); + box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4); + transform: translateY(-1px); +} + +/* Button content */ +.btn-icon { + display: inline-flex; + align-items: center; + font-size: 16px; +} + +.btn-text { + display: inline-flex; + align-items: center; +} + +.btn-spinner { + display: inline-flex; + align-items: center; + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* ============================================================================= + PROGRESS BAR COMPONENT STYLES + ============================================================================= */ + +.progress-bar { + width: 100%; + background: #f3f4f6; + border-radius: 8px; + overflow: hidden; + position: relative; +} + +.progress-bar-sm { + height: 4px; +} + +.progress-bar:not(.progress-bar-sm):not(.progress-bar-lg) { + height: 8px; +} + +.progress-bar-lg { + height: 12px; +} + +.progress-track { + width: 100%; + height: 100%; + position: relative; + overflow: hidden; +} + +.progress-fill { + height: 100%; + width: 0%; + border-radius: inherit; + transition: width 0.5s ease; + position: relative; +} + +.progress-fill-primary { + background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); +} + +.progress-fill-success { + background: linear-gradient(90deg, #10b981 0%, #059669 100%); +} + +.progress-fill-warning { + background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%); +} + +.progress-fill-danger { + background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%); +} + +.progress-fill-info { + background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%); +} + +.progress-bar-animated .progress-fill { + animation: progress-shimmer 2s ease-in-out infinite; +} + +.progress-bar-striped .progress-fill::before { + content: ''; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + background-image: linear-gradient( + 45deg, + rgba(255, 255, 255, 0.2) 25%, + transparent 25%, + transparent 50%, + rgba(255, 255, 255, 0.2) 50%, + rgba(255, 255, 255, 0.2) 75%, + transparent 75%, + transparent + ); + background-size: 16px 16px; + animation: progress-stripes 1s linear infinite; +} + +.progress-label { + margin-top: 4px; + font-size: 12px; + color: #6b7280; + text-align: center; +} + +.progress-bar-indeterminate .progress-fill { + width: 30% !important; + animation: progress-indeterminate 2s ease-in-out infinite; +} + +@keyframes progress-shimmer { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.8; } +} + +@keyframes progress-stripes { + from { background-position: 0 0; } + to { background-position: 16px 0; } +} + +@keyframes progress-indeterminate { + 0% { transform: translateX(-100%); } + 50% { transform: translateX(0%); } + 100% { transform: translateX(100%); } +} + +/* ============================================================================= + CARD COMPONENT STYLES + ============================================================================= */ + +.card { + background: white; + border-radius: 12px; + border: 1px solid #e5e7eb; + overflow: hidden; + transition: all 0.2s ease; +} + +.card-elevated { + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.card-interactive { + cursor: pointer; +} + +.card-interactive:hover { + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + transform: translateY(-2px); +} + +.card-interactive:active { + transform: translateY(0); +} + +/* Card sizes */ +.card-sm { + border-radius: 8px; +} + +.card-lg { + border-radius: 16px; +} + +/* Card types */ +.card-exercise { + border-left: 4px solid #667eea; +} + +.card-question { + border-left: 4px solid #3b82f6; + background: linear-gradient(to right, #eff6ff, #ffffff); +} + +.card-result { + border-left: 4px solid #10b981; +} + +.card-info { + border-left: 4px solid #06b6d4; + background: linear-gradient(to right, #ecfeff, #ffffff); +} + +.card-warning { + border-left: 4px solid #f59e0b; + background: linear-gradient(to right, #fffbeb, #ffffff); +} + +.card-success { + border-left: 4px solid #10b981; + background: linear-gradient(to right, #ecfdf5, #ffffff); +} + +.card-danger { + border-left: 4px solid #ef4444; + background: linear-gradient(to right, #fef2f2, #ffffff); +} + +.card-header { + padding: 16px 20px 12px 20px; + border-bottom: 1px solid #f3f4f6; +} + +.card-title { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #111827; +} + +.card-body { + padding: 16px 20px; + color: #374151; + line-height: 1.6; +} + +.card-footer { + padding: 12px 20px 16px 20px; + background: #f9fafb; + border-top: 1px solid #f3f4f6; + font-size: 14px; + color: #6b7280; +} + +/* Card animations */ +.card-animate-fade { + animation: card-fade-in 0.3s ease; +} + +.card-animate-slide { + animation: card-slide-in 0.3s ease; +} + +.card-animate-scale { + animation: card-scale-in 0.3s ease; +} + +@keyframes card-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes card-slide-in { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +@keyframes card-scale-in { + from { transform: scale(0.9); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +/* ============================================================================= + PANEL COMPONENT STYLES + ============================================================================= */ + +.panel { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; + transition: all 0.2s ease; +} + +.panel-collapsible .panel-header { + cursor: pointer; + user-select: none; +} + +.panel-collapsed .panel-body { + display: none; +} + +/* Panel types */ +.panel-info { + border-left: 4px solid #3b82f6; + background: linear-gradient(to right, #eff6ff, #ffffff); +} + +.panel-success { + border-left: 4px solid #10b981; + background: linear-gradient(to right, #ecfdf5, #ffffff); +} + +.panel-warning { + border-left: 4px solid #f59e0b; + background: linear-gradient(to right, #fffbeb, #ffffff); +} + +.panel-danger { + border-left: 4px solid #ef4444; + background: linear-gradient(to right, #fef2f2, #ffffff); +} + +.panel-hint { + border-left: 4px solid #8b5cf6; + background: linear-gradient(to right, #f5f3ff, #ffffff); +} + +.panel-explanation { + border-left: 4px solid #06b6d4; + background: linear-gradient(to right, #ecfeff, #ffffff); +} + +.panel-header { + display: flex; + justify-content: between; + align-items: center; + padding: 12px 16px; + background: rgba(0, 0, 0, 0.02); + border-bottom: 1px solid #f3f4f6; +} + +.panel-title { + margin: 0; + font-size: 16px; + font-weight: 600; + color: #111827; + flex: 1; +} + +.panel-controls { + display: flex; + gap: 8px; + margin-left: auto; +} + +.panel-toggle, +.panel-close { + background: none; + border: none; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + color: #6b7280; + transition: all 0.2s ease; +} + +.panel-toggle:hover, +.panel-close:hover { + background: rgba(0, 0, 0, 0.05); + color: #374151; +} + +.panel-body { + padding: 16px; + color: #374151; + line-height: 1.6; +} + +/* ============================================================================= + RESPONSIVE DESIGN + ============================================================================= */ + +@media (max-width: 768px) { + .btn { + padding: 12px 16px; + font-size: 16px; + } + + .btn-sm { + padding: 8px 12px; + font-size: 14px; + } + + .card-header, + .card-body, + .card-footer { + padding-left: 16px; + padding-right: 16px; + } + + .panel-header, + .panel-body { + padding-left: 12px; + padding-right: 12px; + } +} + +/* ============================================================================= + UTILITY CLASSES + ============================================================================= */ + +.component-hidden { + display: none !important; +} + +.component-invisible { + opacity: 0; + pointer-events: none; +} + +.component-disabled { + opacity: 0.6; + pointer-events: none; + cursor: not-allowed; +} + +/* Focus management for accessibility */ +.component-focus-visible:focus { + outline: 2px solid #4f46e5; + outline-offset: 2px; +} + +/* High contrast mode support */ +@media (prefers-contrast: high) { + .card, + .panel { + border-width: 2px; + } + + .btn { + border: 2px solid currentColor; + } +} + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + .btn, + .card, + .panel, + .progress-fill, + .btn-spinner { + transition: none !important; + animation: none !important; + } +} \ No newline at end of file diff --git a/src/styles/components.css b/src/styles/components.css index 6d696c3..f0ff896 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -3,6 +3,877 @@ * Buttons, cards, modals, forms, and other interactive elements */ +/* Import unified component styles */ +@import url('./components-ui.css'); + +/* ============================================================================= + UNIFIED DRS SPECIFIC STYLES + ============================================================================= */ + +.drs-main-card { + margin: 16px 0; + min-height: 200px; +} + +.drs-progress { + margin-bottom: 20px; +} + +.drs-actions { + display: flex; + gap: 12px; + margin-top: 16px; + justify-content: space-between; + align-items: center; +} + +.drs-instruction { + font-size: 16px; + font-weight: 600; + color: #374151; + margin-bottom: 16px; + padding: 12px; + background: #f8fafc; + border-radius: 8px; + border-left: 4px solid #3b82f6; +} + +.drs-text-content, +.drs-audio-content, +.drs-image-content, +.drs-grammar-content { + padding: 8px 0; +} + +.drs-text-passage { + background: #f9fafb; + padding: 16px; + border-radius: 8px; + margin-bottom: 16px; + line-height: 1.6; + color: #374151; +} + +.drs-question { + font-size: 16px; + font-weight: 500; + color: #111827; + margin: 16px 0 12px 0; +} + +.drs-options { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 12px; +} + +.drs-option { + display: flex; + align-items: center; + gap: 8px; + padding: 10px; + border: 2px solid #e5e7eb; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.drs-option:hover { + border-color: #3b82f6; + background: #eff6ff; +} + +.drs-option input { + margin: 0; +} + +.drs-audio-player { + text-align: center; + margin: 16px 0; +} + +.drs-audio-player audio { + width: 100%; + max-width: 400px; +} + +.drs-image-container { + text-align: center; + margin: 16px 0; +} + +.drs-image { + max-width: 100%; + max-height: 300px; + border-radius: 8px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); +} + +.drs-sentence { + font-size: 18px; + line-height: 1.6; + margin: 16px 0; + padding: 16px; + background: #f8fafc; + border-radius: 8px; +} + +.drs-blank { + border: none; + border-bottom: 2px solid #3b82f6; + background: transparent; + padding: 2px 8px; + margin: 0 4px; + min-width: 80px; + text-align: center; + font-size: inherit; + color: #3b82f6; + font-weight: 600; +} + +.drs-blank:focus { + outline: none; + border-bottom-color: #1d4ed8; + background: #eff6ff; +} + +.drs-explanation { + background: #f0f9ff; + padding: 12px; + border-radius: 8px; + margin-top: 12px; + border-left: 4px solid #0ea5e9; + font-size: 14px; + color: #0c4a6e; +} + +.drs-hint-panel, +.drs-result-panel { + margin-top: 16px; +} + +@media (max-width: 768px) { + .drs-actions { + flex-direction: column; + gap: 8px; + } + + .drs-actions > * { + width: 100%; + } + + .drs-instruction { + font-size: 14px; + padding: 10px; + } + + .drs-sentence { + font-size: 16px; + padding: 12px; + } +} + +:root { + --primary-color: #3b82f6; + --accent-color: #8b5cf6; + --gradient-start: #f8fafc; + --gradient-end: #e2e8f0; + --success-color: #10b981; + --warning-color: #f59e0b; + --danger-color: #ef4444; + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); +} + +/* Coming Soon Containers */ +.coming-soon-container { + text-align: center; + padding: 3rem 2rem; + background: linear-gradient(135deg, var(--gradient-start) 0%, var(--gradient-end) 100%); + border-radius: 12px; + margin: 2rem 0; + border: 1px solid var(--border-light); +} + +.coming-soon-icon { + font-size: 4rem; + margin-bottom: 1rem; +} + +.coming-soon-container h3 { + color: var(--primary-color); + margin-bottom: 1rem; + font-size: 1.5rem; +} + +.coming-soon-container p { + color: var(--text-secondary); + margin-bottom: 1.5rem; + font-size: 1.1rem; +} + +.coming-soon-details { + background: rgba(255, 255, 255, 0.1); + padding: 1.5rem; + border-radius: 8px; + margin-top: 2rem; + backdrop-filter: blur(10px); +} + +.coming-soon-details ul { + text-align: left; + max-width: 400px; + margin: 1rem auto; + list-style-type: none; + padding: 0; +} + +.coming-soon-details li { + padding: 0.5rem 0; + color: var(--text-primary); + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.coming-soon-details li:last-child { + border-bottom: none; +} + +.coming-soon-details code { + background: rgba(0, 0, 0, 0.2); + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-family: 'Courier New', monospace; + color: var(--accent-color); +} + +/* Games Grid and Cards */ +.games-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; + margin: 2rem 0; +} + +.game-card { + background: linear-gradient(135deg, var(--card-background) 0%, rgba(255, 255, 255, 0.05) 100%); + border: 1px solid var(--border-light); + border-radius: 12px; + padding: 1.5rem; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.game-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + border-color: var(--primary-color); +} + +.game-card.low-compatibility { + opacity: 0.7; + border-color: #ff6b6b; +} + +.game-icon { + font-size: 3rem; + text-align: center; + margin-bottom: 1rem; +} + +.game-info h3 { + color: var(--primary-color); + margin-bottom: 0.5rem; + font-size: 1.2rem; +} + +.game-description { + color: var(--text-secondary); + margin-bottom: 1rem; + font-size: 0.9rem; + line-height: 1.4; +} + +.game-meta { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.difficulty-badge { + padding: 0.2rem 0.6rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; +} + +.difficulty-beginner { + background: #4ade80; + color: #065f46; +} + +.difficulty-intermediate { + background: #fbbf24; + color: #92400e; +} + +.difficulty-advanced { + background: #f87171; + color: #991b1b; +} + +.compatibility-score { + font-size: 0.75rem; + font-weight: 600; + padding: 0.2rem 0.6rem; + border-radius: 8px; +} + +.compatibility-score.high { + background: #dcfce7; + color: #166534; +} + +.compatibility-score.medium { + background: #fef3c7; + color: #92400e; +} + +.compatibility-score.low { + background: #fee2e2; + color: #991b1b; +} + +.compatibility-details { + font-size: 0.8rem; + color: var(--text-muted); + font-style: italic; +} + +.games-subtitle { + color: var(--text-secondary); + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +.no-games-container { + text-align: center; + padding: 3rem 2rem; + background: var(--card-background); + border-radius: 12px; + margin: 2rem 0; +} + +.no-games-icon { + font-size: 4rem; + margin-bottom: 1rem; + opacity: 0.5; +} + +.no-games-container h3 { + color: var(--text-primary); + margin-bottom: 1rem; +} + +.suggestions { + text-align: left; + max-width: 400px; + margin: 1.5rem auto 0; +} + +.suggestions ul { + margin: 0.5rem 0; + padding-left: 1.5rem; +} + +.suggestions li { + color: var(--text-secondary); + margin: 0.25rem 0; +} + +/* Memory Game Styles */ +.memory-game { + max-width: 800px; + margin: 0.5rem auto; + padding: 1rem; + background: linear-gradient(135deg, #f0f4f8 0%, #e2e8f0 100%); + border-radius: 20px; + box-shadow: var(--shadow-xl); + position: relative; + overflow: hidden; +} + +.memory-game::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, var(--primary-color), var(--accent-color), var(--success-color)); + border-radius: 24px 24px 0 0; +} + +.game-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + padding: 1rem 1.5rem; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(10px); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: var(--shadow-lg); + position: relative; +} + +.game-header::before { + content: '🎮'; + position: absolute; + top: -8px; + left: 50%; + transform: translateX(-50%); + font-size: 1.5rem; + background: white; + padding: 0 1rem; + border-radius: 50px; + box-shadow: var(--shadow-md); +} + +.game-stats { + display: flex; + gap: 2rem; +} + +.stat { + text-align: center; + position: relative; + padding: 0.5rem; +} + +.stat-label { + display: block; + font-size: 0.75rem; + color: #64748b; + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 500; +} + +.stat-value { + display: block; + font-size: 1.5rem; + font-weight: 700; + color: var(--primary-color); +} + +.cards-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin-bottom: 1rem; + max-width: 600px; + margin-left: auto; + margin-right: auto; + padding: 1rem; + background: rgba(255, 255, 255, 0.5); + border-radius: 16px; + backdrop-filter: blur(5px); +} + +.memory-card { + width: 120px; + height: 120px; + perspective: 1000px; + cursor: pointer; + position: relative; + margin: 0; + padding: 0; + border: none; + outline: none; +} + +.card-inner { + position: relative; + width: 100%; + height: 100%; + transition: transform 0.6s; + transform-style: preserve-3d; + border-radius: 8px; + margin: 0; + padding: 0; +} + +.memory-card.flipped .card-inner { + transform: rotateY(180deg); +} + +.memory-card.matched { + animation: successPulse 0.8s ease-in-out; +} + +.memory-card.matched .card-inner { + transform: rotateY(180deg); + background: linear-gradient(135deg, #10b981, #34d399); + border-radius: 8px; +} + +.memory-card.matched .card-back { + background: linear-gradient(135deg, #10b981, #34d399); + color: white; + border-color: #059669; + font-weight: bold; +} + +.memory-card.removing { + animation: cardDisappear 0.6s ease-in-out forwards; + pointer-events: none; + opacity: 0; + visibility: hidden; + transform: scale(0); +} + +@keyframes successPulse { + 0%, 100% { transform: scale(1); } + 25% { transform: scale(1.1); } + 50% { transform: scale(1.15) rotate(5deg); } + 75% { transform: scale(1.1) rotate(-5deg); } +} + +@keyframes cardDisappear { + 0% { + transform: scale(1) rotate(0deg); + opacity: 1; + } + 50% { + transform: scale(1.2) rotate(10deg); + opacity: 0.8; + } + 100% { + transform: scale(0) rotate(45deg); + opacity: 0; + visibility: hidden; + } +} + +.card-front, +.card-back { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + backface-visibility: hidden; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + text-align: center; + padding: 8px; + box-sizing: border-box; + margin: 0; +} + +.card-front { + background: linear-gradient(135deg, var(--primary-color) 0%, var(--accent-color) 100%); + color: white; + font-size: 1.5rem; + z-index: 2; +} + + +.card-back { + background: white; + color: #1e293b; + transform: rotateY(180deg); + font-size: 0.8rem; + line-height: 1.2; + z-index: 1; + border: 1px solid #e2e8f0; +} + +.memory-card:hover .card-front { + background: linear-gradient(135deg, var(--accent-color) 0%, var(--primary-color) 100%); +} + +/* Victory Popup - Generic for all games */ +.victory-popup { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(10px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: victoryFadeIn 0.5s ease-out; +} + +.victory-content { + background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%); + border-radius: 24px; + padding: 3rem 2.5rem; + max-width: 500px; + width: 90%; + text-align: center; + box-shadow: var(--shadow-xl); + border: 2px solid rgba(255, 255, 255, 0.2); + position: relative; + animation: victorySlideIn 0.6s ease-out 0.2s both; +} + +.victory-content::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: linear-gradient(90deg, #10b981, #34d399, #6ee7b7); + border-radius: 24px 24px 0 0; +} + +.victory-title { + font-size: 2.5rem; + font-weight: 700; + color: #1e293b; + margin: 0 0 1rem 0; + background: linear-gradient(135deg, var(--success-color), #34d399); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.victory-emoji { + font-size: 4rem; + margin-bottom: 1rem; + display: block; + animation: victoryBounce 1s ease-in-out infinite; +} + +.victory-scores { + margin: 2rem 0; + padding: 1.5rem; + background: rgba(16, 185, 129, 0.1); + border-radius: 16px; + border: 1px solid rgba(16, 185, 129, 0.2); +} + +.score-item { + display: flex; + justify-content: space-between; + align-items: center; + margin: 0.75rem 0; + font-size: 1.1rem; +} + +.score-label { + font-weight: 600; + color: #374151; +} + +.score-value { + font-weight: 700; + color: var(--success-color); +} + +.score-value.new-best { + color: #f59e0b; + animation: newBestGlow 1s ease-in-out infinite; +} + +.victory-actions { + display: flex; + gap: 1rem; + margin-top: 2rem; + flex-wrap: wrap; + justify-content: center; +} + +.victory-btn { + flex: 1; + min-width: 140px; + padding: 1rem 1.5rem; + border: none; + border-radius: 12px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.victory-btn-primary { + background: linear-gradient(135deg, var(--success-color), #34d399); + color: white; + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); +} + +.victory-btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4); +} + +.victory-btn-secondary { + background: linear-gradient(135deg, var(--primary-color), var(--accent-color)); + color: white; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); +} + +.victory-btn-secondary:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(59, 130, 246, 0.4); +} + +.victory-btn-tertiary { + background: #f1f5f9; + color: #475569; + border: 2px solid #e2e8f0; +} + +.victory-btn-tertiary:hover { + background: #e2e8f0; + transform: translateY(-1px); +} + +@keyframes victoryFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes victorySlideIn { + from { + opacity: 0; + transform: scale(0.8) translateY(20px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes victoryBounce { + 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } + 40% { transform: translateY(-10px); } + 60% { transform: translateY(-5px); } +} + +@keyframes newBestGlow { + 0%, 100% { text-shadow: 0 0 5px rgba(245, 158, 11, 0.5); } + 50% { text-shadow: 0 0 20px rgba(245, 158, 11, 0.8), 0 0 30px rgba(245, 158, 11, 0.4); } +} + +.game-message { + text-align: center; + padding: 1.5rem 2rem; + border-radius: 16px; + margin: 2rem 0; + font-weight: 600; + font-size: 1.1rem; + backdrop-filter: blur(10px); + border: 2px solid rgba(255, 255, 255, 0.2); + box-shadow: var(--shadow-lg); + position: relative; + overflow: hidden; +} + +.game-message::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + border-radius: 16px 16px 0 0; +} + +.game-message.success { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(16, 185, 129, 0.05) 100%); + color: var(--success-color); + border-color: rgba(16, 185, 129, 0.3); +} + +.game-message.success::before { + background: linear-gradient(90deg, var(--success-color), #34d399); +} + +.game-message.info { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, rgba(59, 130, 246, 0.05) 100%); + color: var(--primary-color); + border-color: rgba(59, 130, 246, 0.3); +} + +.game-message.info::before { + background: linear-gradient(90deg, var(--primary-color), var(--accent-color)); +} + +.game-error { + text-align: center; + padding: 2rem; + background: #fee2e2; + border: 1px solid #fecaca; + border-radius: 12px; + margin: 2rem 0; +} + +.error-icon { + font-size: 3rem; + margin-bottom: 1rem; +} + +.game-error h3 { + color: #dc2626; + margin-bottom: 1rem; +} + +.game-error p { + color: #991b1b; + margin-bottom: 1.5rem; +} + +.game-loading { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.loading-spinner { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.game-container { + min-height: 600px; +} + +.game-area { + background: var(--card-background); + border-radius: 12px; + padding: 1rem; + min-height: 500px; +} + /* Buttons */ .btn { display: inline-flex; @@ -438,4 +1309,1791 @@ opacity: 1; transform: translateY(0); } +} + +/* Hero Section Styles */ +.hero-container { + position: relative; + min-height: 80vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; + overflow: hidden; +} + +.hero-background { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, rgba(102, 126, 234, 0.08) 0%, rgba(118, 75, 162, 0.12) 100%); + z-index: -1; +} + +.hero-background::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + radial-gradient(circle at 25% 25%, rgba(255, 255, 255, 0.1) 0%, transparent 50%), + radial-gradient(circle at 75% 75%, rgba(255, 255, 255, 0.1) 0%, transparent 50%); + animation: float 6s ease-in-out infinite; +} + +@keyframes float { + 0%, 100% { transform: translateY(0px); } + 50% { transform: translateY(-10px); } +} + +.hero-content { + text-align: center; + max-width: 600px; + animation: slideUp 0.8s ease-out; +} + +.hero-icon { + font-size: 4rem; + margin-bottom: 1rem; + animation: bounce 2s infinite; +} + +@keyframes bounce { + 0%, 20%, 50%, 80%, 100% { transform: translateY(0); } + 40% { transform: translateY(-10px); } + 60% { transform: translateY(-5px); } +} + +.hero-title { + font-size: 3.5rem; + font-weight: 700; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 1rem; + line-height: 1.1; +} + +.hero-subtitle { + font-size: 1.25rem; + color: #6b7280; + margin-bottom: 1rem; + font-weight: 500; +} + +.hero-description { + font-size: 1rem; + color: #9ca3af; + margin-bottom: 2.5rem; + line-height: 1.6; +} + +.action-buttons { + display: flex; + gap: 1rem; + justify-content: center; + margin-bottom: 3rem; + flex-wrap: wrap; +} + +.action-buttons .btn { + padding: 1rem 2rem; + font-size: 1rem; + min-width: 200px; + border-radius: 50px; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.action-buttons .btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + background: rgba(255, 255, 255, 0.1); + border-radius: 50%; + transform: translate(-50%, -50%); + transition: all 0.3s ease; +} + +.action-buttons .btn:hover::before { + width: 300px; + height: 300px; +} + +.action-buttons .btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border: none; + box-shadow: 0 8px 32px rgba(102, 126, 234, 0.3); +} + +.action-buttons .btn-primary:hover { + transform: translateY(-3px); + box-shadow: 0 12px 40px rgba(102, 126, 234, 0.4); +} + +.action-buttons .btn-secondary { + background: rgba(255, 255, 255, 0.9); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.3); + color: #4a5568; +} + +.action-buttons .btn-secondary:hover { + background: rgba(255, 255, 255, 1); + transform: translateY(-3px); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.1); +} + +.secondary-buttons { + display: flex; + gap: 1rem; + justify-content: center; + margin-top: 1.5rem; + flex-wrap: wrap; +} + +.secondary-buttons .btn { + padding: 0.75rem 1.5rem; + font-size: 0.9rem; + min-width: 140px; + border-radius: 25px; + transition: all 0.3s ease; +} + +.btn-outline { + background: transparent; + border: 2px solid rgba(255, 255, 255, 0.3); + color: #6b7280; + backdrop-filter: blur(10px); +} + +.btn-outline:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(102, 126, 234, 0.5); + color: #667eea; + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(102, 126, 234, 0.2); +} + +.btn-icon { + font-size: 1.2em; + margin-right: 0.5rem; +} + +.btn-text { + font-weight: 600; +} + +.features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 2rem; + margin-top: 2rem; +} + +.feature { + text-align: center; + padding: 1.5rem; + background: rgba(255, 255, 255, 0.6); + backdrop-filter: blur(15px); + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.5); + transition: all 0.3s ease; + box-shadow: 0 8px 32px rgba(31, 38, 135, 0.15); +} + +.feature:hover { + transform: translateY(-5px); + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); +} + +.feature-icon { + font-size: 2.5rem; + margin-bottom: 1rem; +} + +.feature-text { + font-weight: 600; + color: #4a5568; + font-size: 0.9rem; +} + +@media (max-width: 768px) { + .hero-title { + font-size: 2.5rem; + } + + .hero-container { + min-height: 70vh; + padding: 1rem; + } + + .action-buttons { + flex-direction: column; + align-items: center; + } + + .action-buttons .btn { + min-width: 250px; + } + + .secondary-buttons { + flex-direction: column; + align-items: center; + } + + .secondary-buttons .btn { + min-width: 200px; + } +} + +/* Books Page Styles */ +.books-container { + padding: 2rem 0; +} + +.books-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; + flex-wrap: wrap; + gap: 1rem; +} + +.books-header h2 { + margin: 0; + color: #2d3748; + font-size: 2rem; + font-weight: 700; +} + +.language-selector { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.language-selector label { + font-weight: 600; + color: #4a5568; + font-size: 0.9rem; +} + +.books-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 2rem; + margin-top: 2rem; +} + +.book-card { + background: rgba(255, 255, 255, 0.9); + border-radius: 16px; + padding: 1.5rem; + box-shadow: 0 8px 32px rgba(31, 38, 135, 0.15); + backdrop-filter: blur(15px); + border: 1px solid rgba(255, 255, 255, 0.5); + transition: all 0.3s ease; + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.book-card:hover { + transform: translateY(-8px); + box-shadow: 0 20px 40px rgba(31, 38, 135, 0.25); + border-color: rgba(102, 126, 234, 0.3); +} + +.book-cover { + width: 120px; + height: 160px; + margin: 0 auto 1rem auto; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15); +} + +.book-cover img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s ease; +} + +.book-card:hover .book-cover img { + transform: scale(1.05); +} + +.book-cover-placeholder { + width: 100%; + height: 100%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + display: flex; + align-items: center; + justify-content: center; + font-size: 3rem; + color: white; + border-radius: 8px; +} + +.book-difficulty { + display: inline-block; + background: rgba(102, 126, 234, 0.1); + color: #4c51bf; + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; + margin-top: 0.5rem; +} + +/* Tooltip Styles */ +.tooltip { + position: relative; + display: inline-block; +} + +.tooltip-content { + position: absolute; + bottom: 125%; + left: 50%; + transform: translateX(-50%); + background: rgba(255, 255, 255, 0.98); + backdrop-filter: blur(20px); + border: 1px solid rgba(102, 126, 234, 0.2); + border-radius: 12px; + padding: 1rem; + box-shadow: + 0 10px 30px rgba(0, 0, 0, 0.15), + 0 4px 12px rgba(102, 126, 234, 0.1); + z-index: 1000; + opacity: 1; + visibility: visible; + transition: all 0.3s ease; + min-width: 280px; + max-width: 350px; + pointer-events: none; +} + +.tooltip:hover .tooltip-content { + opacity: 1; + visibility: visible; + transform: translateX(-50%) translateY(-5px); +} + +.tooltip-content::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 8px solid transparent; + border-top-color: rgba(255, 255, 255, 0.98); + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1)); +} + +.tooltip-header { + font-size: 0.9rem; + font-weight: 700; + color: #2d3748; + margin-bottom: 0.75rem; + text-align: center; + border-bottom: 1px solid rgba(102, 126, 234, 0.15); + padding-bottom: 0.5rem; +} + +.tooltip-stats { + margin-bottom: 0.75rem; +} + +.stat-item { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.4rem; + font-size: 0.8rem; +} + +.stat-label { + color: #4a5568; + font-weight: 500; +} + +.stat-value { + color: #2d3748; + font-weight: 600; + background: rgba(102, 126, 234, 0.1); + padding: 0.15rem 0.4rem; + border-radius: 6px; + font-size: 0.75rem; +} + +.tooltip-categories { + border-top: 1px solid rgba(102, 126, 234, 0.15); + padding-top: 0.75rem; +} + +.categories-header { + font-size: 0.8rem; + font-weight: 600; + color: #4a5568; + margin-bottom: 0.4rem; +} + +.categories-list { + font-size: 0.75rem; + color: #2d3748; + line-height: 1.4; +} + +/* Tooltip Sections */ +.tooltip-section { + margin: 10px 0; + padding: 8px 0; + border-top: 1px solid rgba(102, 126, 234, 0.1); +} + +.section-header { + font-size: 0.7rem; + font-weight: 600; + color: #4a90e2; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.section-content { + font-size: 0.7rem; + color: #666; + line-height: 1.3; +} + +/* Tooltip Tags */ +.tooltip-tags { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(102, 126, 234, 0.1); +} + +.tooltip-tags .tag { + display: inline-block; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 2px 6px; + border-radius: 8px; + font-size: 0.6rem; + font-weight: 500; + margin: 2px 2px 0 0; + text-transform: lowercase; +} + +/* Version Badge */ +.tooltip-header .version { + background: rgba(102, 126, 234, 0.2); + color: #4a90e2; + padding: 1px 6px; + border-radius: 8px; + font-size: 0.6rem; + font-weight: 500; + margin-left: 8px; + vertical-align: middle; +} + +.chapter-card { + position: relative; +} + +.chapter-card:hover { + transform: translateY(-2px) scale(1.02); + box-shadow: 0 12px 28px rgba(102, 126, 234, 0.25); + border-color: rgba(102, 126, 234, 0.4); +} + +.book-info h3 { + margin: 0 0 0.5rem 0; + color: #2d3748; + font-size: 1.1rem; + font-weight: 600; +} + +.book-language { + margin: 0 0 0.5rem 0; + color: #667eea; + font-size: 0.85rem; + font-weight: 500; + background: rgba(102, 126, 234, 0.1); + padding: 0.25rem 0.5rem; + border-radius: 12px; + display: inline-block; +} + +.book-description { + margin: 0; + color: #6b7280; + font-size: 0.9rem; + line-height: 1.4; +} + +@media (max-width: 768px) { + .books-header { + flex-direction: column; + align-items: stretch; + text-align: center; + } + + .language-selector { + justify-content: center; + } + + .books-grid { + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1.5rem; + } +} + +/* Data Sync Modal Styles */ +.sync-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: fadeIn 0.3s ease-out; +} + +.sync-modal-content { + background: white; + border-radius: 16px; + width: 90%; + max-width: 700px; + max-height: 90vh; + overflow: hidden; + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.3), + 0 8px 24px rgba(102, 126, 234, 0.1); + animation: slideIn 0.3s ease-out; +} + +.sync-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem 2rem; + border-bottom: 1px solid #eee; + background: linear-gradient(135deg, #667eea, #764ba2); + color: white; +} + +.sync-modal-header h3 { + margin: 0; + font-size: 1.4rem; +} + +.close-btn { + background: none; + border: none; + font-size: 2rem; + color: white; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: background-color 0.3s ease; +} + +.close-btn:hover { + background-color: rgba(255, 255, 255, 0.2); +} + +.sync-tabs { + display: flex; + background: #f8f9fa; + border-bottom: 1px solid #eee; +} + +.sync-tab { + flex: 1; + padding: 1rem; + border: none; + background: transparent; + font-size: 1rem; + cursor: pointer; + transition: all 0.3s ease; + border-bottom: 3px solid transparent; +} + +.sync-tab:hover { + background: rgba(102, 126, 234, 0.1); +} + +.sync-tab.active { + background: white; + border-bottom-color: #667eea; + font-weight: 600; +} + +.sync-tab-content { + padding: 2rem; + max-height: 60vh; + overflow-y: auto; +} + +.import-options { + display: grid; + gap: 2rem; + margin-top: 1.5rem; +} + +.import-option { + padding: 1.5rem; + border: 2px dashed #ddd; + border-radius: 12px; + transition: all 0.3s ease; +} + +.import-option:hover { + border-color: #667eea; + background: rgba(102, 126, 234, 0.05); +} + +.import-option h5 { + margin: 0 0 1rem 0; + color: #333; + font-size: 1.1rem; +} + +.current-progress-info { + background: #f8f9fa; + padding: 1.5rem; + border-radius: 12px; + margin-bottom: 2rem; +} + +.current-progress-info ul { + margin: 0.5rem 0; + padding-left: 1.5rem; +} + +.current-progress-info li { + margin-bottom: 0.5rem; +} + +.export-note { + background: rgba(102, 126, 234, 0.1); + padding: 1rem 1.5rem; + border-radius: 8px; + margin-top: 1.5rem; + border-left: 4px solid #667eea; +} + +.status-overview { + background: #f8f9fa; + padding: 1.5rem; + border-radius: 12px; + margin-bottom: 2rem; +} + +.saved-files-list h5 { + margin-bottom: 1rem; + color: #333; +} + +.saved-file-item { + background: white; + border: 1px solid #eee; + border-radius: 8px; + padding: 1rem; + margin-bottom: 0.5rem; +} + +.file-info { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.file-meta { + font-size: 0.9em; + color: #666; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-30px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (max-width: 768px) { + .sync-modal-content { + width: 95%; + margin: 0 1rem; + } + + .sync-modal-header { + padding: 1rem 1.5rem; + } + + .sync-tab-content { + padding: 1.5rem; + } + + .import-options { + gap: 1.5rem; + } +} + +/* Chapters Page Styles */ +.chapters-container { + padding: 2rem 0; +} + +.chapters-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 2rem; + flex-wrap: wrap; +} + +.chapters-header h2 { + margin: 0; + color: #2d3748; + font-size: 1.8rem; + font-weight: 700; +} + +.chapters-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1.5rem; +} + +.chapter-card { + background: rgba(255, 255, 255, 0.9); + border-radius: 16px; + padding: 1.5rem; + box-shadow: 0 8px 32px rgba(31, 38, 135, 0.15); + backdrop-filter: blur(15px); + border: 1px solid rgba(255, 255, 255, 0.5); + transition: all 0.3s ease; + cursor: pointer; + display: flex; + align-items: center; + gap: 1rem; +} + +.chapter-card:hover { + transform: translateY(-5px); + box-shadow: 0 15px 35px rgba(31, 38, 135, 0.25); + border-color: rgba(102, 126, 234, 0.3); +} + +.chapter-number { + width: 60px; + height: 60px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + font-weight: 700; + flex-shrink: 0; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3); +} + +.chapter-info { + flex: 1; + min-width: 0; +} + +.chapter-info h3 { + margin: 0 0 0.5rem 0; + color: #2d3748; + font-size: 1.1rem; + font-weight: 600; + line-height: 1.3; +} + +.chapter-description { + margin: 0 0 1rem 0; + color: #6b7280; + font-size: 0.9rem; + line-height: 1.4; +} + +.chapter-meta { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.difficulty-badge { + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; + text-transform: capitalize; +} + +.difficulty-beginner { + background: rgba(34, 197, 94, 0.1); + color: #059669; +} + +.difficulty-intermediate { + background: rgba(245, 158, 11, 0.1); + color: #d97706; +} + +.difficulty-advanced { + background: rgba(239, 68, 68, 0.1); + color: #dc2626; +} + +.language-badge { + padding: 0.25rem 0.5rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; + background: rgba(102, 126, 234, 0.1); + color: #667eea; +} + +.chapter-progress { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.chapter-progress .progress { + flex: 1; + height: 6px; + background-color: #e5e7eb; + border-radius: 3px; + overflow: hidden; +} + +.progress-text { + font-size: 0.8rem; + color: #9ca3af; + font-weight: 500; + white-space: nowrap; +} + +@media (max-width: 768px) { + .chapters-header { + flex-direction: column; + align-items: stretch; + gap: 1rem; + } + + .chapters-grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + .chapter-card { + flex-direction: column; + text-align: center; + gap: 1rem; + } + + .chapter-number { + width: 50px; + height: 50px; + font-size: 1.2rem; + } +} + +/* Games Page Styles */ +.games-container { + padding: 2rem 0; +} + +.games-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 2rem; + flex-wrap: wrap; +} + +.games-header h2 { + margin: 0; + color: #2d3748; + font-size: 1.8rem; + font-weight: 700; +} + +.games-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; +} + +.game-card { + background: rgba(255, 255, 255, 0.9); + border-radius: 16px; + padding: 1.5rem; + box-shadow: 0 8px 32px rgba(31, 38, 135, 0.15); + backdrop-filter: blur(15px); + border: 1px solid rgba(255, 255, 255, 0.5); + transition: all 0.3s ease; + cursor: pointer; + display: flex; + align-items: center; + gap: 1rem; +} + +.game-card:hover { + transform: translateY(-5px); + box-shadow: 0 15px 35px rgba(31, 38, 135, 0.25); + border-color: rgba(102, 126, 234, 0.3); +} + +.game-icon { + font-size: 3rem; + flex-shrink: 0; +} + +.game-info { + flex: 1; + min-width: 0; +} + +.game-info h3 { + margin: 0 0 0.5rem 0; + color: #2d3748; + font-size: 1.1rem; + font-weight: 600; + line-height: 1.3; +} + +.game-description { + margin: 0 0 1rem 0; + color: #6b7280; + font-size: 0.9rem; + line-height: 1.4; +} + +.game-meta { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.play-count { + font-size: 0.8rem; + color: #9ca3af; + font-weight: 500; +} + +/* Game Container Styles */ +.game-container { + padding: 1rem 0; +} + +.game-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.game-header h2 { + margin: 0; + color: #2d3748; + font-size: 1.5rem; + font-weight: 700; +} + +.game-area { + min-height: 400px; + background: rgba(255, 255, 255, 0.9); + border-radius: 16px; + padding: 1rem; + box-shadow: 0 8px 32px rgba(31, 38, 135, 0.15); + backdrop-filter: blur(15px); + border: 1px solid rgba(255, 255, 255, 0.5); +} + +@media (max-width: 768px) { + .games-header { + flex-direction: column; + align-items: stretch; + gap: 1rem; + } + + .games-grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + .game-card { + flex-direction: column; + text-align: center; + gap: 1rem; + } + + .game-header { + flex-direction: column; + align-items: stretch; + gap: 1rem; + } +} + +/* Dynamic Revision System Styles */ +.dynamic-revision-container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.revision-header { + text-align: center; + margin-bottom: 30px; +} + +.revision-header h2 { + color: var(--primary-color); + margin-bottom: 10px; +} + +.revision-subtitle { + color: var(--text-secondary); + font-size: 1.1rem; +} + +.revision-controls { + background: var(--card-background); + border-radius: 12px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.dropdown-container { + display: flex; + gap: 20px; + margin-bottom: 20px; +} + +.dropdown-group { + flex: 1; +} + +.dropdown-group label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: var(--text-primary); +} + +.revision-start { + text-align: center; +} + +.btn-disabled { + opacity: 0.5; + cursor: not-allowed !important; + background-color: #cccccc !important; +} + +.revision-viewport { + background: var(--card-background); + border-radius: 12px; + padding: 40px; + min-height: 400px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.viewport-placeholder { + text-align: center; + color: var(--text-secondary); +} + +.placeholder-icon { + font-size: 4rem; + margin-bottom: 20px; +} + +.exercise-types-preview { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; + margin-top: 30px; +} + +.exercise-type { + padding: 15px; + background: rgba(var(--primary-color-rgb), 0.1); + border-radius: 8px; + font-weight: 500; +} + +.chapter-preview { + text-align: center; +} + +.preview-header { + margin-bottom: 30px; +} + +.preview-header h3 { + color: var(--primary-color); + margin-bottom: 10px; +} + +.exercise-preview-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin-top: 20px; +} + +.exercise-preview-card { + background: rgba(var(--primary-color-rgb), 0.05); + border: 2px solid rgba(var(--primary-color-rgb), 0.1); + border-radius: 12px; + padding: 20px; + text-align: center; + transition: transform 0.2s; +} + +.exercise-preview-card:hover { + transform: translateY(-2px); + border-color: rgba(var(--primary-color-rgb), 0.3); +} + +.exercise-icon { + font-size: 2rem; + margin-bottom: 10px; +} + +.exercise-preview-card h4 { + color: var(--primary-color); + margin-bottom: 8px; +} + +.exercise-preview-card p { + color: var(--text-secondary); + font-size: 0.9rem; +} + +.revision-active { + text-align: center; +} + +.revision-status { + background: rgba(var(--success-color-rgb, 34, 197, 94), 0.1); + border-radius: 8px; + padding: 20px; + margin-bottom: 30px; +} + +.revision-status h3 { + color: var(--success-color, #22c55e); + margin-bottom: 15px; +} + +.revision-placeholder { + padding: 40px; + border: 2px dashed var(--border-color); + border-radius: 12px; + color: var(--text-secondary); +} + +.revision-placeholder .loading-spinner { + font-size: 3rem; + margin-bottom: 20px; + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +@media (max-width: 768px) { + .dropdown-container { + flex-direction: column; + } + + .exercise-preview-grid { + grid-template-columns: 1fr; + } +} + +/* Chapter statistics styling */ +.chapter-stats { + display: flex; + gap: 15px; + justify-content: center; + margin: 15px 0; + flex-wrap: wrap; +} + +.stat { + background: rgba(var(--primary-color-rgb), 0.1); + padding: 8px 15px; + border-radius: 20px; + font-size: 0.9rem; + font-weight: 500; + color: var(--primary-color); +} + +@media (max-width: 768px) { + .chapter-stats { + flex-direction: column; + align-items: center; + } +} + +/* DRS Progress and Mastery UI */ +.chapter-title-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.chapter-badges { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.completion-badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.75rem; + background: var(--success-color); + color: white; + border-radius: 20px; + font-size: 0.875rem; + font-weight: 500; + box-shadow: var(--shadow-sm); +} + +.mastery-badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.75rem; + background: linear-gradient(135deg, #fbbf24, #f59e0b); + color: white; + border-radius: 20px; + font-size: 0.875rem; + font-weight: 500; + box-shadow: var(--shadow-sm); + animation: mastery-glow 2s ease-in-out infinite alternate; +} + +@keyframes mastery-glow { + 0% { + box-shadow: var(--shadow-sm); + } + 100% { + box-shadow: 0 0 20px rgba(251, 191, 36, 0.4), var(--shadow-sm); + } +} + +.chapter-actions { + display: flex; + gap: 1rem; + margin-top: 1rem; + justify-content: center; +} + +.chapter-actions .btn { + transition: all 0.2s ease; +} + +.chapter-actions .btn:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.btn-warning { + background: linear-gradient(135deg, #f59e0b, #d97706); + color: white; + border: none; +} + +.btn-warning:hover { + background: linear-gradient(135deg, #d97706, #b45309); +} + +@media (max-width: 768px) { + .chapter-title-row { + flex-direction: column; + text-align: center; + gap: 0.5rem; + } + + .chapter-badges { + justify-content: center; + } + + .chapter-actions { + flex-direction: column; + align-items: center; + } +} + +/* ============================================================================= + DYNAMIC REVISION - EXERCISE TYPE & DIFFICULTY SELECTION + ============================================================================= */ + +.exercise-type-selection { + margin: 24px 0; +} + +.exercise-type-selection h4 { + margin: 0 0 16px 0; + color: #374151; + font-size: 16px; + font-weight: 600; +} + +.exercise-types { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} + +.exercise-type-btn { + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 12px; + border: 2px solid #e5e7eb; + border-radius: 12px; + background: white; + cursor: pointer; + transition: all 0.2s ease; + font-size: 14px; + color: #6b7280; +} + +.exercise-type-btn:hover { + border-color: #3b82f6; + background: #eff6ff; + color: #1e40af; +} + +.exercise-type-btn.active { + border-color: #10b981; + background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); + color: #065f46; + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.2); +} + +.exercise-type-btn .type-icon { + font-size: 24px; + margin-bottom: 8px; +} + +.exercise-type-btn .type-name { + font-weight: 500; +} + +.difficulty-selection { + margin: 24px 0; +} + +.difficulty-selection h4 { + margin: 0 0 16px 0; + color: #374151; + font-size: 16px; + font-weight: 600; +} + +.difficulty-buttons { + display: flex; + gap: 8px; + justify-content: center; +} + +.difficulty-btn { + padding: 8px 20px; + border: 2px solid #e5e7eb; + border-radius: 8px; + background: white; + cursor: pointer; + font-size: 14px; + font-weight: 500; + color: #6b7280; + transition: all 0.2s ease; + min-width: 80px; +} + +.difficulty-btn:hover { + border-color: #9ca3af; + background: #f9fafb; + color: #374151; +} + +.difficulty-btn.active { + border-color: #3b82f6; + background: #3b82f6; + color: white; + box-shadow: 0 2px 4px rgba(59, 130, 246, 0.3); +} + +@media (max-width: 768px) { + .exercise-types { + grid-template-columns: repeat(2, 1fr); + } + + .difficulty-buttons { + flex-wrap: wrap; + } + + .difficulty-btn { + flex: 1; + min-width: 70px; + } +} + +/* ============================================================================= + SMART GUIDE INTERFACE STYLES + ============================================================================= */ + +.smart-guide-active { + background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); + border-radius: 16px; + padding: 24px; + margin: 16px 0; + border: 2px solid #0ea5e9; + box-shadow: 0 8px 32px rgba(14, 165, 233, 0.1); +} + +.guide-header { + margin-bottom: 24px; +} + +.guide-title { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.guide-title h3 { + margin: 0; + color: #0c4a6e; + font-size: 1.5em; + display: flex; + align-items: center; + gap: 8px; +} + +.guide-status { + padding: 12px 16px; + background: rgba(14, 165, 233, 0.1); + border-radius: 8px; + border-left: 4px solid #0ea5e9; + color: #0c4a6e; + font-weight: 500; + font-size: 14px; +} + +.guide-controls { + margin-bottom: 24px; +} + +.session-progress { + background: white; + padding: 16px; + border-radius: 12px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.progress-bar-container { + background: #e5e7eb; + height: 12px; + border-radius: 6px; + overflow: hidden; + margin-bottom: 8px; +} + +.progress-bar { + background: linear-gradient(90deg, #0ea5e9 0%, #06b6d4 100%); + height: 100%; + transition: width 0.6s ease; + border-radius: 6px; +} + +.progress-text { + font-size: 14px; + font-weight: 500; + color: #374151; + text-align: center; +} + +.current-exercise-info { + background: white; + padding: 20px; + border-radius: 12px; + margin-bottom: 24px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + border: 1px solid #e5e7eb; +} + +.current-exercise-info h4 { + margin: 0 0 16px 0; + color: #111827; + font-size: 1.1em; +} + +.exercise-details { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +.exercise-detail { + padding: 8px 12px; + background: #f8fafc; + border-radius: 6px; + font-size: 14px; +} + +.exercise-detail strong { + color: #374151; +} + +.reasoning-box { + background: #fef3c7; + padding: 16px; + border-radius: 8px; + border-left: 4px solid #f59e0b; + font-size: 14px; + color: #78350f; + line-height: 1.5; +} + +.reasoning-box strong { + display: block; + margin-bottom: 8px; + color: #92400e; +} + +.guide-insights { + background: white; + padding: 24px; + border-radius: 12px; + margin-top: 24px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); + border: 1px solid #e5e7eb; +} + +.guide-insights h4 { + margin: 0 0 20px 0; + color: #111827; + text-align: center; + font-size: 1.2em; +} + +.insights-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.insight-card { + background: #f8fafc; + padding: 16px; + border-radius: 8px; + border: 1px solid #e5e7eb; +} + +.insight-card h5 { + margin: 0 0 12px 0; + color: #374151; + font-size: 1em; + display: flex; + align-items: center; + gap: 8px; +} + +.insight-card ul { + margin: 0; + padding-left: 16px; + list-style-type: disc; +} + +.insight-card li { + margin-bottom: 6px; + font-size: 14px; + color: #6b7280; +} + +.insights-actions { + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; +} + +.revision-start { + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; + margin: 20px 0; +} + +/* Button spacing for revision start section */ +.revision-start .btn { + min-width: 200px; +} + +/* Manual mode collapsible section */ +.manual-mode { + margin-top: 16px; +} + +.manual-controls { + background: #f8fafc; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 8px; +} + +.manual-controls summary { + cursor: pointer; + padding: 8px 12px; + font-size: 14px; + color: #6b7280; + font-weight: 500; + border-radius: 6px; + transition: background 0.2s ease; +} + +.manual-controls summary:hover { + background: #e5e7eb; +} + +.manual-content { + padding: 16px 12px 8px 12px; +} + +.manual-content h5 { + font-size: 14px; + color: #374151; + margin: 0 0 8px 0; +} + +/* Smart Guide specific animations - REMOVED annoying hover animation */ + +/* Progress bar animation */ +@keyframes progressPulse { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(14, 165, 233, 0.4); + } + 50% { + box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.1); + } +} + +.progress-bar { + animation: progressPulse 2s infinite; +} + +/* Smart Guide responsive design */ +@media (max-width: 768px) { + .smart-guide-active { + padding: 16px; + margin: 12px 0; + } + + .guide-title { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .guide-title h3 { + font-size: 1.3em; + } + + .insights-grid { + grid-template-columns: 1fr; + } + + .insights-actions { + flex-direction: column; + } + + .revision-start { + flex-direction: column; + } + + .revision-start .btn { + width: 100%; + min-width: unset; + } +} + +@media (max-width: 480px) { + .exercise-details { + grid-template-columns: 1fr; + } + + .guide-status { + font-size: 13px; + padding: 10px 12px; + } + + .current-exercise-info { + padding: 16px; + } } \ No newline at end of file diff --git a/src/testing/TestFramework.js b/src/testing/TestFramework.js new file mode 100644 index 0000000..4a926ea --- /dev/null +++ b/src/testing/TestFramework.js @@ -0,0 +1,526 @@ +/** + * TestFramework - Lightweight testing framework for the modular architecture + * Tests core architecture and modules with detailed reporting + */ + +class TestFramework { + constructor() { + this.tests = []; + this.results = []; + this.suites = new Map(); + this.currentSuite = null; + this.totalTests = 0; + this.passedTests = 0; + this.failedTests = 0; + this.startTime = null; + this.endTime = null; + + // Test states + this.STATES = { + PENDING: 'pending', + RUNNING: 'running', + PASSED: 'passed', + FAILED: 'failed', + SKIPPED: 'skipped' + }; + } + + /** + * Create a test suite + * @param {string} suiteName - Name of the test suite + * @param {Function} callback - Function containing tests + */ + describe(suiteName, callback) { + console.log(`📝 Setting up test suite: ${suiteName}`); + + const suite = { + name: suiteName, + tests: [], + beforeEach: null, + afterEach: null, + beforeAll: null, + afterAll: null, + results: { + total: 0, + passed: 0, + failed: 0, + skipped: 0 + } + }; + + this.suites.set(suiteName, suite); + this.currentSuite = suite; + + // Execute the callback to register tests + if (callback && typeof callback === 'function') { + callback.call(this); + } + + this.currentSuite = null; + return suite; + } + + /** + * Register a test + * @param {string} testName - Name of the test + * @param {Function} testFunction - Test function to execute + */ + it(testName, testFunction) { + if (!this.currentSuite) { + throw new Error('Test must be inside a describe block'); + } + + const test = { + id: `${this.currentSuite.name}::${testName}`, + name: testName, + suite: this.currentSuite.name, + fn: testFunction, + state: this.STATES.PENDING, + error: null, + duration: 0, + timestamp: null + }; + + this.currentSuite.tests.push(test); + this.tests.push(test); + return test; + } + + /** + * Setup function to run before each test in current suite + * @param {Function} fn - Setup function + */ + beforeEach(fn) { + if (!this.currentSuite) { + throw new Error('beforeEach must be inside a describe block'); + } + this.currentSuite.beforeEach = fn; + } + + /** + * Teardown function to run after each test in current suite + * @param {Function} fn - Teardown function + */ + afterEach(fn) { + if (!this.currentSuite) { + throw new Error('afterEach must be inside a describe block'); + } + this.currentSuite.afterEach = fn; + } + + /** + * Setup function to run before all tests in current suite + * @param {Function} fn - Setup function + */ + beforeAll(fn) { + if (!this.currentSuite) { + throw new Error('beforeAll must be inside a describe block'); + } + this.currentSuite.beforeAll = fn; + } + + /** + * Teardown function to run after all tests in current suite + * @param {Function} fn - Teardown function + */ + afterAll(fn) { + if (!this.currentSuite) { + throw new Error('afterAll must be inside a describe block'); + } + this.currentSuite.afterAll = fn; + } + + /** + * Run all registered tests + * @returns {Promise} - Test results summary + */ + async runAll() { + console.log('🚀 Starting Test Framework execution...'); + this.startTime = Date.now(); + + // Reset counters + this.totalTests = this.tests.length; + this.passedTests = 0; + this.failedTests = 0; + this.results = []; + + // Run each suite + for (const [suiteName, suite] of this.suites) { + await this._runSuite(suite); + } + + this.endTime = Date.now(); + const duration = this.endTime - this.startTime; + + const summary = { + total: this.totalTests, + passed: this.passedTests, + failed: this.failedTests, + skipped: this.totalTests - this.passedTests - this.failedTests, + duration: duration, + suites: Array.from(this.suites.values()).map(suite => ({ + name: suite.name, + results: suite.results + })), + details: this.results + }; + + this._printSummary(summary); + return summary; + } + + /** + * Run a specific test suite + * @param {Object} suite - Test suite to run + * @private + */ + async _runSuite(suite) { + console.log(`\n📦 Running suite: ${suite.name}`); + + try { + // Run beforeAll if exists + if (suite.beforeAll) { + await suite.beforeAll(); + } + + // Run each test in the suite + for (const test of suite.tests) { + await this._runTest(test, suite); + } + + // Run afterAll if exists + if (suite.afterAll) { + await suite.afterAll(); + } + + } catch (error) { + console.error(`❌ Suite ${suite.name} setup/teardown failed:`, error); + } + } + + /** + * Run a single test + * @param {Object} test - Test to run + * @param {Object} suite - Suite containing the test + * @private + */ + async _runTest(test, suite) { + const startTime = Date.now(); + test.state = this.STATES.RUNNING; + test.timestamp = new Date().toISOString(); + + try { + // Run beforeEach if exists + if (suite.beforeEach) { + await suite.beforeEach(); + } + + // Run the actual test + await test.fn(this._createAssertions()); + + // Test passed + test.state = this.STATES.PASSED; + test.duration = Date.now() - startTime; + this.passedTests++; + suite.results.passed++; + + console.log(` ✅ ${test.name} (${test.duration}ms)`); + + // Run afterEach if exists + if (suite.afterEach) { + await suite.afterEach(); + } + + } catch (error) { + // Test failed + test.state = this.STATES.FAILED; + test.error = { + message: error.message, + stack: error.stack, + name: error.name + }; + test.duration = Date.now() - startTime; + this.failedTests++; + suite.results.failed++; + + console.error(` ❌ ${test.name} (${test.duration}ms)`); + console.error(` Error: ${error.message}`); + + // Run afterEach even if test failed + try { + if (suite.afterEach) { + await suite.afterEach(); + } + } catch (teardownError) { + console.error(` Teardown error: ${teardownError.message}`); + } + } + + suite.results.total++; + this.results.push({ + id: test.id, + name: test.name, + suite: test.suite, + state: test.state, + duration: test.duration, + error: test.error, + timestamp: test.timestamp + }); + } + + /** + * Create assertion helpers for tests + * @returns {Object} - Assertion methods + * @private + */ + _createAssertions() { + return { + /** + * Assert that a condition is true + * @param {boolean} condition - Condition to test + * @param {string} message - Error message if assertion fails + */ + assertTrue: (condition, message = 'Expected condition to be true') => { + if (!condition) { + throw new AssertionError(message); + } + }, + + /** + * Assert that a condition is false + * @param {boolean} condition - Condition to test + * @param {string} message - Error message if assertion fails + */ + assertFalse: (condition, message = 'Expected condition to be false') => { + if (condition) { + throw new AssertionError(message); + } + }, + + /** + * Assert that two values are equal + * @param {*} actual - Actual value + * @param {*} expected - Expected value + * @param {string} message - Error message if assertion fails + */ + assertEqual: (actual, expected, message = `Expected ${actual} to equal ${expected}`) => { + if (actual !== expected) { + throw new AssertionError(message); + } + }, + + /** + * Assert that two values are not equal + * @param {*} actual - Actual value + * @param {*} notExpected - Value that should not match + * @param {string} message - Error message if assertion fails + */ + assertNotEqual: (actual, notExpected, message = `Expected ${actual} not to equal ${notExpected}`) => { + if (actual === notExpected) { + throw new AssertionError(message); + } + }, + + /** + * Assert that a value is null + * @param {*} value - Value to test + * @param {string} message - Error message if assertion fails + */ + assertNull: (value, message = 'Expected value to be null') => { + if (value !== null) { + throw new AssertionError(message); + } + }, + + /** + * Assert that a value is not null + * @param {*} value - Value to test + * @param {string} message - Error message if assertion fails + */ + assertNotNull: (value, message = 'Expected value not to be null') => { + if (value === null) { + throw new AssertionError(message); + } + }, + + /** + * Assert that a value is undefined + * @param {*} value - Value to test + * @param {string} message - Error message if assertion fails + */ + assertUndefined: (value, message = 'Expected value to be undefined') => { + if (value !== undefined) { + throw new AssertionError(message); + } + }, + + /** + * Assert that a value is defined + * @param {*} value - Value to test + * @param {string} message - Error message if assertion fails + */ + assertDefined: (value, message = 'Expected value to be defined') => { + if (value === undefined) { + throw new AssertionError(message); + } + }, + + /** + * Assert that a function throws an error + * @param {Function} fn - Function that should throw + * @param {string} expectedError - Expected error message (optional) + * @param {string} message - Error message if assertion fails + */ + assertThrows: (fn, expectedError = null, message = 'Expected function to throw an error') => { + let threwError = false; + let actualError = null; + + try { + fn(); + } catch (error) { + threwError = true; + actualError = error; + } + + if (!threwError) { + throw new AssertionError(message); + } + + if (expectedError && actualError.message !== expectedError) { + throw new AssertionError(`Expected error "${expectedError}" but got "${actualError.message}"`); + } + }, + + /** + * Assert that a function does not throw an error + * @param {Function} fn - Function that should not throw + * @param {string} message - Error message if assertion fails + */ + assertDoesNotThrow: (fn, message = 'Expected function not to throw an error') => { + try { + fn(); + } catch (error) { + throw new AssertionError(`${message}. Got: ${error.message}`); + } + }, + + /** + * Assert that an object has a property + * @param {Object} obj - Object to check + * @param {string} property - Property name + * @param {string} message - Error message if assertion fails + */ + assertHasProperty: (obj, property, message = `Expected object to have property "${property}"`) => { + if (!(property in obj)) { + throw new AssertionError(message); + } + }, + + /** + * Assert that a value is an instance of a specific type + * @param {*} value - Value to check + * @param {Function} type - Constructor function + * @param {string} message - Error message if assertion fails + */ + assertInstanceOf: (value, type, message = `Expected value to be instance of ${type.name}`) => { + if (!(value instanceof type)) { + throw new AssertionError(message); + } + }, + + /** + * Assert that an array contains a specific value + * @param {Array} array - Array to search + * @param {*} value - Value to find + * @param {string} message - Error message if assertion fails + */ + assertContains: (array, value, message = `Expected array to contain ${value}`) => { + if (!Array.isArray(array) || !array.includes(value)) { + throw new AssertionError(message); + } + } + }; + } + + /** + * Print test summary + * @param {Object} summary - Test results summary + * @private + */ + _printSummary(summary) { + console.log('\n' + '='.repeat(60)); + console.log('📊 TEST SUMMARY'); + console.log('='.repeat(60)); + + const passRate = summary.total > 0 ? Math.round((summary.passed / summary.total) * 100) : 0; + const status = summary.failed === 0 ? '✅ ALL TESTS PASSED' : '❌ SOME TESTS FAILED'; + + console.log(`${status}`); + console.log(`Total Tests: ${summary.total}`); + console.log(`Passed: ${summary.passed} (${passRate}%)`); + console.log(`Failed: ${summary.failed}`); + console.log(`Skipped: ${summary.skipped}`); + console.log(`Duration: ${summary.duration}ms`); + + // Suite breakdown + console.log('\n📦 SUITE BREAKDOWN:'); + summary.suites.forEach(suite => { + const suitePassRate = suite.results.total > 0 ? + Math.round((suite.results.passed / suite.results.total) * 100) : 0; + console.log(` ${suite.name}: ${suite.results.passed}/${suite.results.total} (${suitePassRate}%)`); + }); + + // Failed tests details + if (summary.failed > 0) { + console.log('\n❌ FAILED TESTS:'); + this.results + .filter(result => result.state === this.STATES.FAILED) + .forEach(result => { + console.log(` ${result.suite}::${result.name}`); + console.log(` Error: ${result.error.message}`); + }); + } + + console.log('='.repeat(60)); + } + + /** + * Get test results as JSON + * @returns {Object} - Complete test results + */ + getResults() { + return { + summary: { + total: this.totalTests, + passed: this.passedTests, + failed: this.failedTests, + skipped: this.totalTests - this.passedTests - this.failedTests, + duration: this.endTime - this.startTime + }, + suites: Array.from(this.suites.entries()).map(([name, suite]) => ({ + name, + results: suite.results, + tests: suite.tests.map(test => ({ + name: test.name, + state: test.state, + duration: test.duration, + error: test.error + })) + })), + results: this.results + }; + } +} + +/** + * Custom assertion error + */ +class AssertionError extends Error { + constructor(message) { + super(message); + this.name = 'AssertionError'; + } +} + +// Export for use in tests +export { TestFramework, AssertionError }; \ No newline at end of file diff --git a/src/testing/TestRunner.js b/src/testing/TestRunner.js new file mode 100644 index 0000000..f152ea9 --- /dev/null +++ b/src/testing/TestRunner.js @@ -0,0 +1,489 @@ +/** + * TestRunner - Main test execution engine + * Runs all test suites and generates comprehensive reports + */ + +class TestRunner { + constructor() { + this.testSuites = []; + this.results = []; + this.overallResults = { + totalSuites: 0, + passedSuites: 0, + failedSuites: 0, + totalTests: 0, + passedTests: 0, + failedTests: 0, + skippedTests: 0, + duration: 0, + startTime: null, + endTime: null + }; + } + + /** + * Register a test suite + * @param {string} name - Name of the test suite + * @param {Object} testFramework - TestFramework instance with tests + */ + registerSuite(name, testFramework) { + this.testSuites.push({ + name, + framework: testFramework + }); + } + + /** + * Run all registered test suites + * @returns {Promise} - Complete test results + */ + async runAllTests() { + console.log('🚀 Starting Test Runner...'); + console.log('='.repeat(80)); + + this.overallResults.startTime = Date.now(); + this.overallResults.totalSuites = this.testSuites.length; + + for (const suite of this.testSuites) { + console.log(`\n🏃‍♂️ Running test suite: ${suite.name}`); + console.log('-'.repeat(60)); + + try { + const result = await suite.framework.runAll(); + this.results.push({ + suiteName: suite.name, + success: result.failed === 0, + result: result + }); + + if (result.failed === 0) { + this.overallResults.passedSuites++; + console.log(`✅ Suite "${suite.name}" PASSED`); + } else { + this.overallResults.failedSuites++; + console.log(`❌ Suite "${suite.name}" FAILED`); + } + + // Aggregate test counts + this.overallResults.totalTests += result.total; + this.overallResults.passedTests += result.passed; + this.overallResults.failedTests += result.failed; + this.overallResults.skippedTests += result.skipped; + + } catch (error) { + console.error(`💥 Suite "${suite.name}" crashed:`, error); + this.overallResults.failedSuites++; + this.results.push({ + suiteName: suite.name, + success: false, + error: { + message: error.message, + stack: error.stack + } + }); + } + } + + this.overallResults.endTime = Date.now(); + this.overallResults.duration = this.overallResults.endTime - this.overallResults.startTime; + + this._printOverallSummary(); + return this.getCompleteResults(); + } + + /** + * Run tests with HTML report generation + * @param {HTMLElement} container - Container to render results (optional) + * @returns {Promise} - Complete test results + */ + async runTestsWithReport(container = null) { + const results = await this.runAllTests(); + + if (container) { + this._renderHTMLReport(container, results); + } + + return results; + } + + /** + * Get complete test results + * @returns {Object} - All test results and metadata + */ + getCompleteResults() { + return { + overall: this.overallResults, + suites: this.results, + summary: { + success: this.overallResults.failedSuites === 0 && this.overallResults.failedTests === 0, + passRate: this.overallResults.totalTests > 0 + ? Math.round((this.overallResults.passedTests / this.overallResults.totalTests) * 100) + : 0, + message: this._getOverallMessage() + } + }; + } + + /** + * Print overall test summary + * @private + */ + _printOverallSummary() { + console.log('\n' + '='.repeat(80)); + console.log('🏆 OVERALL TEST RESULTS'); + console.log('='.repeat(80)); + + const passRate = this.overallResults.totalTests > 0 + ? Math.round((this.overallResults.passedTests / this.overallResults.totalTests) * 100) + : 0; + + const overallSuccess = this.overallResults.failedSuites === 0 && this.overallResults.failedTests === 0; + const statusIcon = overallSuccess ? '✅' : '❌'; + const statusText = overallSuccess ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'; + + console.log(`${statusIcon} ${statusText}`); + console.log(`\n📊 SUITE SUMMARY:`); + console.log(` Total Suites: ${this.overallResults.totalSuites}`); + console.log(` Passed Suites: ${this.overallResults.passedSuites}`); + console.log(` Failed Suites: ${this.overallResults.failedSuites}`); + + console.log(`\n🧪 TEST SUMMARY:`); + console.log(` Total Tests: ${this.overallResults.totalTests}`); + console.log(` Passed: ${this.overallResults.passedTests} (${passRate}%)`); + console.log(` Failed: ${this.overallResults.failedTests}`); + console.log(` Skipped: ${this.overallResults.skippedTests}`); + console.log(` Duration: ${this.overallResults.duration}ms`); + + // Suite breakdown + console.log(`\n📦 DETAILED BREAKDOWN:`); + this.results.forEach(result => { + const icon = result.success ? '✅' : '❌'; + if (result.result) { + console.log(` ${icon} ${result.suiteName}: ${result.result.passed}/${result.result.total} tests`); + } else { + console.log(` ${icon} ${result.suiteName}: CRASHED`); + } + }); + + // Performance analysis + const avgTestTime = this.overallResults.totalTests > 0 + ? Math.round(this.overallResults.duration / this.overallResults.totalTests) + : 0; + + console.log(`\n⚡ PERFORMANCE:`); + console.log(` Average per test: ${avgTestTime}ms`); + console.log(` Total execution: ${this.overallResults.duration}ms`); + + // Architecture quality assessment + console.log(`\n🏗️ ARCHITECTURE QUALITY:`); + this._assessArchitectureQuality(); + + console.log('='.repeat(80)); + } + + /** + * Assess architecture quality based on test results + * @private + */ + _assessArchitectureQuality() { + const coreArchTests = this.results.find(r => r.suiteName.includes('CoreArchitecture')); + const drsTests = this.results.find(r => r.suiteName.includes('DRS')); + + if (coreArchTests && coreArchTests.success) { + console.log(' ✅ Core Architecture: SOLID'); + } else if (coreArchTests && !coreArchTests.success) { + console.log(' ❌ Core Architecture: NEEDS ATTENTION'); + } else { + console.log(' ⚠️ Core Architecture: NOT TESTED'); + } + + if (drsTests && drsTests.success) { + console.log(' ✅ DRS Modules: WELL INTEGRATED'); + } else if (drsTests && !drsTests.success) { + console.log(' ❌ DRS Modules: INTEGRATION ISSUES'); + } else { + console.log(' ⚠️ DRS Modules: NOT TESTED'); + } + + // Performance assessment + if (this.overallResults.duration < 5000) { // Under 5 seconds + console.log(' ✅ Performance: EXCELLENT'); + } else if (this.overallResults.duration < 15000) { // Under 15 seconds + console.log(' ⚠️ Performance: ACCEPTABLE'); + } else { + console.log(' ❌ Performance: SLOW - NEEDS OPTIMIZATION'); + } + + // Coverage assessment + const passRate = this.overallResults.totalTests > 0 + ? (this.overallResults.passedTests / this.overallResults.totalTests) + : 0; + + if (passRate >= 0.95) { + console.log(' ✅ Test Coverage: EXCELLENT'); + } else if (passRate >= 0.8) { + console.log(' ⚠️ Test Coverage: GOOD'); + } else { + console.log(' ❌ Test Coverage: INSUFFICIENT'); + } + } + + /** + * Get overall result message + * @returns {string} - Summary message + * @private + */ + _getOverallMessage() { + if (this.overallResults.failedSuites === 0 && this.overallResults.failedTests === 0) { + return '🎉 All tests passed! Architecture is solid and modules are working correctly.'; + } else if (this.overallResults.failedTests > 0 && this.overallResults.passedTests > this.overallResults.failedTests) { + return '⚠️ Most tests passed, but some issues need attention.'; + } else { + return '❌ Significant test failures detected. Architecture or implementation needs review.'; + } + } + + /** + * Render HTML report in container + * @param {HTMLElement} container - Container element + * @param {Object} results - Test results + * @private + */ + _renderHTMLReport(container, results) { + const passRate = results.overall.passedTests > 0 + ? Math.round((results.overall.passedTests / results.overall.totalTests) * 100) + : 0; + + const html = ` +
+
+

🧪 Test Report

+
+ ${results.summary.success ? '✅' : '❌'} ${results.summary.message} +
+
+ +
+
+

📊 Overall

+
${results.overall.totalTests} Tests
+
${passRate}% Pass Rate
+
+
+

✅ Passed

+
${results.overall.passedTests}
+
${results.overall.passedSuites} Suites
+
+
+

❌ Failed

+
${results.overall.failedTests}
+
${results.overall.failedSuites} Suites
+
+
+

⚡ Duration

+
${results.overall.duration}ms
+
${Math.round(results.overall.duration / results.overall.totalTests)}ms avg
+
+
+ +
+

📦 Test Suites

+ ${results.suites.map(suite => ` +
+
+

${suite.success ? '✅' : '❌'} ${suite.suiteName}

+ ${suite.result ? `${suite.result.passed}/${suite.result.total} tests` : 'CRASHED'} +
+ ${suite.error ? `
Error: ${suite.error.message}
` : ''} + ${suite.result && suite.result.failed > 0 ? ` +
+ Failed Tests: + ${suite.result.details.filter(d => d.state === 'failed').map(d => + `
${d.name}: ${d.error?.message || 'Unknown error'}
` + ).join('')} +
+ ` : ''} +
+ `).join('')} +
+
+ `; + + container.innerHTML = html; + this._addReportStyles(); + } + + /** + * Add CSS styles for HTML report + * @private + */ + _addReportStyles() { + if (document.getElementById('test-report-styles')) return; + + const styles = document.createElement('style'); + styles.id = 'test-report-styles'; + styles.textContent = ` + .test-report { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + max-width: 1200px; + margin: 0 auto; + padding: 20px; + } + + .report-header { + text-align: center; + margin-bottom: 30px; + } + + .overall-status { + font-size: 1.2em; + font-weight: 600; + padding: 15px 30px; + border-radius: 8px; + margin-top: 15px; + } + + .overall-status.success { + background: linear-gradient(135deg, #e8f5e8, #f1f8e9); + color: #2e7d32; + border: 2px solid #4caf50; + } + + .overall-status.failure { + background: linear-gradient(135deg, #ffebee, #ffcdd2); + color: #c62828; + border: 2px solid #f44336; + } + + .summary-cards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin-bottom: 40px; + } + + .summary-card { + background: white; + border-radius: 12px; + padding: 20px; + box-shadow: 0 4px 15px rgba(0,0,0,0.1); + text-align: center; + border-left: 4px solid #667eea; + } + + .summary-card h3 { + margin: 0 0 15px 0; + color: #333; + font-size: 1.1em; + } + + .metric { + font-size: 2.5em; + font-weight: bold; + color: #333; + margin-bottom: 5px; + } + + .sub-metric { + color: #666; + font-size: 0.9em; + } + + .suites-breakdown { + margin-top: 40px; + } + + .suites-breakdown h2 { + margin-bottom: 20px; + color: #333; + } + + .suite-card { + background: white; + border-radius: 8px; + padding: 20px; + margin-bottom: 15px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + } + + .suite-card.success { + border-left: 4px solid #4caf50; + } + + .suite-card.failure { + border-left: 4px solid #f44336; + } + + .suite-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + } + + .suite-header h3 { + margin: 0; + color: #333; + } + + .suite-stats { + background: #f5f5f5; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.9em; + color: #666; + } + + .suite-error { + background: #ffcdd2; + color: #c62828; + padding: 4px 12px; + border-radius: 20px; + font-size: 0.9em; + } + + .error-details { + background: #fff3e0; + border: 1px solid #ffb74d; + border-radius: 4px; + padding: 10px; + margin-top: 10px; + color: #e65100; + font-size: 0.9em; + } + + .failed-tests { + margin-top: 15px; + padding: 15px; + background: #ffebee; + border-radius: 4px; + } + + .failed-test { + margin: 5px 0; + padding: 5px; + background: white; + border-radius: 3px; + font-family: monospace; + font-size: 0.85em; + color: #c62828; + } + + @media (max-width: 768px) { + .summary-cards { + grid-template-columns: 1fr; + } + + .suite-header { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + } + `; + + document.head.appendChild(styles); + } +} + +export default TestRunner; \ No newline at end of file diff --git a/src/testing/runTests.js b/src/testing/runTests.js new file mode 100644 index 0000000..57987e2 --- /dev/null +++ b/src/testing/runTests.js @@ -0,0 +1,246 @@ +/** + * runTests.js - Main test execution entry point + * Runs all test suites and provides comprehensive reporting + */ + +import TestRunner from './TestRunner.js'; +import { CoreArchitectureTests } from './tests/CoreArchitectureTests.js'; +import { DRSModuleTests } from './tests/DRSModuleTests.js'; + +/** + * Main test execution function + * @param {HTMLElement} reportContainer - Optional container for HTML report + * @returns {Promise} - Complete test results + */ +async function runAllTests(reportContainer = null) { + console.log('🧪 Class Generator 2.0 - Test Suite'); + console.log('Testing architecture and DRS modules...\n'); + + // Create test runner + const testRunner = new TestRunner(); + + // Register all test suites + testRunner.registerSuite('Core Architecture Tests', CoreArchitectureTests); + testRunner.registerSuite('DRS Module Tests', DRSModuleTests); + + // Run tests with optional HTML report + const results = await testRunner.runTestsWithReport(reportContainer); + + // Additional validation and recommendations + await generateRecommendations(results); + + return results; +} + +/** + * Generate recommendations based on test results + * @param {Object} results - Test results + */ +async function generateRecommendations(results) { + console.log('\n🎯 RECOMMENDATIONS:'); + console.log('-'.repeat(50)); + + const issues = []; + const improvements = []; + + // Analyze results + if (results.overall.failedTests > 0) { + issues.push('❌ Failed tests need immediate attention'); + } + + if (results.overall.duration > 10000) { + issues.push('⚠️ Test execution is slow - consider optimization'); + } + + const passRate = results.overall.totalTests > 0 + ? (results.overall.passedTests / results.overall.totalTests) + : 0; + + if (passRate < 0.8) { + issues.push('❌ Test pass rate below 80% - architecture may need review'); + } + + // Check specific areas + const coreTests = results.suites.find(s => s.suiteName.includes('Core')); + const drsTests = results.suites.find(s => s.suiteName.includes('DRS')); + + if (coreTests && !coreTests.success) { + issues.push('🏗️ Core architecture has issues - this is critical'); + improvements.push('Review Module.js, EventBus.js, and ModuleLoader.js implementation'); + } + + if (drsTests && !drsTests.success) { + issues.push('🎮 DRS modules have issues - affects user experience'); + improvements.push('Review exercise module implementations and interfaces'); + } + + // Print issues + if (issues.length > 0) { + console.log('🚨 ISSUES DETECTED:'); + issues.forEach(issue => console.log(` ${issue}`)); + } else { + console.log('✅ No major issues detected!'); + } + + // Print improvements + if (improvements.length > 0) { + console.log('\n💡 SUGGESTED IMPROVEMENTS:'); + improvements.forEach(improvement => console.log(` • ${improvement}`)); + } + + // Next steps based on results + console.log('\n🚀 NEXT STEPS:'); + + if (results.summary.success) { + console.log(' ✅ All tests pass - ready for PHASE 2.2 (Component Extraction)'); + console.log(' • Extract UI components from DRS modules'); + console.log(' • Create reusable component library'); + console.log(' • Implement component registration system'); + } else if (passRate >= 0.7) { + console.log(' ⚠️ Address failing tests before proceeding to next phase'); + console.log(' • Fix critical architecture issues first'); + console.log(' • Re-run tests after fixes'); + console.log(' • Consider test-driven development for remaining work'); + } else { + console.log(' ❌ Major issues detected - extensive review needed'); + console.log(' • Focus on core architecture stability'); + console.log(' • Review module contracts and dependencies'); + console.log(' • Consider architectural refactoring if needed'); + } + + // Performance recommendations + if (results.overall.duration > 5000) { + console.log('\n⚡ PERFORMANCE OPTIMIZATION:'); + console.log(' • Consider lazy loading of modules'); + console.log(' • Review EventBus efficiency'); + console.log(' • Optimize module initialization sequences'); + } + + console.log('-'.repeat(50)); +} + +/** + * Run tests in browser environment + * Creates a test page with results + */ +function runTestsInBrowser() { + // Create test page container + document.body.innerHTML = ` +
+
+

🧪 Class Generator 2.0 - Test Results

+

Comprehensive testing of architecture and modules

+
+
+
+
+ Running tests... +
+
+ +
+ `; + + // Add basic styles + const styles = document.createElement('style'); + styles.textContent = ` + body { + margin: 0; + padding: 20px; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + color: #333; + } + + #test-page { + max-width: 1200px; + margin: 0 auto; + } + + header { + text-align: center; + color: white; + margin-bottom: 40px; + } + + header h1 { + font-size: 2.5em; + margin-bottom: 10px; + } + + .progress-indicator { + text-align: center; + background: white; + padding: 40px; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + } + + .spinner { + width: 40px; + height: 40px; + border: 4px solid #f3f3f3; + border-top: 4px solid #667eea; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 20px auto; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + #test-results { + background: white; + border-radius: 12px; + padding: 20px; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + } + `; + document.head.appendChild(styles); + + // Run tests + const progressDiv = document.getElementById('test-progress'); + const resultsDiv = document.getElementById('test-results'); + + setTimeout(async () => { + try { + await runAllTests(resultsDiv); + progressDiv.style.display = 'none'; + resultsDiv.style.display = 'block'; + } catch (error) { + progressDiv.innerHTML = ` +
+

❌ Test execution failed

+

Error: ${error.message}

+
${error.stack}
+
+ `; + } + }, 100); +} + +/** + * Auto-run tests if in browser + */ +if (typeof window !== 'undefined' && typeof document !== 'undefined') { + // Browser environment - create test page + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', runTestsInBrowser); + } else { + runTestsInBrowser(); + } +} else { + // Node.js environment - just export the function + console.log('Test runner loaded - use runAllTests() to execute'); +} + +// Export for both Node.js and browser environments +export { runAllTests, generateRecommendations }; + +// Also make available globally in browser +if (typeof window !== 'undefined') { + window.runAllTests = runAllTests; +} \ No newline at end of file diff --git a/src/testing/tests/CoreArchitectureTests.js b/src/testing/tests/CoreArchitectureTests.js new file mode 100644 index 0000000..48375b2 --- /dev/null +++ b/src/testing/tests/CoreArchitectureTests.js @@ -0,0 +1,400 @@ +/** + * CoreArchitectureTests - Tests for core architecture components + * Validates Module.js, EventBus.js, ModuleLoader.js, Router.js, Application.js + */ + +import { TestFramework } from '../TestFramework.js'; + +// We'll dynamically import core modules to avoid dependency issues +let Module, EventBus, ModuleLoader, Router, Application; + +const testFramework = new TestFramework(); + +// Test Module.js - Abstract base class +testFramework.describe('Module.js - Abstract Base Class', function() { + + this.beforeAll(async () => { + try { + Module = (await import('../../core/Module.js')).default; + } catch (error) { + console.warn('Could not import Module.js, using mock for tests'); + Module = class MockModule { + constructor(name, dependencies) { + if (this.constructor === Module) { + throw new Error('Module is abstract and cannot be instantiated directly'); + } + this.name = name; + this.dependencies = dependencies || []; + this._initialized = false; + this._destroyed = false; + } + + _validateNotDestroyed() { + if (this._destroyed) throw new Error('Module has been destroyed'); + } + + _setInitialized() { this._initialized = true; } + _setDestroyed() { this._destroyed = true; } + + async init() { throw new Error('init() must be implemented by subclass'); } + async destroy() { throw new Error('destroy() must be implemented by subclass'); } + }; + } + }); + + this.it('should not allow direct instantiation of abstract Module class', (assert) => { + assert.assertThrows( + () => new Module('test', []), + 'Module is abstract and cannot be instantiated directly', + 'Module class should be abstract' + ); + }); + + this.it('should allow subclass instantiation with proper parameters', (assert) => { + class TestModule extends Module { + constructor(name, dependencies) { + super(name, dependencies); + Object.seal(this); + } + async init() { this._setInitialized(); } + async destroy() { this._setDestroyed(); } + } + + const testModule = new TestModule('testModule', ['eventBus']); + + assert.assertEqual(testModule.name, 'testModule'); + assert.assertContains(testModule.dependencies, 'eventBus'); + assert.assertFalse(testModule._initialized); + assert.assertFalse(testModule._destroyed); + }); + + this.it('should enforce abstract method implementation', (assert) => { + class IncompleteModule extends Module { + constructor(name, dependencies) { + super(name, dependencies); + } + // Missing init() and destroy() implementations + } + + const incompleteModule = new IncompleteModule('incomplete', []); + + assert.assertThrows( + () => incompleteModule.init(), + 'Module incomplete: init() method must be implemented' + ); + + assert.assertThrows( + () => incompleteModule.destroy(), + 'Module incomplete: destroy() method must be implemented' + ); + }); + + this.it('should handle module lifecycle correctly', async (assert) => { + class LifecycleModule extends Module { + constructor(name, dependencies) { + super(name, dependencies); + Object.seal(this); + } + + async init() { + this._validateNotDestroyed(); + this._setInitialized(); + } + + async destroy() { + this._validateNotDestroyed(); + this._setDestroyed(); + } + } + + const module = new LifecycleModule('lifecycle', []); + + // Initially not initialized or destroyed + assert.assertFalse(module._initialized); + assert.assertFalse(module._destroyed); + + // After init + await module.init(); + assert.assertTrue(module._initialized); + assert.assertFalse(module._destroyed); + + // After destroy + await module.destroy(); + assert.assertTrue(module._destroyed); + + // Should not allow operations after destroy + assert.assertThrows( + () => module.init(), + 'Module has been destroyed' + ); + }); + + this.it('should prevent modification after sealing', (assert) => { + class SealedModule extends Module { + constructor(name, dependencies) { + super(name, dependencies); + Object.seal(this); + } + async init() { this._setInitialized(); } + async destroy() { this._setDestroyed(); } + } + + const module = new SealedModule('sealed', []); + + // Try to add new properties (should fail silently in non-strict mode) + module.newProperty = 'should not work'; + assert.assertUndefined(module.newProperty, 'Sealed object should not accept new properties'); + }); +}); + +// Test EventBus.js - Event communication system +testFramework.describe('EventBus.js - Event Communication System', function() { + + this.beforeAll(async () => { + try { + EventBus = (await import('../../core/EventBus.js')).default; + } catch (error) { + console.warn('Could not import EventBus.js, using mock for tests'); + EventBus = class MockEventBus { + constructor() { + this.events = new Map(); + this.registeredModules = new Set(); + this.eventHistory = []; + } + + registerModule(moduleName) { + this.registeredModules.add(moduleName); + } + + on(eventName, callback, moduleName) { + if (!this.registeredModules.has(moduleName)) { + throw new Error('EventBus requires module registration before use'); + } + if (!this.events.has(eventName)) { + this.events.set(eventName, []); + } + this.events.get(eventName).push({ callback, moduleName }); + } + + emit(eventName, data, senderModule) { + const event = { eventName, data, senderModule, timestamp: Date.now() }; + this.eventHistory.push(event); + + if (this.events.has(eventName)) { + this.events.get(eventName).forEach(({ callback }) => { + try { callback(event); } catch (error) { console.error(error); } + }); + } + } + + off(eventName, callback, moduleName) { + if (this.events.has(eventName)) { + const listeners = this.events.get(eventName); + this.events.set(eventName, listeners.filter( + listener => !(listener.callback === callback && listener.moduleName === moduleName) + )); + } + } + + getEventHistory() { return this.eventHistory; } + }; + } + }); + + this.it('should require module registration before event usage', (assert) => { + const eventBus = new EventBus(); + + assert.assertThrows( + () => eventBus.on('test:event', () => {}, 'unregisteredModule'), + 'Module unregisteredModule is not registered with EventBus' + ); + }); + + this.it('should allow event registration and emission after module registration', (assert) => { + const eventBus = new EventBus(); + let eventReceived = false; + let eventData = null; + + // Register module + const testModule = { name: 'testModule' }; + eventBus.registerModule(testModule); + + // Register event listener + eventBus.on('test:event', (event) => { + eventReceived = true; + eventData = event.data; + }, 'testModule'); + + // Emit event + eventBus.emit('test:event', { message: 'hello' }, 'testModule'); + + assert.assertTrue(eventReceived, 'Event should have been received'); + assert.assertEqual(eventData.message, 'hello', 'Event data should be correct'); + }); + + this.it('should maintain event history', (assert) => { + const eventBus = new EventBus(); + + const historyModule = { name: 'historyModule' }; + eventBus.registerModule(historyModule); + eventBus.emit('history:test1', { id: 1 }, 'historyModule'); + eventBus.emit('history:test2', { id: 2 }, 'historyModule'); + + const history = eventBus.getEventHistory(); + assert.assertEqual(history.length, 2, 'Should have 2 events in history'); + assert.assertEqual(history[0].eventName, 'history:test1'); + assert.assertEqual(history[1].eventName, 'history:test2'); + }); + + this.it('should allow event listener removal', (assert) => { + const eventBus = new EventBus(); + let callCount = 0; + + const removalModule = { name: 'removalModule' }; + eventBus.registerModule(removalModule); + + const listener = () => { callCount++; }; + eventBus.on('removal:test', listener, 'removalModule'); + + // Emit once + eventBus.emit('removal:test', {}, 'removalModule'); + assert.assertEqual(callCount, 1, 'Should have been called once'); + + // Remove listener and emit again + eventBus.off('removal:test', listener, 'removalModule'); + eventBus.emit('removal:test', {}, 'removalModule'); + assert.assertEqual(callCount, 1, 'Should still be 1 after removal'); + }); + + this.it('should handle errors in event callbacks gracefully', (assert) => { + const eventBus = new EventBus(); + let goodListenerCalled = false; + + const errorModule = { name: 'errorModule' }; + eventBus.registerModule(errorModule); + + // Add a listener that throws + eventBus.on('error:test', () => { + throw new Error('Test error'); + }, 'errorModule'); + + // Add a good listener + eventBus.on('error:test', () => { + goodListenerCalled = true; + }, 'errorModule'); + + // This should not throw, even with error in first listener + assert.assertDoesNotThrow( + () => eventBus.emit('error:test', {}, 'errorModule'), + 'EventBus should handle listener errors gracefully' + ); + + assert.assertTrue(goodListenerCalled, 'Good listener should still be called'); + }); +}); + +// Test Module Isolation +testFramework.describe('Module Isolation and Architecture Integrity', function() { + + this.it('should enforce zero direct dependencies between modules', (assert) => { + // This test validates that modules can only communicate via EventBus + + class ModuleA extends (Module || class {}) { + constructor() { + super('moduleA', ['eventBus']); + this.eventBus = null; + this.receivedMessages = []; + Object.seal(this); + } + + setEventBus(eventBus) { + this.eventBus = eventBus; + const moduleA = { name: 'moduleA' }; + this.eventBus.registerModule(moduleA); + this.eventBus.on('moduleB:message', (event) => { + this.receivedMessages.push(event.data); + }, 'moduleA'); + } + + async init() { this._setInitialized && this._setInitialized(); } + async destroy() { this._setDestroyed && this._setDestroyed(); } + + sendMessage(message) { + this.eventBus.emit('moduleA:message', { message }, 'moduleA'); + } + } + + class ModuleB extends (Module || class {}) { + constructor() { + super('moduleB', ['eventBus']); + this.eventBus = null; + this.receivedMessages = []; + Object.seal(this); + } + + setEventBus(eventBus) { + this.eventBus = eventBus; + const moduleB = { name: 'moduleB' }; + this.eventBus.registerModule(moduleB); + this.eventBus.on('moduleA:message', (event) => { + this.receivedMessages.push(event.data); + // Respond back + this.eventBus.emit('moduleB:message', { response: 'received' }, 'moduleB'); + }, 'moduleB'); + } + + async init() { this._setInitialized && this._setInitialized(); } + async destroy() { this._setDestroyed && this._setDestroyed(); } + } + + const eventBus = new EventBus(); + const moduleA = new ModuleA(); + const moduleB = new ModuleB(); + + moduleA.setEventBus(eventBus); + moduleB.setEventBus(eventBus); + + // ModuleA sends message to ModuleB via EventBus + moduleA.sendMessage('hello'); + + assert.assertEqual(moduleB.receivedMessages.length, 1, 'ModuleB should receive message via EventBus'); + assert.assertEqual(moduleA.receivedMessages.length, 1, 'ModuleA should receive response via EventBus'); + assert.assertEqual(moduleB.receivedMessages[0].message, 'hello'); + assert.assertEqual(moduleA.receivedMessages[0].response, 'received'); + }); + + this.it('should prevent external modification of module internals', (assert) => { + class ProtectedModule extends (Module || class {}) { + constructor() { + super('protected', []); + // Private data should be truly private (WeakMap would be ideal) + this._privateData = { secret: 'hidden' }; + Object.seal(this); + } + + async init() { this._setInitialized && this._setInitialized(); } + async destroy() { this._setDestroyed && this._setDestroyed(); } + + getPublicData() { + return { name: this.name }; + } + } + + const module = new ProtectedModule(); + + // Should not be able to add new properties + module.newProperty = 'should not work'; + assert.assertUndefined(module.newProperty, 'Should not be able to add properties to sealed module'); + + // Should not be able to modify existing properties (in strict mode) + const originalName = module.name; + module.name = 'modified'; + // In some environments this might work due to non-strict mode, but architecture should prevent it + + // Should be able to call public methods + const publicData = module.getPublicData(); + assert.assertDefined(publicData.name, 'Public method should work'); + }); +}); + +export { testFramework as CoreArchitectureTests }; \ No newline at end of file diff --git a/src/testing/tests/DRSModuleTests.js b/src/testing/tests/DRSModuleTests.js new file mode 100644 index 0000000..a84c0e7 --- /dev/null +++ b/src/testing/tests/DRSModuleTests.js @@ -0,0 +1,457 @@ +/** + * DRSModuleTests - Tests for DRS exercise modules + * Validates TextModule, AudioModule, ImageModule, GrammarModule + */ + +import { TestFramework } from '../TestFramework.js'; + +// Mock dependencies for DRS modules +const mockOrchestrator = { + sessionId: 'test-session', + bookId: 'test-book', + chapterId: 'test-chapter', + _eventBus: { + emit: (event, data, sender) => { + console.log(`Event emitted: ${event}`, data); + } + } +}; + +const mockLLMValidator = { + testConnectivity: async () => ({ success: true, provider: 'openai' }), + iaEngine: { + validateEducationalContent: async (prompt, options) => { + // Mock AI response + return '[answer]yes [explanation]This is a mock validation response for testing purposes.'; + } + } +}; + +const mockPrerequisiteEngine = { + canUnlock: (type, content) => ({ canUnlock: true, reason: 'test' }), + markWordMastered: (word, metadata) => console.log(`Word mastered: ${word}`, metadata), + markPhraseMastered: (phrase, metadata) => console.log(`Phrase mastered: ${phrase}`, metadata), + markGrammarMastered: (rule, metadata) => console.log(`Grammar mastered: ${rule}`, metadata) +}; + +const mockContextMemory = { + recordInteraction: (interaction) => console.log('Interaction recorded:', interaction) +}; + +const testFramework = new TestFramework(); + +// Test ExerciseModuleInterface +testFramework.describe('ExerciseModuleInterface - Contract Validation', function() { + + let ExerciseModuleInterface; + + this.beforeAll(async () => { + try { + ExerciseModuleInterface = (await import('../../DRS/interfaces/ExerciseModuleInterface.js')).default; + } catch (error) { + console.warn('Could not import ExerciseModuleInterface, using mock'); + ExerciseModuleInterface = class MockExerciseModuleInterface { + canRun() { throw new Error('ExerciseModuleInterface.canRun() must be implemented by subclass'); } + async present() { throw new Error('ExerciseModuleInterface.present() must be implemented by subclass'); } + async validate() { throw new Error('ExerciseModuleInterface.validate() must be implemented by subclass'); } + getProgress() { throw new Error('ExerciseModuleInterface.getProgress() must be implemented by subclass'); } + cleanup() { throw new Error('ExerciseModuleInterface.cleanup() must be implemented by subclass'); } + getMetadata() { throw new Error('ExerciseModuleInterface.getMetadata() must be implemented by subclass'); } + }; + } + }); + + this.it('should enforce implementation of all required methods', (assert) => { + const interface_ = new ExerciseModuleInterface(); + + // All methods should throw errors when not implemented + assert.assertThrows( + () => interface_.canRun([], {}), + 'ExerciseModuleInterface.canRun() must be implemented by subclass' + ); + + assert.assertThrows( + () => interface_.present(null, {}), + 'ExerciseModuleInterface.present() must be implemented by subclass' + ); + + assert.assertThrows( + () => interface_.validate('', {}), + 'ExerciseModuleInterface.validate() must be implemented by subclass' + ); + + assert.assertThrows( + () => interface_.getProgress(), + 'ExerciseModuleInterface.getProgress() must be implemented by subclass' + ); + + assert.assertThrows( + () => interface_.cleanup(), + 'ExerciseModuleInterface.cleanup() must be implemented by subclass' + ); + + assert.assertThrows( + () => interface_.getMetadata(), + 'ExerciseModuleInterface.getMetadata() must be implemented by subclass' + ); + }); +}); + +// Test TextModule +testFramework.describe('TextModule - Reading Comprehension', function() { + + let TextModule; + let textModule; + + this.beforeAll(async () => { + try { + TextModule = (await import('../../DRS/exercise-modules/TextModule.js')).default; + } catch (error) { + console.warn('Could not import TextModule, skipping tests'); + TextModule = null; + } + }); + + this.beforeEach(() => { + if (TextModule) { + textModule = new TextModule(mockOrchestrator, mockLLMValidator, mockPrerequisiteEngine, mockContextMemory); + } + }); + + this.afterEach(() => { + if (textModule) { + try { + textModule.cleanup(); + } catch (error) { + // Ignore cleanup errors in tests + } + } + }); + + this.it('should initialize with correct dependencies', (assert) => { + if (!TextModule) { + console.log('Skipping TextModule test - module not available'); + return; + } + + assert.assertDefined(textModule.orchestrator, 'Should have orchestrator dependency'); + assert.assertDefined(textModule.llmValidator, 'Should have llmValidator dependency'); + assert.assertDefined(textModule.prerequisiteEngine, 'Should have prerequisiteEngine dependency'); + assert.assertDefined(textModule.contextMemory, 'Should have contextMemory dependency'); + assert.assertFalse(textModule.initialized, 'Should not be initialized initially'); + }); + + this.it('should reject initialization without required dependencies', (assert) => { + if (!TextModule) return; + + assert.assertThrows( + () => new TextModule(null, mockLLMValidator, mockPrerequisiteEngine, mockContextMemory), + 'TextModule requires all service dependencies' + ); + + assert.assertThrows( + () => new TextModule(mockOrchestrator, null, mockPrerequisiteEngine, mockContextMemory), + 'TextModule requires all service dependencies' + ); + }); + + this.it('should initialize correctly', async (assert) => { + if (!TextModule) return; + + await textModule.init(); + assert.assertTrue(textModule.initialized, 'Should be initialized after init()'); + }); + + this.it('should implement all required interface methods', (assert) => { + if (!TextModule) return; + + // Check that methods exist and don't throw "not implemented" errors + assert.assertDefined(textModule.canRun, 'Should have canRun method'); + assert.assertDefined(textModule.present, 'Should have present method'); + assert.assertDefined(textModule.validate, 'Should have validate method'); + assert.assertDefined(textModule.getProgress, 'Should have getProgress method'); + assert.assertDefined(textModule.cleanup, 'Should have cleanup method'); + assert.assertDefined(textModule.getMetadata, 'Should have getMetadata method'); + }); + + this.it('should return correct metadata', (assert) => { + if (!TextModule) return; + + const metadata = textModule.getMetadata(); + + assert.assertEqual(metadata.name, 'TextModule'); + assert.assertEqual(metadata.type, 'text'); + assert.assertDefined(metadata.version); + assert.assertDefined(metadata.description); + assert.assertTrue(Array.isArray(metadata.capabilities)); + assert.assertContains(metadata.capabilities, 'text_comprehension'); + }); + + this.it('should handle canRun logic correctly', (assert) => { + if (!TextModule) return; + + // Test with empty content + const emptyContent = { texts: [] }; + assert.assertFalse(textModule.canRun([], emptyContent), 'Should return false for empty texts'); + + // Test with available content + const validContent = { + texts: [ + { id: 'text1', title: 'Test Text', content: 'Sample content' } + ] + }; + assert.assertTrue(textModule.canRun([], validContent), 'Should return true for available texts'); + }); + + this.it('should validate user input correctly', async (assert) => { + if (!TextModule) return; + + await textModule.init(); + + // Set up mock exercise data + textModule.currentText = { title: 'Test', content: 'Test content' }; + textModule.currentQuestion = { question: 'Test question?', keywords: ['test'] }; + textModule.questionIndex = 0; + textModule.questions = [textModule.currentQuestion]; + + const result = await textModule.validate('This is a test answer', {}); + + assert.assertDefined(result.score, 'Should return a score'); + assert.assertDefined(result.correct, 'Should return correct status'); + assert.assertDefined(result.feedback, 'Should return feedback'); + assert.assertTrue(typeof result.score === 'number', 'Score should be a number'); + assert.assertTrue(typeof result.correct === 'boolean', 'Correct should be boolean'); + }); + + this.it('should track progress correctly', (assert) => { + if (!TextModule) return; + + textModule.questions = [ + { question: 'Q1' }, + { question: 'Q2' }, + { question: 'Q3' } + ]; + textModule.questionResults = [ + { correct: true, score: 90 }, + { correct: false, score: 60 } + ]; + + const progress = textModule.getProgress(); + + assert.assertEqual(progress.type, 'text'); + assert.assertEqual(progress.totalQuestions, 3); + assert.assertEqual(progress.completedQuestions, 2); + assert.assertEqual(progress.correctAnswers, 1); + assert.assertEqual(progress.progressPercentage, 67); // 2/3 * 100, rounded + assert.assertEqual(progress.comprehensionRate, 50); // 1/2 * 100 + }); +}); + +// Test AudioModule +testFramework.describe('AudioModule - Listening Exercises', function() { + + let AudioModule; + let audioModule; + + this.beforeAll(async () => { + try { + AudioModule = (await import('../../DRS/exercise-modules/AudioModule.js')).default; + } catch (error) { + console.warn('Could not import AudioModule, skipping tests'); + AudioModule = null; + } + }); + + this.beforeEach(() => { + if (AudioModule) { + audioModule = new AudioModule(mockOrchestrator, mockLLMValidator, mockPrerequisiteEngine, mockContextMemory); + } + }); + + this.afterEach(() => { + if (audioModule) { + try { + audioModule.cleanup(); + } catch (error) { + // Ignore cleanup errors + } + } + }); + + this.it('should initialize correctly and track playback', async (assert) => { + if (!AudioModule) return; + + await audioModule.init(); + + assert.assertEqual(audioModule.playCount, 0, 'Should start with 0 playbacks'); + assert.assertFalse(audioModule.initialized === false, 'Should be initialized'); + }); + + this.it('should handle audio-specific validation with playback penalties', async (assert) => { + if (!AudioModule) return; + + await audioModule.init(); + + // Set up mock data + audioModule.currentAudio = { title: 'Test Audio', duration: 30 }; + audioModule.currentQuestion = { question: 'What did you hear?', keywords: ['audio'] }; + audioModule.playCount = 6; // Exceeds maxPlaybacks (5) + audioModule.questionIndex = 0; + audioModule.questions = [audioModule.currentQuestion]; + + const result = await audioModule.validate('I heard test audio', {}); + + // Should apply penalty for excessive playbacks + assert.assertTrue(result.feedback.includes('excessive playbacks') || result.score < 90, + 'Should apply penalty for too many playbacks'); + }); + + this.it('should return audio-specific progress data', (assert) => { + if (!AudioModule) return; + + audioModule.currentAudio = { title: 'Test Audio' }; + audioModule.playCount = 3; + audioModule.questions = [{ question: 'Q1' }]; + audioModule.questionResults = [{ correct: true, score: 85 }]; + + const progress = audioModule.getProgress(); + + assert.assertEqual(progress.type, 'audio'); + assert.assertEqual(progress.playbackCount, 3); + assert.assertDefined(progress.audioTitle); + }); +}); + +// Test ImageModule +testFramework.describe('ImageModule - Visual Comprehension', function() { + + let ImageModule; + let imageModule; + + this.beforeAll(async () => { + try { + ImageModule = (await import('../../DRS/exercise-modules/ImageModule.js')).default; + } catch (error) { + console.warn('Could not import ImageModule, skipping tests'); + ImageModule = null; + } + }); + + this.beforeEach(() => { + if (ImageModule) { + imageModule = new ImageModule(mockOrchestrator, mockLLMValidator, mockPrerequisiteEngine, mockContextMemory); + } + }); + + this.afterEach(() => { + if (imageModule) { + try { + imageModule.cleanup(); + } catch (error) { + // Ignore cleanup errors + } + } + }); + + this.it('should initialize and track viewing time', async (assert) => { + if (!ImageModule) return; + + await imageModule.init(); + + assert.assertEqual(imageModule.viewingTime, 0, 'Should start with 0 viewing time'); + assert.assertNull(imageModule.startViewTime, 'Should start with null view timer'); + }); + + this.it('should handle image-specific metadata', (assert) => { + if (!ImageModule) return; + + const metadata = imageModule.getMetadata(); + + assert.assertEqual(metadata.name, 'ImageModule'); + assert.assertEqual(metadata.type, 'image'); + assert.assertContains(metadata.capabilities, 'image_comprehension'); + assert.assertContains(metadata.capabilities, 'visual_analysis'); + assert.assertTrue(metadata.aiRequired, 'Image module should require AI for vision analysis'); + }); + + this.it('should track observation time in progress', (assert) => { + if (!ImageModule) return; + + imageModule.viewingTime = 15; // 15 seconds + imageModule.currentImage = { title: 'Test Image' }; + + const progress = imageModule.getProgress(); + + assert.assertEqual(progress.type, 'image'); + assert.assertEqual(progress.observationTime, 15); + }); +}); + +// Test GrammarModule +testFramework.describe('GrammarModule - Grammar Exercises', function() { + + let GrammarModule; + let grammarModule; + + this.beforeAll(async () => { + try { + GrammarModule = (await import('../../DRS/exercise-modules/GrammarModule.js')).default; + } catch (error) { + console.warn('Could not import GrammarModule, skipping tests'); + GrammarModule = null; + } + }); + + this.beforeEach(() => { + if (GrammarModule) { + grammarModule = new GrammarModule(mockOrchestrator, mockLLMValidator, mockPrerequisiteEngine, mockContextMemory); + } + }); + + this.afterEach(() => { + if (grammarModule) { + try { + grammarModule.cleanup(); + } catch (error) { + // Ignore cleanup errors + } + } + }); + + this.it('should handle different exercise types', (assert) => { + if (!GrammarModule) return; + + const exerciseTypes = grammarModule.exerciseTypes; + + assert.assertDefined(exerciseTypes.fill_blank, 'Should support fill in the blank'); + assert.assertDefined(exerciseTypes.correction, 'Should support error correction'); + assert.assertDefined(exerciseTypes.transformation, 'Should support sentence transformation'); + assert.assertDefined(exerciseTypes.multiple_choice, 'Should support multiple choice'); + assert.assertDefined(exerciseTypes.conjugation, 'Should support verb conjugation'); + assert.assertDefined(exerciseTypes.construction, 'Should support sentence construction'); + }); + + this.it('should track attempts and hints', (assert) => { + if (!GrammarModule) return; + + grammarModule.attempts = 2; + grammarModule.hintUsed = true; + + const progress = grammarModule.getProgress(); + + assert.assertEqual(progress.currentAttempts, 2); + assert.assertTrue(progress.hintUsed); + }); + + this.it('should have strict grammar validation settings', (assert) => { + if (!GrammarModule) return; + + const config = grammarModule.config; + + assert.assertEqual(config.temperature, 0.1, 'Should use low temperature for grammar accuracy'); + assert.assertTrue(config.maxAttempts > 0, 'Should allow multiple attempts'); + assert.assertTrue(config.showHints, 'Should support hints'); + }); +}); + +export { testFramework as DRSModuleTests }; \ No newline at end of file diff --git a/src/utils/ContentLoader.js b/src/utils/ContentLoader.js new file mode 100644 index 0000000..9324046 --- /dev/null +++ b/src/utils/ContentLoader.js @@ -0,0 +1,411 @@ +/** + * ContentLoader - Système de chargement et analyse de contenu JSON + * Génère des rapports de contenu et des statistiques + */ + +class ContentLoader { + constructor() { + this._cache = new Map(); + this._contentReports = new Map(); + this._booksCache = new Map(); + this._booksLoaded = false; + } + + /** + * Charge et analyse un fichier JSON de contenu + * @param {string} bookId - ID du livre + * @returns {Promise} - Contenu chargé avec rapport + */ + async loadContent(bookId) { + // Vérifier le cache + if (this._cache.has(bookId)) { + return this._cache.get(bookId); + } + + try { + const response = await fetch(`/content/chapters/${bookId}.json`); + if (!response.ok) { + throw new Error(`Failed to load content for ${bookId}: ${response.status}`); + } + + const contentData = await response.json(); + + // Générer le rapport de contenu + const contentReport = this._generateContentReport(contentData); + + const processedContent = { + ...contentData, + _meta: { + loadedAt: new Date().toISOString(), + report: contentReport + } + }; + + // Mettre en cache + this._cache.set(bookId, processedContent); + this._contentReports.set(bookId, contentReport); + + console.log(`📊 Content loaded for ${bookId}:`, contentReport); + return processedContent; + + } catch (error) { + console.error(`❌ Error loading content for ${bookId}:`, error); + throw error; + } + } + + /** + * Génère un rapport détaillé du contenu + * @param {Object} contentData - Données JSON du contenu + * @returns {Object} - Rapport de contenu + */ + _generateContentReport(contentData) { + const report = { + totalWords: 0, + wordTypes: {}, + languages: new Set(), + difficulty: contentData.difficulty || 'unknown', + categories: {}, + statistics: {}, + metadata: contentData.metadata || {}, + contentStructure: contentData.content_structure || {}, + learningPaths: contentData.learning_paths || {}, + assessment: contentData.assessment || {} + }; + + // Analyser le vocabulaire + if (contentData.vocabulary) { + const vocabulary = contentData.vocabulary; + report.totalWords = Object.keys(vocabulary).length; + + // Analyser par type de mot + Object.entries(vocabulary).forEach(([word, data]) => { + const type = data.type || 'unknown'; + report.wordTypes[type] = (report.wordTypes[type] || 0) + 1; + + // Détecter les langues + if (data.user_language) { + // Détecter si c'est du chinois, français, etc. + if (/[\u4e00-\u9fff]/.test(data.user_language)) { + report.languages.add('Chinese'); + } else if (/[àâäçéèêëïîôùûüÿ]/.test(data.user_language)) { + report.languages.add('French'); + } else { + report.languages.add('English'); + } + } + + // Utiliser les sections structurées si disponibles + if (contentData.content_structure && contentData.content_structure.vocabulary_sections) { + const section = this._findWordSection(word, contentData.content_structure.vocabulary_sections); + if (section) { + report.categories[section.title] = (report.categories[section.title] || 0) + 1; + } + } else { + // Fallback sur l'ancien système de catégorisation + const category = this._categorizeWord(word, data); + report.categories[category] = (report.categories[category] || 0) + 1; + } + }); + } + + // Convertir Set en Array pour JSON + report.languages = Array.from(report.languages); + + // Statistiques finales avec les nouvelles métadonnées + report.statistics = { + mostCommonType: this._getMostCommon(report.wordTypes), + mostCommonCategory: this._getMostCommon(report.categories), + averageWordLength: this._calculateAverageWordLength(contentData.vocabulary), + complexityScore: this._calculateComplexityScore(report), + estimatedHours: contentData.metadata?.estimated_hours || 0, + totalSections: contentData.content_structure?.vocabulary_sections?.length || 0, + totalSentences: contentData.sentences?.length || 0, + learningPathsCount: Object.keys(contentData.learning_paths || {}).length + }; + + return report; + } + + /** + * Trouve la section d'un mot dans la structure de contenu + * @param {string} word - Le mot à chercher + * @param {Array} sections - Les sections de vocabulaire + * @returns {Object|null} - Section trouvée ou null + */ + _findWordSection(word, sections) { + for (const section of sections) { + if (section.words && section.words.includes(word)) { + return section; + } + } + return null; + } + + /** + * Catégorise un mot selon son contexte + * @param {string} word - Le mot à catégoriser + * @param {Object} data - Données du mot + * @returns {string} - Catégorie + */ + _categorizeWord(word, data) { + const lowerWord = word.toLowerCase(); + const translation = (data.user_language || '').toLowerCase(); + + // Catégories basées sur des mots-clés + if (lowerWord.includes('house') || lowerWord.includes('room') || lowerWord.includes('building') || + translation.includes('房') || translation.includes('室') || lowerWord.includes('home')) { + return 'Housing & Home'; + } + + if (lowerWord.includes('food') || lowerWord.includes('eat') || lowerWord.includes('cook') || + translation.includes('食') || translation.includes('饭') || lowerWord.includes('restaurant')) { + return 'Food & Dining'; + } + + if (lowerWord.includes('work') || lowerWord.includes('job') || lowerWord.includes('office') || + translation.includes('工作') || translation.includes('职')) { + return 'Work & Career'; + } + + if (lowerWord.includes('family') || lowerWord.includes('mother') || lowerWord.includes('father') || + translation.includes('家') || translation.includes('妈') || translation.includes('爸')) { + return 'Family & Relationships'; + } + + if (lowerWord.includes('time') || lowerWord.includes('day') || lowerWord.includes('year') || + translation.includes('时间') || translation.includes('天') || translation.includes('年')) { + return 'Time & Calendar'; + } + + if (data.type === 'adjective') return 'Descriptive Words'; + if (data.type === 'verb') return 'Actions & Verbs'; + if (data.type === 'noun') return 'Objects & Things'; + + return 'General Vocabulary'; + } + + /** + * Trouve l'élément le plus commun dans un objet + * @param {Object} obj - Objet avec des compteurs + * @returns {string} - Clé la plus fréquente + */ + _getMostCommon(obj) { + return Object.entries(obj).reduce((a, b) => obj[a] > obj[b] ? a : b, Object.keys(obj)[0]); + } + + /** + * Calcule la longueur moyenne des mots + * @param {Object} vocabulary - Vocabulaire + * @returns {number} - Longueur moyenne + */ + _calculateAverageWordLength(vocabulary) { + if (!vocabulary) return 0; + const words = Object.keys(vocabulary); + const totalLength = words.reduce((sum, word) => sum + word.length, 0); + return Math.round((totalLength / words.length) * 100) / 100; + } + + /** + * Calcule un score de complexité + * @param {Object} report - Rapport de contenu + * @returns {number} - Score de 1 à 10 + */ + _calculateComplexityScore(report) { + let score = 5; // Base + + // Plus de mots = plus complexe + if (report.totalWords > 100) score += 1; + if (report.totalWords > 200) score += 1; + + // Diversité des types de mots + const typeCount = Object.keys(report.wordTypes).length; + if (typeCount > 5) score += 1; + + // Longueur moyenne des mots + if (report.statistics?.averageWordLength > 7) score += 1; + + // Difficulté déclarée + if (report.difficulty === 'advanced') score += 2; + if (report.difficulty === 'intermediate') score += 1; + + return Math.min(10, Math.max(1, score)); + } + + /** + * Génère un tooltip HTML pour un chapitre + * @param {string} bookId - ID du livre + * @returns {string} - HTML du tooltip + */ + generateTooltipHTML(bookId) { + const report = this._contentReports.get(bookId); + if (!report) return '
Loading content info...
'; + + const topCategories = Object.entries(report.categories) + .sort(([,a], [,b]) => b - a) + .slice(0, 3) + .map(([cat, count]) => `${cat}: ${count} words`) + .join('
'); + + // Informations sur les métadonnées si disponibles + const metadata = report.metadata || {}; + const statistics = report.statistics || {}; + + const metadataSection = metadata.estimated_hours ? ` +
+
📋 Learning Info:
+
+ ⏱️ Est. Time: ${metadata.estimated_hours}h
+ 📖 Sections: ${statistics.totalSections || 0}
+ 💬 Sentences: ${statistics.totalSentences || 0}
+ 🎯 Paths: ${statistics.learningPathsCount || 0} +
+
+ ` : ''; + + const learningObjectives = metadata.learning_objectives ? ` +
+
🎯 Objectives:
+
+ ${metadata.learning_objectives.slice(0, 2).map(obj => `• ${obj}`).join('
')} + ${metadata.learning_objectives.length > 2 ? `
+${metadata.learning_objectives.length - 2} more...` : ''} +
+
+ ` : ''; + + return ` +
+
+ 📚 ${metadata.source || 'Content Overview'} + ${metadata.version ? `v${metadata.version}` : ''} +
+
+
+ 📊 Total Words: + ${report.totalWords} +
+
+ 🏷️ Word Types: + ${Object.keys(report.wordTypes).length} +
+
+ 🌐 Languages: + ${report.languages.join(', ')} +
+
+ ⭐ Complexity: + ${statistics.complexityScore}/10 +
+
+ 📝 Level: + ${metadata.target_level || report.difficulty} +
+
+ ${metadataSection} +
+
Top Categories:
+
${topCategories}
+
+ ${learningObjectives} + ${metadata.content_tags ? ` +
+ ${metadata.content_tags.map(tag => `#${tag}`).join(' ')} +
+ ` : ''} +
+ `; + } + + /** + * Récupère le rapport d'un contenu + * @param {string} bookId - ID du livre + * @returns {Object|null} - Rapport ou null + */ + getContentReport(bookId) { + return this._contentReports.get(bookId) || null; + } + + /** + * Obtient le contenu chargé pour un livre (depuis le cache) + * @param {string} bookId - ID du livre + * @returns {Object|null} - Contenu chargé ou null si pas en cache + */ + getContent(bookId) { + return this._cache.get(bookId) || null; + } + + /** + * Charge tous les livres disponibles + * @returns {Promise} - Liste des livres + */ + async loadBooks() { + if (this._booksLoaded) { + return Array.from(this._booksCache.values()); + } + + try { + // Pour l'instant, on va récupérer la liste des livres via le serveur + // Plus tard on pourra implémenter une découverte automatique + const booksToLoad = ['sbs']; // Liste des IDs de livres à charger + + for (const bookId of booksToLoad) { + try { + const response = await fetch(`/content/books/${bookId}.json`); + if (response.ok) { + const bookData = await response.json(); + this._booksCache.set(bookId, bookData); + } + } catch (error) { + console.warn(`Could not load book ${bookId}:`, error); + } + } + + this._booksLoaded = true; + console.log(`📚 Loaded ${this._booksCache.size} books into cache`); + return Array.from(this._booksCache.values()); + + } catch (error) { + console.error('Error loading books:', error); + return []; + } + } + + /** + * Obtient tous les livres (depuis le cache) + * @returns {Array} - Liste des livres ou tableau vide + */ + getBooks() { + return Array.from(this._booksCache.values()); + } + + /** + * Obtient un livre spécifique + * @param {string} bookId - ID du livre + * @returns {Object|null} - Données du livre ou null + */ + getBook(bookId) { + return this._booksCache.get(bookId) || null; + } + + /** + * Obtient les chapitres d'un livre + * @param {string} bookId - ID du livre + * @returns {Array} - Liste des chapitres + */ + getBookChapters(bookId) { + const book = this._booksCache.get(bookId); + return book ? book.chapters || [] : []; + } + + /** + * Vide le cache + */ + clearCache() { + this._cache.clear(); + this._contentReports.clear(); + this._booksCache.clear(); + this._booksLoaded = false; + } +} + +export default ContentLoader; \ No newline at end of file diff --git a/start-portable.bat b/start-portable.bat new file mode 100644 index 0000000..8935692 --- /dev/null +++ b/start-portable.bat @@ -0,0 +1,43 @@ +@echo off +title Class Generator - Development Server + +echo. +echo 🚀 Starting Class Generator Development Server... +echo. + +:: Check if Node.js is installed +node --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo ❌ Node.js is not installed or not in PATH + echo Please install Node.js from https://nodejs.org/ + pause + exit /b 1 +) + +:: Kill any existing servers first +echo 🧹 Cleaning up existing servers... +taskkill /f /im node.exe >nul 2>&1 +taskkill /F /IM caddy.exe >nul 2>&1 + +:: Start the server +echo ✅ Node.js found +echo 🔄 Starting server on port 8080... +echo 📡 Server will be available at: http://localhost:8080 +echo 🌐 ES6 modules support: ✅ +echo 🔗 CORS enabled: ✅ +echo 🔌 API endpoints: ✅ +echo ⏳ Waiting for system initialization... +echo. + +timeout /t 3 /nobreak >nul + +:: Start browser +start msedge "http://localhost:8080" + +:: Start Node.js server +node server.js + +:: If we get here, the server stopped +echo. +echo 👋 Server stopped +pause \ No newline at end of file diff --git a/start.bat b/start.bat index 996a61d..3a30319 100644 --- a/start.bat +++ b/start.bat @@ -14,11 +14,17 @@ if %errorlevel% neq 0 ( exit /b 1 ) +:: Kill any existing servers first +echo 🧹 Cleaning up existing servers... +taskkill /f /im node.exe >nul 2>&1 + :: Start the server echo ✅ Node.js found -echo 🔄 Starting server... +echo 🔄 Starting server on port 8080... +echo ⏳ Waiting for system initialization... echo. +timeout /t 3 /nobreak >nul node server.js :: If we get here, the server stopped diff --git a/test-api.bat b/test-api.bat new file mode 100644 index 0000000..92336a0 --- /dev/null +++ b/test-api.bat @@ -0,0 +1,31 @@ +@echo off +echo 🧪 Testing API endpoints... +echo. + +echo 📚 Testing /api/books: +curl -s http://localhost:8080/api/books | findstr "id" +if %errorlevel% equ 0 ( + echo ✅ API /api/books works! +) else ( + echo ❌ API /api/books failed! +) + +echo. +echo 📄 Testing JSON file access: +curl -s http://localhost:8080/src/chapters/sbs.json | findstr "name" +if %errorlevel% equ 0 ( + echo ✅ JSON file access works! +) else ( + echo ❌ JSON file access failed! +) + +echo. +echo 🏠 Testing homepage: +curl -s http://localhost:8080/ | findstr "currentBookId" +if %errorlevel% equ 0 ( + echo ✅ Dynamic frontend loaded! +) else ( + echo ❌ Frontend issue! +) + +pause \ No newline at end of file diff --git a/test-settings.html b/test-settings.html new file mode 100644 index 0000000..7c96ec1 --- /dev/null +++ b/test-settings.html @@ -0,0 +1,341 @@ + + + + + + Settings/Debug Test - Class Generator 2.0 + + + +
+

🔧 Settings/Debug Test Page

+

Class Generator 2.0 - Ultra-Modular System

+
+ +
+

🎛️ Test Controls

+ +
+ + + +
+ +
+ + + +
+
+ +
+

📊 System Status

+
+ Application: + Loading... +
+
+ Settings Module: + Not Loaded +
+
+ Router: + Not Ready +
+
+ Current Route: + None +
+
+ + +
+ + + + + + + \ No newline at end of file diff --git a/tests.html b/tests.html new file mode 100644 index 0000000..48e2c00 --- /dev/null +++ b/tests.html @@ -0,0 +1,652 @@ + + + + + + 🧪 Class Generator 2.0 - Test Suite + + + + +
+
+

🧪 Class Generator 2.0

+

Comprehensive Test Suite - Architecture & Modules Validation

+
+ +
+

Test Execution

+
+ + + + +
+ + + +
+

📋 What This Tests

+
    +
  • Core Architecture: Module.js, EventBus.js, ModuleLoader.js validation
  • +
  • DRS Modules: TextModule, AudioModule, ImageModule, GrammarModule functionality
  • +
  • Integration: Module contracts, dependency injection, event communication
  • +
  • Performance: Loading times, memory management, execution efficiency
  • +
  • Quality: Code architecture adherence and best practices
  • +
+
+
+ +
+ +
+ +
+
+

🏗️ Core Architecture

+

Module system integrity and event communication validation

+
Not Tested
+
+
+

🎮 DRS Modules

+

Exercise modules functionality and interface compliance

+
Not Tested
+
+
+

⚡ Performance

+

Execution speed and memory efficiency metrics

+
Not Tested
+
+
+

🔄 Integration

+

Cross-module communication and dependency management

+
Not Tested
+
+
+ +
+

Class Generator 2.0 - Ultra-Modular Educational Platform

+

Built with Vanilla JavaScript, ES6 Modules, and Strict Architecture Patterns

+
+
+ + + + \ No newline at end of file