- 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>
1317 lines
42 KiB
JavaScript
1317 lines
42 KiB
JavaScript
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; |