Class_generator/src/games/WhackAMole.js
StillHammer 3a024e6fab Add Hanyu Jiaocheng content and improve game UX
- Add Hanyu Jiaocheng (Chinese course) book and chapters (3 & 4)
- Update TODO with completed game improvements
- Remove legacy TODO file
- Improve game modules with visual enhancements and bug fixes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 11:43:56 +08:00

1317 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = `
<div class="whack-game-wrapper">
<!-- Game Header -->
<div class="whack-game-header">
<div class="game-stats">
<div class="stat-item">
<span class="stat-value" id="time-left">${this._config.gameTime}</span>
<span class="stat-label">Time</span>
</div>
<div class="stat-item">
<span class="stat-value" id="errors-count">0</span>
<span class="stat-label">Errors</span>
</div>
<div class="stat-item">
<span class="stat-value" id="score-display">0</span>
<span class="stat-label">Score</span>
</div>
</div>
<div class="target-display">
<div class="target-label">Find the word:</div>
<div class="target-word" id="target-word">---</div>
</div>
<div class="game-controls">
<button class="control-btn" id="pronunciation-btn" title="Toggle pronunciation">
🔊 Pronunciation
</button>
<button class="control-btn" id="start-btn">🎮 Start</button>
<button class="control-btn" id="pause-btn" disabled>⏸️ Pause</button>
<button class="control-btn" id="restart-btn">🔄 Restart</button>
<button class="btn btn-outline btn-sm" id="exit-whack">
<span class="btn-icon">←</span>
<span class="btn-text">Exit</span>
</button>
</div>
</div>
<!-- Game Board -->
<div class="whack-game-board" id="game-board">
<!-- Holes will be generated here -->
</div>
<!-- Feedback Area -->
<div class="feedback-area" id="feedback-area">
<div class="instruction">
Click Start to begin the game!
</div>
</div>
</div>
`;
}
_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 = `
<div class="whack-mole" data-hole="${i}">
<div class="pronunciation" style="display: none;"></div>
<div class="word"></div>
</div>
`;
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 - 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));
});
}
_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;
// 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);
// 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 = `<div class="instruction ${type}">${message}</div>`;
}
_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 = `
<div class="game-error">
<h3>❌ Whack A Mole Error</h3>
<p>${message}</p>
<p>This game requires vocabulary with translations.</p>
<button class="back-btn" onclick="history.back()">← Go Back</button>
</div>
`;
}
}
async _speakWord(word) {
const targetLanguage = this._content?.language || 'en-US';
await ttsService.speak(word, targetLanguage, { rate: 0.9, volume: 1.0 });
}
_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 = `
<div class="victory-content">
<div class="victory-header">
<div class="victory-icon">🎯</div>
<h2 class="victory-title">${gameTitle} Complete!</h2>
${isNewBest ? '<div class="new-best-badge">🎉 New Best Score!</div>' : ''}
</div>
<div class="victory-scores">
<div class="score-display">
<div class="score-label">Your Score</div>
<div class="score-value">${currentScore}</div>
</div>
<div class="score-display best-score">
<div class="score-label">Best Score</div>
<div class="score-value">${bestScore}</div>
</div>
</div>
<div class="victory-stats">
${Object.entries(stats).map(([key, value]) => `
<div class="stat-item">
<div class="stat-label">${key}</div>
<div class="stat-value">${value}</div>
</div>
`).join('')}
</div>
<div class="victory-actions">
<button class="victory-btn victory-btn-primary" id="play-again-btn">
<span class="btn-icon">🔄</span>
<span class="btn-text">Play Again</span>
</button>
<button class="victory-btn victory-btn-secondary" id="different-game-btn">
<span class="btn-icon">🎮</span>
<span class="btn-text">Different Game</span>
</button>
<button class="victory-btn victory-btn-outline" id="main-menu-btn">
<span class="btn-icon">🏠</span>
<span class="btn-text">Main Menu</span>
</button>
</div>
</div>
`;
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;