Class_generator/Legacy/js/games/whack-a-mole.js
StillHammer 38920cc858 Complete architectural rewrite with ultra-modular system
Major Changes:
- Moved legacy system to Legacy/ folder for archival
- Built new modular architecture with strict separation of concerns
- Created core system: Module, EventBus, ModuleLoader, Router
- Added Application bootstrap with auto-start functionality
- Implemented development server with ES6 modules support
- Created comprehensive documentation and project context
- Converted SBS-7-8 content to JSON format
- Copied all legacy games and content to new structure

New Architecture Features:
- Sealed modules with WeakMap private data
- Strict dependency injection system
- Event-driven communication only
- Inviolable responsibility patterns
- Auto-initialization without commands
- Component-based UI foundation ready

Technical Stack:
- Vanilla JS/HTML/CSS only
- ES6 modules with proper imports/exports
- HTTP development server (no file:// protocol)
- Modular CSS with component scoping
- Comprehensive error handling and debugging

Ready for Phase 2: Converting legacy modules to new architecture

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-22 07:08:39 +08:00

685 lines
24 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.

// === MODULE WHACK-A-MOLE ===
class WhackAMoleGame {
constructor(options) {
this.container = options.container;
this.content = options.content;
this.onScoreUpdate = options.onScoreUpdate || (() => {});
this.onGameEnd = options.onGameEnd || (() => {});
// Game state
this.score = 0;
this.errors = 0;
this.maxErrors = 3;
this.gameTime = 60; // 60 secondes
this.timeLeft = this.gameTime;
this.isRunning = false;
this.gameMode = 'translation'; // 'translation', 'image', 'sound'
this.showPronunciation = false; // Track pronunciation display state
// Mole configuration
this.holes = [];
this.activeMoles = [];
this.moleAppearTime = 2000; // 2 seconds display time
this.spawnRate = 1500; // New mole every 1.5 seconds
// Timers
this.gameTimer = null;
this.spawnTimer = null;
// Vocabulary for this game - adapted for the new system
this.vocabulary = this.extractVocabulary(this.content);
this.currentWords = [];
this.targetWord = null;
// Target word guarantee system
this.spawnsSinceTarget = 0;
this.maxSpawnsWithoutTarget = 3; // Target word must appear in the next 3 moles
this.init();
}
init() {
// Check that we have vocabulary
if (!this.vocabulary || this.vocabulary.length === 0) {
logSh('No vocabulary available for Whack-a-Mole', 'ERROR');
this.showInitError();
return;
}
this.createGameBoard();
this.createGameUI();
this.setupEventListeners();
}
showInitError() {
this.container.innerHTML = `
<div class="game-error">
<h3>❌ Loading Error</h3>
<p>This content does not contain vocabulary compatible with Whack-a-Mole.</p>
<p>The game requires words with their translations.</p>
<button onclick="AppNavigation.navigateTo('games')" class="back-btn">← Back</button>
</div>
`;
}
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" id="game-board">
<!-- Holes will be generated here -->
</div>
<!-- Feedback Area -->
<div class="feedback-area" id="feedback-area">
<div class="instruction">
Select a mode and click Start!
</div>
</div>
</div>
`;
this.createHoles();
}
createHoles() {
const gameBoard = document.getElementById('game-board');
gameBoard.innerHTML = '';
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; font-size: 0.8em; color: #2563eb; font-style: italic; margin-bottom: 5px; font-weight: 500;"></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
});
}
}
createGameUI() {
// UI elements are already created in createGameBoard
}
setupEventListeners() {
// Mode selection
document.querySelectorAll('.mode-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
if (this.isRunning) return;
document.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
document.querySelector('.mode-btn[data-mode="translation"]').classList.add('active');
btn.classList.remove('active');
this.gameMode = 'translation';
}
});
});
// Game controls
document.getElementById('pronunciation-btn').addEventListener('click', () => this.togglePronunciation());
document.getElementById('start-btn').addEventListener('click', () => this.start());
document.getElementById('pause-btn').addEventListener('click', () => this.pause());
document.getElementById('restart-btn').addEventListener('click', () => this.restart());
// Mole clicks
this.holes.forEach((hole, index) => {
hole.mole.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();
document.getElementById('start-btn').disabled = true;
document.getElementById('pause-btn').disabled = false;
this.showFeedback(`Find the word: "${this.targetWord.translation}"`, 'info');
// Show loaded content info
const contentName = this.content.name || 'Content';
logSh(`🎮 Whack-a-Mole started with: ${contentName} (${this.vocabulary.length} words, 'INFO');`);
}
pause() {
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');
}
restart() {
this.stopWithoutEnd(); // Stop without triggering game end
this.resetGame();
setTimeout(() => this.start(), 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');
}
// Update currently visible moles
this.updateMoleDisplay();
}
updateMoleDisplay() {
// Update pronunciation display for all active moles
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';
}
}
});
}
stop() {
this.stopWithoutEnd();
this.onGameEnd(this.score); // Trigger game end only here
}
stopWithoutEnd() {
this.isRunning = false;
this.stopTimers();
this.hideAllMoles();
document.getElementById('start-btn').disabled = false;
document.getElementById('pause-btn').disabled = true;
}
resetGame() {
// Ensure everything is completely stopped
this.stopWithoutEnd();
// Reset all state variables
this.score = 0;
this.errors = 0;
this.timeLeft = this.gameTime;
this.isRunning = false;
this.targetWord = null;
this.activeMoles = [];
this.spawnsSinceTarget = 0; // Reset guarantee counter
// Ensure all timers are properly stopped
this.stopTimers();
// Reset UI
this.updateUI();
this.onScoreUpdate(0);
// Clear feedback
document.getElementById('target-word').textContent = '---';
this.showFeedback('Select a mode and click Start!', 'info');
// Reset buttons
document.getElementById('start-btn').disabled = false;
document.getElementById('pause-btn').disabled = true;
// Clear all holes with verification
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');
}
});
logSh('🔄 Game completely reset', 'INFO');
}
startTimers() {
// Main game timer
this.gameTimer = setInterval(() => {
this.timeLeft--;
this.updateUI();
if (this.timeLeft <= 0 && this.isRunning) {
this.stop();
}
}, 1000);
// Mole spawn timer
this.spawnTimer = setInterval(() => {
if (this.isRunning) {
this.spawnMole();
}
}, this.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() {
// Increment spawn counter since last target word
this.spawnsSinceTarget++;
// If we've reached the limit, force the target word
if (this.spawnsSinceTarget >= this.maxSpawnsWithoutTarget) {
logSh(`🎯 Forced target word spawn after ${this.spawnsSinceTarget} attempts`, 'INFO');
this.spawnsSinceTarget = 0;
return this.targetWord;
}
// Otherwise, 50% chance for target word, 50% random word
if (Math.random() < 0.5) {
logSh('🎯 Natural target word spawn', 'INFO');
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');
// Add to active moles list
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;
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);
} 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.updateUI();
this.onScoreUpdate(this.score);
// Check game end by errors
if (this.errors >= this.maxErrors) {
this.showFeedback('Too many errors! Game over.', 'error');
setTimeout(() => {
if (this.isRunning) { // Check if game is still running
this.stop();
}
}, 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;
logSh(`🎯 New target word: ${this.targetWord.original} -> ${this.targetWord.translation}`, 'INFO');
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;
}
extractVocabulary(content) {
let vocabulary = [];
logSh('🔍 Extracting vocabulary from:', content?.name || 'content', 'INFO');
// Priority 1: Use raw module content (simple format)
if (content.rawContent) {
logSh('📦 Using raw module content', 'INFO');
return this.extractVocabularyFromRaw(content.rawContent);
}
// Priority 2: Ultra-modular format (vocabulary object) - ONLY format supported
if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) {
logSh('✨ Ultra-modular format detected (vocabulary object)', 'INFO');
vocabulary = Object.entries(content.vocabulary).map(([word, data]) => {
// Support ultra-modular format and new centralized vocabulary format
if (typeof data === 'object' && (data.user_language || data.translation)) {
const translationText = data.user_language || data.translation;
return {
original: word, // Clé = original_language
translation: translationText.split('')[0], // First translation
fullTranslation: translationText, // Complete translation
type: data.type || 'general',
audio: data.audio,
image: data.image,
examples: data.examples,
pronunciation: data.pronunciation,
category: data.type || 'general'
};
}
// Legacy fallback - simple string (temporary, will be removed)
else if (typeof data === 'string') {
return {
original: word,
translation: data.split('')[0],
fullTranslation: data,
type: 'general',
category: 'general'
};
}
return null;
}).filter(Boolean);
}
// No other formats supported - ultra-modular only
return this.finalizeVocabulary(vocabulary);
}
extractVocabularyFromRaw(rawContent) {
logSh('🔧 Extracting from raw content:', rawContent.name || 'Module', 'INFO');
let vocabulary = [];
// Ultra-modular format (vocabulary object) - ONLY format supported
if (rawContent.vocabulary && typeof rawContent.vocabulary === 'object' && !Array.isArray(rawContent.vocabulary)) {
vocabulary = Object.entries(rawContent.vocabulary).map(([word, data]) => {
// Support ultra-modular format and new centralized vocabulary format
if (typeof data === 'object' && (data.user_language || data.translation)) {
const translationText = data.user_language || data.translation;
return {
original: word, // Clé = original_language
translation: translationText.split('')[0], // First translation
fullTranslation: translationText, // Complete translation
type: data.type || 'general',
audio: data.audio,
image: data.image,
examples: data.examples,
pronunciation: data.pronunciation,
category: data.type || 'general'
};
}
// Legacy fallback - simple string (temporary, will be removed)
else if (typeof data === 'string') {
return {
original: word,
translation: data.split('')[0],
fullTranslation: data,
type: 'general',
category: 'general'
};
}
return null;
}).filter(Boolean);
logSh(`${vocabulary.length} words extracted from ultra-modular vocabulary`, 'INFO');
}
// No other formats supported - ultra-modular only
else {
logSh('⚠️ Content format not supported - ultra-modular format required', 'WARN');
}
return this.finalizeVocabulary(vocabulary);
}
finalizeVocabulary(vocabulary) {
// Validation and cleanup for ultra-modular format
vocabulary = vocabulary.filter(word =>
word &&
typeof word.original === 'string' &&
typeof word.translation === 'string' &&
word.original.trim() !== '' &&
word.translation.trim() !== ''
);
if (vocabulary.length === 0) {
logSh('❌ No valid vocabulary found', 'ERROR');
// Demo vocabulary as last resort
vocabulary = [
{ 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' }
];
logSh('🚨 Using demo vocabulary', 'WARN');
}
logSh(`✅ Whack-a-Mole: ${vocabulary.length} vocabulary words finalized`, 'INFO');
return this.shuffleArray(vocabulary);
}
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;
}
destroy() {
this.stop();
this.container.innerHTML = '';
}
}
// Module registration
window.GameModules = window.GameModules || {};
window.GameModules.WhackAMole = WhackAMoleGame;