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 = `
Your Score
${currentScore}
${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;