Class_generator/js/games/whack-a-mole-hard.js
StillHammer 30a2028da6 Remove Text Reader game and enhance Story Reader
- Enhanced Story Reader with text-to-story conversion methods
- Added support for simple texts and sentences in Story Reader
- Removed Text Reader game file (js/games/text-reader.js)
- Updated all configuration files to remove Text Reader references
- Modified game compatibility system to use Story Reader instead
- Updated test fixtures to reflect game changes
- Cleaned up debug/test HTML files

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-20 11:22:56 +08:00

703 lines
25 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 HARD ===
class WhackAMoleHardGame {
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 seconds
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 = 3000; // 3 seconds display time (longer)
this.spawnRate = 2000; // New wave every 2 seconds
this.molesPerWave = 3; // 3 moles per wave
// 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 = 10; // Target word must appear in the next 10 moles (1/10 chance)
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 hard-mode" id="game-board">
<!-- Holes will be generated here (5x3 = 15 holes) -->
</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 < 15; i++) { // 5x3 = 15 holes
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() {
// Hard mode: Spawn 3 moles at once
this.spawnMultipleMoles();
}
spawnMultipleMoles() {
// Find all free holes
const availableHoles = this.holes.filter(hole => !hole.isActive);
// Spawn up to 3 moles (or fewer if not enough free holes)
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) {
logSh(`🎯 Forced target word spawn after ${this.spawnsSinceTarget} attempts`, 'INFO');
this.spawnsSinceTarget = 0;
return this.targetWord;
}
// Otherwise, 10% chance for target word (1/10 instead of 1/2)
if (Math.random() < 0.1) {
logSh('🎯 Natural target word spawn (1/10)', '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 ONLY
if (typeof data === 'object' && data.user_language) {
return {
original: word, // Clé = original_language
translation: data.user_language.split('')[0], // First translation
fullTranslation: data.user_language, // 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 ONLY
if (typeof data === 'object' && data.user_language) {
return {
original: word, // Clé = original_language
translation: data.user_language.split('')[0], // First translation
fullTranslation: data.user_language, // 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.WhackAMoleHard = WhackAMoleHardGame;