import Module from '../core/Module.js'; import ttsService from '../services/TTSService.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: 6px; width: 100%; max-width: min(650px, 95vw); margin: 0 auto; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 8px; color: white; height: 75vh; max-height: 75vh; overflow: hidden; display: flex; flex-direction: column; box-sizing: border-box; } .whack-game-header { display: flex; justify-content: center; align-items: center; flex-wrap: wrap; gap: 4px; margin-bottom: 4px; padding: 4px; background: rgba(255, 255, 255, 0.1); border-radius: 6px; backdrop-filter: blur(10px); flex-shrink: 0; } .game-stats { display: flex; gap: 3px; align-items: center; flex-wrap: wrap; justify-content: center; } .stat-item { text-align: center; background: rgba(255, 255, 255, 0.1); padding: 2px 6px; border-radius: 4px; min-width: 45px; flex: 0 1 auto; } .stat-value { display: block; font-size: clamp(0.75rem, 1.3vh, 0.85rem); font-weight: bold; margin-bottom: 1px; line-height: 1.1; } .stat-label { font-size: clamp(0.5rem, 0.9vh, 0.6rem); opacity: 0.9; line-height: 1.1; } .target-display { background: rgba(255, 255, 255, 0.2); padding: 3px 8px; border-radius: 6px; text-align: center; border: 2px solid rgba(255, 255, 255, 0.3); min-width: 90px; max-width: 140px; flex-shrink: 1; } .target-label { font-size: clamp(0.5rem, 0.9vh, 0.6rem); opacity: 0.9; margin-bottom: 1px; line-height: 1.1; } .target-word { font-size: clamp(0.7rem, 1.2vh, 0.85rem); font-weight: bold; word-break: break-word; line-height: 1.2; } .game-controls { display: flex; gap: 3px; align-items: center; flex-wrap: wrap; justify-content: center; } .control-btn { padding: 3px 6px; border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; background: rgba(255, 255, 255, 0.1); color: white; font-size: clamp(0.55rem, 1vh, 0.65rem); font-weight: 500; cursor: pointer; transition: all 0.3s ease; backdrop-filter: blur(5px); white-space: nowrap; min-width: fit-content; flex-shrink: 1; line-height: 1.2; } .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: 6px; margin: 0; padding: 6px; background: rgba(0, 0, 0, 0.2); border-radius: 8px; width: 100%; max-width: 300px; margin: 0 auto; box-sizing: border-box; align-self: center; } .whack-hole { position: relative; aspect-ratio: 1; background: radial-gradient(circle at center, #8b5cf6 0%, #7c3aed 100%); border-radius: 50%; border: 2px solid rgba(255, 255, 255, 0.3); overflow: hidden; cursor: pointer; transition: all 0.3s ease; width: 100%; height: auto; } .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: 5px; padding: 3px 5px; color: white; text-align: center; font-weight: 600; font-size: clamp(0.55rem, 1.1vh, 0.7rem); line-height: 1.15; transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); cursor: pointer; max-width: 90%; word-wrap: break-word; overflow: hidden; } .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 4px 12px rgba(0, 0, 0, 0.4); } .pronunciation { font-size: clamp(0.48rem, 0.9vh, 0.6rem); color: rgba(255, 255, 255, 0.8); font-style: italic; margin-bottom: 1px; font-weight: 400; line-height: 1.1; } .feedback-area { text-align: center; padding: 4px; background: rgba(255, 255, 255, 0.1); border-radius: 6px; margin-top: 4px; backdrop-filter: blur(10px); flex-shrink: 0; } .instruction { font-size: clamp(0.65rem, 1.1vh, 0.75rem); font-weight: 500; padding: 4px; border-radius: 4px; transition: all 0.3s ease; line-height: 1.2; } .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.2rem; 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: 10px; z-index: 1000; } .game-over-content { background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); color: white; padding: 20px; border-radius: 10px; text-align: center; max-width: 350px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); } .game-over-content h2 { margin: 0 0 15px 0; font-size: 1.8rem; } .final-stats { margin: 15px 0; padding: 15px; background: rgba(255, 255, 255, 0.1); border-radius: 8px; backdrop-filter: blur(10px); } .stat-row { display: flex; justify-content: space-between; margin: 8px 0; font-size: 0.9rem; } .game-over-btn { margin: 8px; padding: 8px 16px; border: 2px solid white; border-radius: 6px; background: transparent; color: white; font-size: 0.85rem; 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: 20px; background: linear-gradient(135deg, #f87171 0%, #ef4444 100%); color: white; border-radius: 10px; height: 90vh; display: flex; flex-direction: column; align-items: center; justify-content: center; } .game-error h3 { font-size: 1.5rem; margin-bottom: 15px; } .back-btn { padding: 8px 16px; background: white; color: #ef4444; border: none; border-radius: 6px; font-size: 0.85rem; font-weight: 600; cursor: pointer; transition: all 0.3s ease; margin-top: 15px; } .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; } } /* Ensure Exit button uses control-btn styles */ #exit-whack { padding: 3px 6px !important; border: 1px solid rgba(255, 255, 255, 0.3) !important; border-radius: 4px; background: rgba(255, 255, 255, 0.1) !important; color: white !important; font-size: clamp(0.55rem, 1vh, 0.65rem) !important; font-weight: 500; cursor: pointer; transition: all 0.3s ease; backdrop-filter: blur(5px); white-space: nowrap; min-width: fit-content; flex-shrink: 1; line-height: 1.2; } #exit-whack:hover { background: rgba(255, 255, 255, 0.2) !important; transform: translateY(-2px); } #exit-whack .btn-icon, #exit-whack .btn-text { font-size: clamp(0.55rem, 1vh, 0.65rem); } /* Media queries pour ajustements mineurs sur très petits écrans */ @media (max-width: 400px) { .whack-game-wrapper { border-radius: 6px; } .stat-item { min-width: 45px; } .target-display { min-width: 90px; max-width: 120px; } } /* Ajustements pour écrans très larges */ @media (min-width: 1200px) { .whack-game-wrapper { max-width: 650px; } } `; 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 = `
${message}
This game requires vocabulary with translations.