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>
628 lines
23 KiB
JavaScript
628 lines
23 KiB
JavaScript
// === 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; |