Class_generator/js/games/memory-match.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

628 lines
23 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 MEMORY MATCH ===
class MemoryMatchGame {
constructor(options) {
this.container = options.container;
this.content = options.content;
this.onScoreUpdate = options.onScoreUpdate || (() => {});
this.onGameEnd = options.onGameEnd || (() => {});
// Game state
this.cards = [];
this.flippedCards = [];
this.matchedPairs = 0;
this.totalPairs = 8; // 4x4 grid = 16 cards = 8 pairs
this.moves = 0;
this.score = 0;
this.isFlipping = false;
// Extract vocabulary
this.vocabulary = this.extractVocabulary(this.content);
this.init();
}
init() {
// Check if we have enough vocabulary
if (!this.vocabulary || this.vocabulary.length < this.totalPairs) {
logSh('Not enough vocabulary for Memory Match', 'ERROR');
this.showInitError();
return;
}
this.createGameInterface();
this.generateCards();
this.setupEventListeners();
}
showInitError() {
this.container.innerHTML = `
<div class="game-error">
<h3>❌ Error loading</h3>
<p>This content doesn't have enough vocabulary for Memory Match.</p>
<p>The game needs at least ${this.totalPairs} vocabulary pairs.</p>
<button onclick="AppNavigation.navigateTo('games')" class="back-btn">← Back</button>
</div>
`;
}
extractVocabulary(content) {
let vocabulary = [];
logSh('📝 Extracting vocabulary from:', content?.name || 'content', 'INFO');
// Use raw module content if available
if (content.rawContent) {
logSh('📦 Using raw module content', 'INFO');
return this.extractVocabularyFromRaw(content.rawContent);
}
// 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) {
// Filter and validate vocabulary for ultra-modular format
vocabulary = vocabulary.filter(item =>
item &&
typeof item.original === 'string' &&
typeof item.translation === 'string' &&
item.original.trim() !== '' &&
item.translation.trim() !== ''
);
if (vocabulary.length === 0) {
logSh('❌ No valid vocabulary found', 'ERROR');
// Demo vocabulary as fallback
vocabulary = [
{ original: "cat", translation: "chat" },
{ original: "dog", translation: "chien" },
{ original: "house", translation: "maison" },
{ original: "car", translation: "voiture" },
{ original: "book", translation: "livre" },
{ original: "water", translation: "eau" },
{ original: "food", translation: "nourriture" },
{ original: "friend", translation: "ami" }
];
logSh('🚨 Using demo vocabulary', 'WARN');
}
logSh(`✅ Memory Match: ${vocabulary.length} vocabulary items finalized`, 'INFO');
return vocabulary;
}
createGameInterface() {
this.container.innerHTML = `
<div class="memory-match-wrapper">
<!-- Game Stats -->
<div class="game-stats">
<div class="stat-item">
<span class="stat-label">Moves:</span>
<span id="moves-counter">0</span>
</div>
<div class="stat-item">
<span class="stat-label">Pairs:</span>
<span id="pairs-counter">0 / ${this.totalPairs}</span>
</div>
<div class="stat-item">
<span class="stat-label">Score:</span>
<span id="score-counter">0</span>
</div>
</div>
<!-- Game Grid -->
<div class="memory-grid" id="memory-grid">
<!-- Cards will be generated here -->
</div>
<!-- Game Controls -->
<div class="game-controls">
<button class="control-btn secondary" id="restart-btn">🔄 Restart</button>
<button class="control-btn secondary" id="hint-btn">💡 Hint</button>
</div>
<!-- Feedback Area -->
<div class="feedback-area" id="feedback-area">
<div class="instruction">
Click cards to flip them and find matching pairs!
</div>
</div>
</div>
`;
}
generateCards() {
// Select random vocabulary pairs
const selectedVocab = this.vocabulary
.sort(() => Math.random() - 0.5)
.slice(0, this.totalPairs);
// Create card pairs
this.cards = [];
selectedVocab.forEach((item, index) => {
// English card
this.cards.push({
id: `en_${index}`,
content: item.original,
type: 'english',
pairId: index,
isFlipped: false,
isMatched: false
});
// French card
this.cards.push({
id: `fr_${index}`,
content: item.translation,
type: 'french',
pairId: index,
isFlipped: false,
isMatched: false
});
});
// Shuffle cards
this.cards.sort(() => Math.random() - 0.5);
// Render cards
this.renderCards();
}
renderCards() {
const grid = document.getElementById('memory-grid');
grid.innerHTML = '';
this.cards.forEach((card, index) => {
const cardElement = document.createElement('div');
cardElement.className = 'memory-card';
cardElement.dataset.cardIndex = index;
cardElement.innerHTML = `
<div class="card-inner">
<div class="card-front">
<span class="card-icon">🎯</span>
</div>
<div class="card-back">
<span class="card-content">${card.content}</span>
</div>
</div>
`;
cardElement.addEventListener('click', () => this.flipCard(index));
grid.appendChild(cardElement);
});
}
setupEventListeners() {
document.getElementById('restart-btn').addEventListener('click', () => this.restart());
document.getElementById('hint-btn').addEventListener('click', () => this.showHint());
}
flipCard(cardIndex) {
if (this.isFlipping) return;
const card = this.cards[cardIndex];
if (card.isFlipped || card.isMatched) return;
// Flip the card
card.isFlipped = true;
this.updateCardDisplay(cardIndex);
this.flippedCards.push(cardIndex);
if (this.flippedCards.length === 2) {
this.moves++;
this.updateStats();
this.checkMatch();
}
}
updateCardDisplay(cardIndex) {
const cardElement = document.querySelector(`[data-card-index="${cardIndex}"]`);
const card = this.cards[cardIndex];
if (card.isFlipped || card.isMatched) {
cardElement.classList.add('flipped');
} else {
cardElement.classList.remove('flipped');
}
if (card.isMatched) {
cardElement.classList.add('matched');
}
}
checkMatch() {
this.isFlipping = true;
setTimeout(() => {
const [firstIndex, secondIndex] = this.flippedCards;
const firstCard = this.cards[firstIndex];
const secondCard = this.cards[secondIndex];
if (firstCard.pairId === secondCard.pairId) {
// Match found!
firstCard.isMatched = true;
secondCard.isMatched = true;
this.updateCardDisplay(firstIndex);
this.updateCardDisplay(secondIndex);
this.matchedPairs++;
this.score += 100;
this.showFeedback('Great match! 🎉', 'success');
// Trigger success animation
this.triggerSuccessAnimation(firstIndex, secondIndex);
if (this.matchedPairs === this.totalPairs) {
setTimeout(() => this.gameComplete(), 800);
}
} else {
// No match, flip back and apply penalty
firstCard.isFlipped = false;
secondCard.isFlipped = false;
this.updateCardDisplay(firstIndex);
this.updateCardDisplay(secondIndex);
// Apply penalty but don't go below 0
this.score = Math.max(0, this.score - 10);
this.showFeedback('Try again! (-10 points)', 'warning');
}
this.flippedCards = [];
this.isFlipping = false;
this.updateStats();
}, 1000);
}
showHint() {
if (this.flippedCards.length > 0) {
this.showFeedback('Finish your current move first!', 'warning');
return;
}
// Find first unmatched pair
const unmatchedCards = this.cards.filter(card => !card.isMatched);
if (unmatchedCards.length === 0) return;
// Group by pairId
const pairs = {};
unmatchedCards.forEach((card, index) => {
const actualIndex = this.cards.indexOf(card);
if (!pairs[card.pairId]) {
pairs[card.pairId] = [];
}
pairs[card.pairId].push(actualIndex);
});
// Find first complete pair
const completePair = Object.values(pairs).find(pair => pair.length === 2);
if (completePair) {
// Briefly show the pair
completePair.forEach(cardIndex => {
const cardElement = document.querySelector(`[data-card-index="${cardIndex}"]`);
cardElement.classList.add('hint');
});
setTimeout(() => {
completePair.forEach(cardIndex => {
const cardElement = document.querySelector(`[data-card-index="${cardIndex}"]`);
cardElement.classList.remove('hint');
});
}, 2000);
this.showFeedback('Hint shown for 2 seconds!', 'info');
}
}
updateStats() {
document.getElementById('moves-counter').textContent = this.moves;
document.getElementById('pairs-counter').textContent = `${this.matchedPairs} / ${this.totalPairs}`;
document.getElementById('score-counter').textContent = this.score;
this.onScoreUpdate(this.score);
}
gameComplete() {
// Calculate bonus based on moves
const perfectMoves = this.totalPairs;
if (this.moves <= perfectMoves + 5) {
this.score += 200; // Efficiency bonus
}
this.updateStats();
this.showFeedback('🎉 Congratulations! All pairs found!', 'success');
setTimeout(() => {
this.onGameEnd(this.score);
}, 2000);
}
showFeedback(message, type = 'info') {
const feedbackArea = document.getElementById('feedback-area');
feedbackArea.innerHTML = `<div class="instruction ${type}">${message}</div>`;
}
start() {
logSh('🧠 Memory Match: Starting', 'INFO');
this.showFeedback('Find matching English-French pairs!', 'info');
}
restart() {
logSh('🔄 Memory Match: Restarting', 'INFO');
this.reset();
this.start();
}
reset() {
this.flippedCards = [];
this.matchedPairs = 0;
this.moves = 0;
this.score = 0;
this.isFlipping = false;
this.generateCards();
this.updateStats();
}
triggerSuccessAnimation(cardIndex1, cardIndex2) {
// Get card elements
const card1 = document.querySelector(`[data-card-index="${cardIndex1}"]`);
const card2 = document.querySelector(`[data-card-index="${cardIndex2}"]`);
if (!card1 || !card2) return;
// Add success animation class
card1.classList.add('success-animation');
card2.classList.add('success-animation');
// Create sparkle particles for both cards
this.createSparkleParticles(card1);
this.createSparkleParticles(card2);
// Remove animation class after animation completes
setTimeout(() => {
card1.classList.remove('success-animation');
card2.classList.remove('success-animation');
}, 800);
}
createSparkleParticles(cardElement) {
const rect = cardElement.getBoundingClientRect();
// Create 4 sparkle particles around the card
for (let i = 1; i <= 4; i++) {
const particle = document.createElement('div');
particle.className = `success-particle particle-${i}`;
// Position relative to card
particle.style.position = 'fixed';
particle.style.left = (rect.left + rect.width / 2) + 'px';
particle.style.top = (rect.top + rect.height / 2) + 'px';
particle.style.pointerEvents = 'none';
particle.style.zIndex = '1000';
document.body.appendChild(particle);
// Remove particle after animation
setTimeout(() => {
if (particle.parentNode) {
particle.parentNode.removeChild(particle);
}
}, 1200);
}
}
destroy() {
this.container.innerHTML = '';
}
// === COMPATIBILITY SYSTEM ===
static getCompatibilityRequirements() {
return {
minimum: {
vocabulary: 6
},
optimal: {
vocabulary: 15
},
name: "Memory Match",
description: "Needs vocabulary pairs for card matching (8 pairs optimal, 6 minimum)"
};
}
static checkContentCompatibility(content) {
const requirements = MemoryMatchGame.getCompatibilityRequirements();
// Extract vocabulary using same method as instance
const vocabulary = MemoryMatchGame.extractVocabularyStatic(content);
const vocabCount = vocabulary.length;
// Dynamic percentage based on optimal volume (6 min → 15 optimal)
// 0 words = 0%, 8 words = 53%, 15 words = 100%
const score = Math.min(100, (vocabCount / requirements.optimal.vocabulary) * 100);
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 = [];
// Use raw module content if available
if (content.rawContent) {
return MemoryMatchGame.extractVocabularyFromRawStatic(content.rawContent);
}
// 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 {
original: word,
translation: data.user_language.split('')[0],
fullTranslation: data.user_language,
type: data.type || 'general',
audio: data.audio,
image: data.image,
examples: data.examples,
pronunciation: data.pronunciation,
category: data.type || 'general'
};
}
// 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);
}
return MemoryMatchGame.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 {
original: word,
translation: data.user_language.split('')[0],
fullTranslation: data.user_language,
type: data.type || 'general',
audio: data.audio,
image: data.image,
examples: data.examples,
pronunciation: data.pronunciation,
category: data.type || 'general'
};
}
// 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);
}
return MemoryMatchGame.finalizeVocabularyStatic(vocabulary);
}
static finalizeVocabularyStatic(vocabulary) {
// Filter and validate vocabulary for ultra-modular format
vocabulary = vocabulary.filter(item =>
item &&
typeof item.original === 'string' &&
typeof item.translation === 'string' &&
item.original.trim() !== '' &&
item.translation.trim() !== ''
);
return vocabulary;
}
}
// Module registration
window.GameModules = window.GameModules || {};
window.GameModules.MemoryMatch = MemoryMatchGame;