Class_generator/js/games/word-storm.js
StillHammer 24362165ab Implement dynamic percentage compatibility system across all games
Major architectural update to replace fixed 50%/100% scoring with true dynamic percentages based on content volume:

• Replace old interpolation system with Math.min(100, (count/optimal)*100) formula
• Add embedded compatibility methods to all 14 game modules with static requirements
• Remove compatibility cache system for real-time calculation
• Fix content loading to pass complete modules with vocabulary (not just metadata)
• Clean up duplicate syntax errors in adventure-reader and grammar-discovery
• Update navigation.js module mapping to match actual exported class names

Examples of new dynamic scoring:
- 15 words / 20 optimal = 75% (was 87.5% with old interpolation)
- 5 words / 10 minimum = 50% (was 25% with old linear system)
- 30 words / 20 optimal = 100% (unchanged)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-20 17:00:52 +08:00

799 lines
28 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.

// === WORD STORM GAME ===
// Game where words fall from the sky like meteorites!
class WordStormGame {
constructor(options) {
logSh('Word Storm constructor called', 'DEBUG');
this.container = options.container;
this.content = options.content;
this.onScoreUpdate = options.onScoreUpdate || (() => {});
this.onGameEnd = options.onGameEnd || (() => {});
// Inject game-specific CSS
this.injectCSS();
logSh('Options processed, initializing game state...', 'DEBUG');
// Game state
this.score = 0;
this.level = 1;
this.lives = 3;
this.combo = 0;
this.isGamePaused = false;
this.isGameOver = false;
// Game mechanics
this.fallingWords = [];
this.gameInterval = null;
this.spawnInterval = null;
this.currentWordIndex = 0;
// Game settings
this.fallSpeed = 8000; // ms to fall from top to bottom (very slow)
this.spawnRate = 4000; // ms between spawns (not frequent)
this.wordLifetime = 15000; // ms before word disappears (long time)
logSh('Game state initialized, extracting vocabulary...', 'DEBUG');
// Content extraction
try {
this.vocabulary = this.extractVocabulary(this.content);
this.shuffledVocab = [...this.vocabulary];
this.shuffleArray(this.shuffledVocab);
logSh(`Word Storm initialized with ${this.vocabulary.length} words`, 'INFO');
} catch (error) {
logSh(`Error extracting vocabulary: ${error.message}`, 'ERROR');
throw error;
}
logSh('Calling init()...', 'DEBUG');
this.init();
}
injectCSS() {
// Avoid injecting CSS multiple times
if (document.getElementById('word-storm-styles')) return;
const styleSheet = document.createElement('style');
styleSheet.id = 'word-storm-styles';
styleSheet.textContent = `
.falling-word {
position: absolute;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px 30px;
border-radius: 25px;
font-size: 2rem;
font-weight: 600;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3), 0 0 20px rgba(102, 126, 234, 0.4);
cursor: default;
user-select: none;
transform: translateX(-50%);
animation: wordGlow 2s ease-in-out infinite;
}
.falling-word.exploding {
animation: explode 0.8s ease-out forwards;
}
.falling-word.wrong-shake {
animation: wrongShake 0.6s ease-in-out forwards;
}
.answer-panel.wrong-flash {
animation: wrongFlash 0.5s ease-in-out;
}
@keyframes wordGlow {
0%, 100% { box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3), 0 0 20px rgba(102, 126, 234, 0.4); }
50% { box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3), 0 0 30px rgba(102, 126, 234, 0.6); }
}
@keyframes explode {
0% {
transform: translateX(-50%) scale(1) rotate(0deg);
opacity: 1;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3), 0 0 20px rgba(102, 126, 234, 0.4);
}
25% {
transform: translateX(-50%) scale(1.3) rotate(5deg);
opacity: 0.9;
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
box-shadow: 0 8px 25px rgba(16, 185, 129, 0.5), 0 0 40px rgba(16, 185, 129, 0.8);
}
50% {
transform: translateX(-50%) scale(1.5) rotate(-3deg);
opacity: 0.7;
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
box-shadow: 0 12px 35px rgba(245, 158, 11, 0.6), 0 0 60px rgba(245, 158, 11, 0.9);
}
75% {
transform: translateX(-50%) scale(0.8) rotate(2deg);
opacity: 0.4;
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
}
100% {
transform: translateX(-50%) scale(0.1) rotate(0deg);
opacity: 0;
}
}
@keyframes wrongShake {
0%, 100% {
transform: translateX(-50%) scale(1);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-60%) scale(0.95);
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.6), 0 0 25px rgba(239, 68, 68, 0.8);
}
20%, 40%, 60%, 80% {
transform: translateX(-40%) scale(0.95);
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.6), 0 0 25px rgba(239, 68, 68, 0.8);
}
}
@keyframes wrongFlash {
0%, 100% {
background: transparent;
box-shadow: none;
}
50% {
background: rgba(239, 68, 68, 0.4);
box-shadow: 0 0 20px rgba(239, 68, 68, 0.6), inset 0 0 20px rgba(239, 68, 68, 0.3);
}
}
@keyframes screenShake {
0%, 100% { transform: translateX(0); }
10% { transform: translateX(-3px) translateY(1px); }
20% { transform: translateX(3px) translateY(-1px); }
30% { transform: translateX(-2px) translateY(2px); }
40% { transform: translateX(2px) translateY(-2px); }
50% { transform: translateX(-1px) translateY(1px); }
60% { transform: translateX(1px) translateY(-1px); }
70% { transform: translateX(-2px) translateY(0px); }
80% { transform: translateX(2px) translateY(1px); }
90% { transform: translateX(-1px) translateY(-1px); }
}
@keyframes pointsFloat {
0% {
transform: translateY(0) scale(1);
opacity: 1;
}
30% {
transform: translateY(-20px) scale(1.3);
opacity: 1;
}
100% {
transform: translateY(-80px) scale(0.5);
opacity: 0;
}
}
@media (max-width: 768px) {
.falling-word {
padding: 18px 25px;
font-size: 1.8rem;
}
}
@media (max-width: 480px) {
.falling-word {
font-size: 1.5rem;
padding: 15px 20px;
}
}
`;
document.head.appendChild(styleSheet);
logSh('Word Storm CSS injected', 'DEBUG');
}
extractVocabulary(content) {
let vocabulary = [];
logSh(`Word Storm extracting vocabulary from content`, 'DEBUG');
// Support Dragon's Pearl and other formats
if (content.vocabulary && typeof content.vocabulary === 'object') {
vocabulary = Object.entries(content.vocabulary).map(([original, vocabData]) => {
if (typeof vocabData === 'string') {
return {
original: original,
translation: vocabData
};
} else if (typeof vocabData === 'object') {
return {
original: original,
translation: vocabData.user_language || vocabData.translation || 'No translation',
pronunciation: vocabData.pronunciation
};
}
return null;
}).filter(item => item !== null);
logSh(`Extracted ${vocabulary.length} words from content.vocabulary`, 'DEBUG');
}
// Support rawContent format
if (content.rawContent && content.rawContent.vocabulary) {
const rawVocab = Object.entries(content.rawContent.vocabulary).map(([original, vocabData]) => {
if (typeof vocabData === 'string') {
return { original: original, translation: vocabData };
} else if (typeof vocabData === 'object') {
return {
original: original,
translation: vocabData.user_language || vocabData.translation,
pronunciation: vocabData.pronunciation
};
}
return null;
}).filter(item => item !== null);
vocabulary = vocabulary.concat(rawVocab);
logSh(`Added ${rawVocab.length} words from rawContent.vocabulary, total: ${vocabulary.length}`, 'DEBUG');
}
// Limit to 50 words max for performance
return vocabulary.slice(0, 50);
}
init() {
if (this.vocabulary.length === 0) {
this.showNoVocabularyMessage();
return;
}
this.container.innerHTML = `
<div class="game-wrapper compact">
<div class="game-hud">
<div class="hud-left">
<div class="score">Score: <span id="score">0</span></div>
<div class="level">Level: <span id="level">1</span></div>
</div>
<div class="hud-center">
<div class="lives">Lives: <span id="lives">3</span></div>
<div class="combo">Combo: <span id="combo">0</span></div>
</div>
<div class="hud-right">
<button class="pause-btn" id="pause-btn">⏸️ Pause</button>
</div>
</div>
<div class="game-area" id="game-area" style="position: relative; height: 80vh; background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%); overflow: hidden;">
</div>
<div class="answer-panel" id="answer-panel">
<div class="answer-buttons" id="answer-buttons">
<!-- Dynamic answer buttons -->
</div>
</div>
</div>
`;
this.setupEventListeners();
this.generateAnswerOptions();
}
setupEventListeners() {
const pauseBtn = document.getElementById('pause-btn');
if (pauseBtn) {
pauseBtn.addEventListener('click', () => this.togglePause());
}
// Answer button clicks
document.addEventListener('click', (e) => {
if (e.target.classList.contains('answer-btn')) {
const answer = e.target.textContent;
this.checkAnswer(answer);
}
});
// Keyboard support
document.addEventListener('keydown', (e) => {
if (e.key >= '1' && e.key <= '4') {
const btnIndex = parseInt(e.key) - 1;
const buttons = document.querySelectorAll('.answer-btn');
if (buttons[btnIndex]) {
buttons[btnIndex].click();
}
}
});
}
start() {
logSh('Word Storm game started', 'INFO');
this.startSpawning();
}
startSpawning() {
this.spawnInterval = setInterval(() => {
if (!this.isGamePaused && !this.isGameOver) {
this.spawnFallingWord();
}
}, this.spawnRate);
}
spawnFallingWord() {
if (this.vocabulary.length === 0) return;
const word = this.vocabulary[this.currentWordIndex % this.vocabulary.length];
this.currentWordIndex++;
const gameArea = document.getElementById('game-area');
const wordElement = document.createElement('div');
wordElement.className = 'falling-word';
wordElement.textContent = word.original;
wordElement.style.left = Math.random() * 80 + 10 + '%';
wordElement.style.top = '-60px';
gameArea.appendChild(wordElement);
this.fallingWords.push({
element: wordElement,
word: word,
startTime: Date.now()
});
// Generate new answer options when word spawns
this.generateAnswerOptions();
// Animate falling
this.animateFalling(wordElement);
// Remove after lifetime
setTimeout(() => {
if (wordElement.parentNode) {
this.missWord(wordElement);
}
}, this.wordLifetime);
}
animateFalling(wordElement) {
wordElement.style.transition = `top ${this.fallSpeed}ms linear`;
setTimeout(() => {
wordElement.style.top = '100vh';
}, 50);
}
generateAnswerOptions() {
if (this.vocabulary.length === 0) return;
const buttons = [];
const correctWord = this.fallingWords.length > 0 ?
this.fallingWords[this.fallingWords.length - 1].word :
this.vocabulary[0];
// Add correct answer
buttons.push(correctWord.translation);
// Add 3 random incorrect answers
while (buttons.length < 4) {
const randomWord = this.vocabulary[Math.floor(Math.random() * this.vocabulary.length)];
if (!buttons.includes(randomWord.translation)) {
buttons.push(randomWord.translation);
}
}
// Shuffle buttons
this.shuffleArray(buttons);
// Update answer panel
const answerButtons = document.getElementById('answer-buttons');
if (answerButtons) {
answerButtons.innerHTML = buttons.map(answer =>
`<button class="answer-btn">${answer}</button>`
).join('');
}
}
checkAnswer(selectedAnswer) {
const activeFallingWords = this.fallingWords.filter(fw => fw.element.parentNode);
for (let i = 0; i < activeFallingWords.length; i++) {
const fallingWord = activeFallingWords[i];
if (fallingWord.word.translation === selectedAnswer) {
this.correctAnswer(fallingWord);
return;
}
}
// Wrong answer
this.wrongAnswer();
}
correctAnswer(fallingWord) {
// Remove from game with epic explosion
if (fallingWord.element.parentNode) {
fallingWord.element.classList.add('exploding');
// Add screen shake effect
const gameArea = document.getElementById('game-area');
if (gameArea) {
gameArea.style.animation = 'none';
gameArea.offsetHeight; // Force reflow
gameArea.style.animation = 'screenShake 0.3s ease-in-out';
setTimeout(() => {
gameArea.style.animation = '';
}, 300);
}
setTimeout(() => {
if (fallingWord.element.parentNode) {
fallingWord.element.remove();
}
}, 800);
}
// Remove from tracking
this.fallingWords = this.fallingWords.filter(fw => fw !== fallingWord);
// Update score
this.combo++;
const points = 10 + (this.combo * 2);
this.score += points;
this.onScoreUpdate(this.score);
// Update display with animation
document.getElementById('score').textContent = this.score;
document.getElementById('combo').textContent = this.combo;
// Add points popup animation
this.showPointsPopup(points, fallingWord.element);
// Vibration feedback (if supported)
if (navigator.vibrate) {
navigator.vibrate([50, 30, 50]);
}
// Level up check
if (this.score > 0 && this.score % 100 === 0) {
this.levelUp();
}
}
wrongAnswer() {
this.combo = 0;
document.getElementById('combo').textContent = this.combo;
// Enhanced wrong answer animation
const answerPanel = document.getElementById('answer-panel');
if (answerPanel) {
answerPanel.classList.add('wrong-flash');
setTimeout(() => {
answerPanel.classList.remove('wrong-flash');
}, 500);
}
// Shake all falling words to show disappointment
this.fallingWords.forEach(fw => {
if (fw.element.parentNode && !fw.element.classList.contains('exploding')) {
fw.element.classList.add('wrong-shake');
setTimeout(() => {
fw.element.classList.remove('wrong-shake');
}, 600);
}
});
// Screen flash red
const gameArea = document.getElementById('game-area');
if (gameArea) {
const overlay = document.createElement('div');
overlay.style.cssText = `
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(239, 68, 68, 0.3);
pointer-events: none;
animation: wrongFlash 0.4s ease-in-out;
z-index: 100;
`;
gameArea.appendChild(overlay);
setTimeout(() => {
if (overlay.parentNode) overlay.remove();
}, 400);
}
// Wrong answer vibration (stronger/longer)
if (navigator.vibrate) {
navigator.vibrate([200, 100, 200, 100, 200]);
}
}
showPointsPopup(points, wordElement) {
const popup = document.createElement('div');
popup.textContent = `+${points}`;
popup.style.cssText = `
position: absolute;
left: ${wordElement.style.left};
top: ${wordElement.offsetTop}px;
font-size: 2rem;
font-weight: bold;
color: #10b981;
pointer-events: none;
z-index: 1000;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
animation: pointsFloat 1.5s ease-out forwards;
`;
const gameArea = document.getElementById('game-area');
if (gameArea) {
gameArea.appendChild(popup);
setTimeout(() => {
if (popup.parentNode) popup.remove();
}, 1500);
}
}
missWord(wordElement) {
// Remove word
if (wordElement.parentNode) {
wordElement.remove();
}
// Remove from tracking
this.fallingWords = this.fallingWords.filter(fw => fw.element !== wordElement);
// Lose life
this.lives--;
this.combo = 0;
document.getElementById('lives').textContent = this.lives;
document.getElementById('combo').textContent = this.combo;
if (this.lives <= 0) {
this.gameOver();
}
}
levelUp() {
this.level++;
document.getElementById('level').textContent = this.level;
// Increase difficulty
this.fallSpeed = Math.max(1000, this.fallSpeed * 0.9);
this.spawnRate = Math.max(800, this.spawnRate * 0.95);
// Restart intervals with new timing
if (this.spawnInterval) {
clearInterval(this.spawnInterval);
this.startSpawning();
}
// Show level up message
const gameArea = document.getElementById('game-area');
const levelUpMsg = document.createElement('div');
levelUpMsg.innerHTML = `
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: rgba(0,0,0,0.8); color: white; padding: 20px; border-radius: 10px;
text-align: center; z-index: 1000;">
<h2>⚡ LEVEL UP! ⚡</h2>
<p>Level ${this.level}</p>
</div>
`;
gameArea.appendChild(levelUpMsg);
setTimeout(() => {
if (levelUpMsg.parentNode) {
levelUpMsg.remove();
}
}, 2000);
}
togglePause() {
this.isGamePaused = !this.isGamePaused;
const pauseBtn = document.getElementById('pause-btn');
if (pauseBtn) {
pauseBtn.textContent = this.isGamePaused ? '▶️ Resume' : '⏸️ Pause';
}
}
gameOver() {
this.isGameOver = true;
// Clear intervals
if (this.spawnInterval) {
clearInterval(this.spawnInterval);
}
// Clear falling words
this.fallingWords.forEach(fw => {
if (fw.element.parentNode) {
fw.element.remove();
}
});
this.onGameEnd(this.score);
}
showNoVocabularyMessage() {
this.container.innerHTML = `
<div class="game-error">
<div class="error-content">
<h2>🌪️ Word Storm</h2>
<p>❌ No vocabulary found in this content.</p>
<p>This game requires content with vocabulary words.</p>
<button class="back-btn" onclick="AppNavigation.navigateTo('games')">← Back to Games</button>
</div>
</div>
`;
}
shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
}
destroy() {
if (this.spawnInterval) {
clearInterval(this.spawnInterval);
}
// Remove CSS
const styleSheet = document.getElementById('word-storm-styles');
if (styleSheet) {
styleSheet.remove();
}
logSh('Word Storm destroyed', 'INFO');
}
// === COMPATIBILITY SYSTEM ===
static getCompatibilityRequirements() {
return {
minimum: {
vocabulary: 8
},
optimal: {
vocabulary: 20
},
name: "Word Storm",
description: "Fast-paced vocabulary game with falling words to catch"
};
}
static checkContentCompatibility(content) {
const requirements = WordStormGame.getCompatibilityRequirements();
// DEBUG: Log content structure
console.log(`🔍 WordStorm DEBUG - Content received:`, content);
console.log(`🔍 WordStorm DEBUG - Content vocabulary:`, content.vocabulary);
// Extract vocabulary using same method as instance
const vocabulary = WordStormGame.extractVocabularyStatic(content);
const vocabCount = vocabulary.length;
// DEBUG: Log extraction results
console.log(`🔍 WordStorm DEBUG - Extracted vocabulary:`, vocabulary);
console.log(`🔍 WordStorm DEBUG - Vocab count: ${vocabCount}, Requirements:`, requirements);
// Dynamic percentage based on optimal volume (8 min → 20 optimal)
// 0 words = 0%, 10 words = 50%, 20 words = 100%
const score = Math.min(100, (vocabCount / requirements.optimal.vocabulary) * 100);
console.log(`🔍 WordStorm DEBUG - Final score: ${score}%`);
return {
score: Math.round(score),
details: {
vocabulary: {
found: vocabCount,
minimum: requirements.minimum.vocabulary,
optimal: requirements.optimal.vocabulary,
status: vocabCount >= requirements.minimum.vocabulary ? 'sufficient' : 'insufficient'
}
},
recommendations: vocabCount < requirements.optimal.vocabulary ?
[`Add ${requirements.optimal.vocabulary - vocabCount} more vocabulary words for optimal experience`] :
[]
};
}
static extractVocabularyStatic(content) {
let vocabulary = [];
// Priority 1: Use raw module content (simple format)
if (content.rawContent) {
return WordStormGame.extractVocabularyFromRawStatic(content.rawContent);
}
// Priority 2: Ultra-modular format (vocabulary object) - ONLY format supported
if (content.vocabulary && typeof content.vocabulary === 'object' && !Array.isArray(content.vocabulary)) {
vocabulary = Object.entries(content.vocabulary).map(([word, data]) => {
// Support ultra-modular format ONLY
if (typeof data === 'object' && data.user_language) {
return {
word: 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'
};
}
// Legacy fallback - simple string (temporary, will be removed)
else if (typeof data === 'string') {
return {
word: word,
translation: data.split('')[0],
fullTranslation: data,
type: 'general',
category: 'general'
};
}
return null;
}).filter(Boolean);
}
return WordStormGame.finalizeVocabularyStatic(vocabulary);
}
static extractVocabularyFromRawStatic(rawContent) {
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 {
word: 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'
};
}
// Legacy fallback - simple string (temporary, will be removed)
else if (typeof data === 'string') {
return {
word: word,
translation: data.split('')[0],
fullTranslation: data,
type: 'general',
category: 'general'
};
}
return null;
}).filter(Boolean);
}
return WordStormGame.finalizeVocabularyStatic(vocabulary);
}
static finalizeVocabularyStatic(vocabulary) {
// Validation and cleanup for ultra-modular format
vocabulary = vocabulary.filter(word =>
word &&
typeof word.word === 'string' &&
typeof word.translation === 'string' &&
word.word.trim() !== '' &&
word.translation.trim() !== ''
);
return vocabulary;
}
}
// Export to global namespace
window.GameModules = window.GameModules || {};
window.GameModules.WordStorm = WordStormGame;