- Fix WebSocket server to properly broadcast logs to all connected clients - Integrate professional logging system with real-time WebSocket interface - Add network status indicator with DigitalOcean Spaces connectivity - Implement AWS Signature V4 authentication for private bucket access - Add JSON content loader with backward compatibility to JS modules - Restore navigation breadcrumb system with comprehensive logging - Add multiple content formats: JSON + JS with automatic discovery - Enhance top bar with logger toggle and network status indicator - Remove deprecated temp-games module and clean up unused files 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
455 lines
16 KiB
JavaScript
455 lines
16 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.goBack()" 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);
|
|
}
|
|
|
|
// Modern format with contentItems
|
|
if (content.contentItems && Array.isArray(content.contentItems)) {
|
|
logSh('🆕 ContentItems format detected', 'INFO');
|
|
const vocabItems = content.contentItems.filter(item => item.type === 'vocabulary');
|
|
if (vocabItems.length > 0) {
|
|
vocabulary = vocabItems[0].items || [];
|
|
}
|
|
}
|
|
// Legacy format with vocabulary array
|
|
else if (content.vocabulary && Array.isArray(content.vocabulary)) {
|
|
logSh('📚 Vocabulary array format detected', 'INFO');
|
|
vocabulary = content.vocabulary;
|
|
}
|
|
|
|
return this.finalizeVocabulary(vocabulary);
|
|
}
|
|
|
|
extractVocabularyFromRaw(rawContent) {
|
|
logSh('🔧 Extracting from raw content:', rawContent.name || 'Module', 'INFO');
|
|
let vocabulary = [];
|
|
|
|
// Check vocabulary object format (key-value pairs)
|
|
if (rawContent.vocabulary && typeof rawContent.vocabulary === 'object' && !Array.isArray(rawContent.vocabulary)) {
|
|
vocabulary = Object.entries(rawContent.vocabulary).map(([english, translation]) => ({
|
|
english: english,
|
|
french: translation
|
|
}));
|
|
logSh(`📝 ${vocabulary.length} vocabulary pairs extracted from object`, 'INFO');
|
|
}
|
|
// Check vocabulary array format
|
|
else if (rawContent.vocabulary && Array.isArray(rawContent.vocabulary)) {
|
|
vocabulary = rawContent.vocabulary;
|
|
logSh(`📚 ${vocabulary.length} vocabulary items extracted from array`, 'INFO');
|
|
}
|
|
|
|
return this.finalizeVocabulary(vocabulary);
|
|
}
|
|
|
|
finalizeVocabulary(vocabulary) {
|
|
// Filter and validate vocabulary
|
|
vocabulary = vocabulary.filter(item =>
|
|
item &&
|
|
item.english &&
|
|
(item.french || item.translation || item.chinese)
|
|
).map(item => ({
|
|
english: item.english,
|
|
french: item.french || item.translation || item.chinese
|
|
}));
|
|
|
|
if (vocabulary.length === 0) {
|
|
logSh('❌ No valid vocabulary found', 'ERROR');
|
|
// Demo vocabulary as fallback
|
|
vocabulary = [
|
|
{ english: "cat", french: "chat" },
|
|
{ english: "dog", french: "chien" },
|
|
{ english: "house", french: "maison" },
|
|
{ english: "car", french: "voiture" },
|
|
{ english: "book", french: "livre" },
|
|
{ english: "water", french: "eau" },
|
|
{ english: "food", french: "nourriture" },
|
|
{ english: "friend", french: "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.english,
|
|
type: 'english',
|
|
pairId: index,
|
|
isFlipped: false,
|
|
isMatched: false
|
|
});
|
|
|
|
// French card
|
|
this.cards.push({
|
|
id: `fr_${index}`,
|
|
content: item.french,
|
|
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 = '';
|
|
}
|
|
}
|
|
|
|
// Module registration
|
|
window.GameModules = window.GameModules || {};
|
|
window.GameModules.MemoryMatch = MemoryMatchGame; |