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 || 'whack-a-mole-hard', ['eventBus']); // Validate dependencies if (!dependencies.eventBus) { throw new Error('WhackAMoleHard requires EventBus dependency'); } // Ensure name is always defined if (!this.name) { this.name = 'whack-a-mole-hard'; } 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
Select a mode and click Start!
`; 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 - click on hole OR mole text this._holes.forEach((hole, index) => { // Click on the entire hole (easier to target) hole.element.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' }, this.name); } _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 }, this.name); } _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; // Speak the word (pronounce it) this._speakWord(hole.word.original); 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 }, 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'); this._eventBus.emit('whack-hard:wrong-hit', { expected: this._targetWord, actual: hole.word, errors: this._errors }, this.name); } 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 }, this.name); } _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' } ]; } async _speakWord(word) { // Use Web Speech API to pronounce the word if ('speechSynthesis' in window) { // Cancel any ongoing speech speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(word); const targetLanguage = this._content?.language || 'en-US'; utterance.lang = targetLanguage; utterance.rate = 0.9; // Slightly slower for clarity utterance.pitch = 1.0; utterance.volume = 1.0; // Try to use a good voice for the target language const voices = await this._getVoices(); const langPrefix = targetLanguage.split('-')[0]; const preferredVoice = voices.find(voice => voice.lang.startsWith(langPrefix) && (voice.name.includes('Google') || voice.name.includes('Neural') || voice.default) ) || voices.find(voice => voice.lang.startsWith(langPrefix)); if (preferredVoice) { utterance.voice = preferredVoice; console.log(`🔊 Using voice: ${preferredVoice.name} (${preferredVoice.lang})`); } else { console.warn(`🔊 No voice found for: ${targetLanguage}, available:`, voices.map(v => v.lang)); } speechSynthesis.speak(utterance); } } /** * Get available speech synthesis voices, waiting for them to load if necessary * @returns {Promise} Array of available voices * @private */ _getVoices() { return new Promise((resolve) => { let voices = window.speechSynthesis.getVoices(); // If voices are already loaded, return them immediately if (voices.length > 0) { resolve(voices); return; } // Otherwise, wait for voiceschanged event const voicesChangedHandler = () => { voices = window.speechSynthesis.getVoices(); if (voices.length > 0) { window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler); resolve(voices); } }; window.speechSynthesis.addEventListener('voiceschanged', voicesChangedHandler); // Fallback timeout in case voices never load setTimeout(() => { window.speechSynthesis.removeEventListener('voiceschanged', voicesChangedHandler); resolve(window.speechSynthesis.getVoices()); }, 1000); }); } _shuffleArray(array) { const shuffled = [...array]; for (let i = shuffled.length - 1; i > 0; i--) { 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 }, this.name); } // 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;