Class_generator/src/games/WhackAMoleHard.js
StillHammer 4714a4a1c6 Add TTS support and improve content compatibility system
Major improvements:
- Add TTSHelper utility for text-to-speech functionality
- Enhance content compatibility scoring across all games
- Improve sentence extraction from multiple content sources
- Update all game modules to support diverse content formats
- Refine MarioEducational physics and rendering
- Polish UI styles and remove unused CSS

Games updated: AdventureReader, FillTheBlank, FlashcardLearning,
GrammarDiscovery, MarioEducational, QuizGame, RiverRun, WhackAMole,
WhackAMoleHard, WizardSpellCaster, WordDiscovery, WordStorm

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 02:49:48 +08:00

1559 lines
50 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';
/**
* 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 = `
<div class="whack-game-wrapper">
<!-- Mode Selection -->
<div class="mode-selector">
<button class="mode-btn active" data-mode="translation">
🔤 Translation
</button>
<button class="mode-btn" data-mode="image">
🖼️ Image (soon)
</button>
<button class="mode-btn" data-mode="sound">
🔊 Sound (soon)
</button>
</div>
<!-- Game Info -->
<div class="game-info">
<div class="game-stats">
<div class="stat-item">
<span class="stat-value" id="time-left">${this._timeLeft}</span>
<span class="stat-label">Time</span>
</div>
<div class="stat-item">
<span class="stat-value" id="errors-count">${this._errors}</span>
<span class="stat-label">Errors</span>
</div>
<div class="stat-item">
<span class="stat-value" id="target-word">---</span>
<span class="stat-label">Find</span>
</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>
</div>
</div>
<!-- Game Board -->
<div class="whack-game-board hard-mode" id="game-board">
<!-- 15 holes will be generated here (5x3 grid) -->
</div>
<!-- Feedback Area -->
<div class="feedback-area" id="feedback-area">
<div class="instruction">
Select a mode and click Start!
</div>
</div>
</div>
`;
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 = `
<div class="whack-mole" data-hole="${i}">
<div class="pronunciation" style="display: none;"></div>
<div class="word"></div>
</div>
`;
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 = `<div class="instruction ${type}">${message}</div>`;
}
_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 = `
<div class="game-error">
<h3>❌ Loading Error</h3>
<p>This content does not contain vocabulary compatible with Whack-a-Mole Hard.</p>
<p>The game requires words with their translations.</p>
<button onclick="window.app?.getCore().router.navigate('/games')" class="back-btn">← Back to Games</button>
</div>
`;
}
_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<SpeechSynthesisVoice[]>} 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 = `
<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-row">
<span class="stat-name">${key}</span>
<span class="stat-value">${value}</span>
</div>
`).join('')}
</div>
<div class="victory-buttons">
<button class="victory-btn primary" onclick="this.closest('.victory-popup').remove(); window.location.reload();">🔄 Play Again</button>
<button class="victory-btn secondary" onclick="this.closest('.victory-popup').remove(); window.app.getCore().router.navigate('/games'); setTimeout(() => { const chapterId = window.currentChapterId || 'sbs'; window.app.getCore().eventBus.emit('navigation:games', { path: \`/games/\${chapterId}\`, data: { path: \`/games/\${chapterId}\` } }, 'Application'); }, 100);">🎮 Different Game</button>
<button class="victory-btn tertiary" onclick="this.closest('.victory-popup').remove(); window.app.getCore().router.navigate('/');">🏠 Main Menu</button>
</div>
</div>
`;
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;