Header, footer, titres et balises meta de toutes les pages actives : remplace NextGN Formation par Gauthier Chombart & Nathan Chauwin. Nouveau pied de page uniforme mentionnant les droits reserves et le statut de formateurs independants (dossier archives/ laisse inchange). Integre aussi la refonte du jeu Memory (CSS/JS/HUD/leaderboard) et ajoute le rapport de veille Certibiocide semaine 28.
539 lines
18 KiB
JavaScript
539 lines
18 KiB
JavaScript
/* ============================================
|
|
MEMORY PICTOGRAMMES - GAME ENGINE
|
|
Logique principale du jeu
|
|
============================================ */
|
|
|
|
class MemoryGameEngine {
|
|
constructor(difficulty = 'normal') {
|
|
this.difficulty = difficulty;
|
|
this.mode = DIFFICULTY_MODES[difficulty];
|
|
this.cards = [];
|
|
this.gameState = {
|
|
flipped: [],
|
|
matched: 0,
|
|
moves: 0,
|
|
score: 0,
|
|
isProcessing: false,
|
|
gameStarted: false
|
|
};
|
|
this.startTime = null;
|
|
this.timerInterval = null;
|
|
this.scoreEngine = new ScoringEngine();
|
|
this.analytics = new AnalyticsManager();
|
|
|
|
this.init();
|
|
}
|
|
|
|
/**
|
|
* Initialiser le jeu
|
|
*/
|
|
init() {
|
|
this.cards = this.initializeCards();
|
|
this.renderCards();
|
|
this.setupEventListeners();
|
|
this.updateHUD();
|
|
}
|
|
|
|
/**
|
|
* Initialiser le deck de cartes
|
|
*/
|
|
initializeCards() {
|
|
const selectedPictogrammes = PICTOGRAMMES.slice(0, this.mode.paires);
|
|
const pairs = [];
|
|
|
|
// Créer paires (image + texte pour chaque pictogramme)
|
|
selectedPictogrammes.forEach(pic => {
|
|
pairs.push({
|
|
type: 'image',
|
|
data: pic,
|
|
id: pic.id,
|
|
pairId: `${pic.id}-img`
|
|
});
|
|
pairs.push({
|
|
type: 'text',
|
|
data: pic,
|
|
id: pic.id,
|
|
pairId: `${pic.id}-txt`
|
|
});
|
|
});
|
|
|
|
// Mélanger les cartes
|
|
return this.shuffle(pairs);
|
|
}
|
|
|
|
/**
|
|
* Algorithme de shuffling (Fisher-Yates)
|
|
*/
|
|
shuffle(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;
|
|
}
|
|
|
|
/**
|
|
* Rendre les cartes en HTML
|
|
*/
|
|
renderCards() {
|
|
const gameGrid = document.getElementById('game-grid');
|
|
gameGrid.innerHTML = '';
|
|
|
|
this.cards.forEach((card, index) => {
|
|
const cardElement = document.createElement('div');
|
|
cardElement.className = 'memory-card';
|
|
cardElement.setAttribute('data-card-index', index);
|
|
|
|
// Face avant (couverture)
|
|
const frontFace = document.createElement('div');
|
|
frontFace.className = 'card-face front';
|
|
frontFace.textContent = '?';
|
|
|
|
// Face arrière (contenu)
|
|
const backFace = document.createElement('div');
|
|
backFace.className = 'card-face back';
|
|
|
|
if (card.type === 'image') {
|
|
const img = document.createElement('img');
|
|
img.src = `assets/pictogrammes/${card.data.filename}`;
|
|
img.alt = card.data.nom;
|
|
backFace.appendChild(img);
|
|
} else {
|
|
const text = document.createElement('span');
|
|
text.textContent = card.data.texte;
|
|
backFace.appendChild(text);
|
|
}
|
|
|
|
cardElement.appendChild(frontFace);
|
|
cardElement.appendChild(backFace);
|
|
|
|
cardElement.addEventListener('click', () => this.onCardClicked(index));
|
|
gameGrid.appendChild(cardElement);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Gestion du clic sur une carte
|
|
*/
|
|
onCardClicked(cardIndex) {
|
|
// Démarrer le timer au premier clic
|
|
if (!this.gameState.gameStarted) {
|
|
this.startGame();
|
|
}
|
|
|
|
// Guard clauses
|
|
if (this.gameState.isProcessing) return;
|
|
if (this.gameState.flipped.length === 2) return;
|
|
if (this.cards[cardIndex].isMatched) return;
|
|
if (this.gameState.flipped.includes(cardIndex)) return;
|
|
|
|
// Retourner la carte
|
|
this.flipCard(cardIndex);
|
|
this.gameState.flipped.push(cardIndex);
|
|
|
|
// Analytics
|
|
this.analytics.trackFlip(cardIndex, this.gameState.moves);
|
|
|
|
// Vérifier si 2 cartes retournées
|
|
if (this.gameState.flipped.length === 2) {
|
|
this.gameState.isProcessing = true;
|
|
this.gameState.moves++;
|
|
this.updateHUD();
|
|
|
|
// Délai pour visualiser les 2 cartes
|
|
setTimeout(() => this.checkMatch(), 1200);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Retourner une carte (animation)
|
|
*/
|
|
flipCard(index) {
|
|
const cardElement = document.querySelector(`[data-card-index="${index}"]`);
|
|
cardElement.classList.add('flipped');
|
|
}
|
|
|
|
/**
|
|
* Retourner une carte (inverse l'animation)
|
|
*/
|
|
unflipCard(index) {
|
|
const cardElement = document.querySelector(`[data-card-index="${index}"]`);
|
|
cardElement.classList.remove('flipped');
|
|
}
|
|
|
|
/**
|
|
* Vérifier si les 2 cartes retournées correspondent
|
|
*/
|
|
checkMatch() {
|
|
const [idx1, idx2] = this.gameState.flipped;
|
|
const card1 = this.cards[idx1];
|
|
const card2 = this.cards[idx2];
|
|
|
|
if (card1.id === card2.id) {
|
|
this.onMatchSuccess(idx1, idx2);
|
|
} else {
|
|
this.onMatchFailure(idx1, idx2);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Paire correctement matchée
|
|
*/
|
|
onMatchSuccess(idx1, idx2) {
|
|
const card1Element = document.querySelector(`[data-card-index="${idx1}"]`);
|
|
const card2Element = document.querySelector(`[data-card-index="${idx2}"]`);
|
|
|
|
// Marquer comme matched
|
|
this.cards[idx1].isMatched = true;
|
|
this.cards[idx2].isMatched = true;
|
|
card1Element.classList.add('matched');
|
|
card2Element.classList.add('matched');
|
|
|
|
this.gameState.matched++;
|
|
|
|
// Augmenter score
|
|
const points = 100 + (50 * this.mode.scoreMultiplier);
|
|
this.gameState.score += points;
|
|
|
|
// Analytics
|
|
this.analytics.trackMatch(
|
|
this.cards[idx1].id,
|
|
this.gameState.matched,
|
|
this.gameState.moves
|
|
);
|
|
|
|
// Mettre à jour UI
|
|
this.updateHUD();
|
|
|
|
// Ajouter pulse à la barre de progression
|
|
const progressContainer = document.querySelector('.progress-container');
|
|
progressContainer.classList.add('pulse');
|
|
setTimeout(() => progressContainer.classList.remove('pulse'), 600);
|
|
|
|
// Vérifier victoire
|
|
if (this.gameState.matched === this.mode.paires) {
|
|
setTimeout(() => this.endGame(true), 600);
|
|
} else {
|
|
// Reset pour prochaine tentative
|
|
this.gameState.flipped = [];
|
|
this.gameState.isProcessing = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Paire incorrecte
|
|
*/
|
|
onMatchFailure(idx1, idx2) {
|
|
const card1Element = document.querySelector(`[data-card-index="${idx1}"]`);
|
|
const card2Element = document.querySelector(`[data-card-index="${idx2}"]`);
|
|
|
|
// Animation shake
|
|
card1Element.classList.add('error');
|
|
card2Element.classList.add('error');
|
|
|
|
// Analytics
|
|
this.analytics.trackMismatch(
|
|
this.cards[idx1].id,
|
|
this.cards[idx2].id,
|
|
this.gameState.moves
|
|
);
|
|
|
|
// Retourner après délai
|
|
setTimeout(() => {
|
|
this.unflipCard(idx1);
|
|
this.unflipCard(idx2);
|
|
card1Element.classList.remove('error');
|
|
card2Element.classList.remove('error');
|
|
this.gameState.flipped = [];
|
|
this.gameState.isProcessing = false;
|
|
}, 1200);
|
|
}
|
|
|
|
/**
|
|
* Démarrer le timer du jeu
|
|
*/
|
|
startGame() {
|
|
this.gameState.gameStarted = true;
|
|
this.startTime = Date.now();
|
|
|
|
this.timerInterval = setInterval(() => {
|
|
this.updateTimer();
|
|
}, 100);
|
|
|
|
this.analytics.trackGameStart(this.difficulty);
|
|
}
|
|
|
|
/**
|
|
* Mettre à jour le timer
|
|
*/
|
|
updateTimer() {
|
|
const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
|
|
const minutes = Math.floor(elapsed / 60);
|
|
const seconds = elapsed % 60;
|
|
|
|
const timerDisplay = document.getElementById('timer-display');
|
|
timerDisplay.textContent = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
|
|
// Animation warning si peu de temps reste
|
|
if (this.mode.timeLimit && elapsed >= this.mode.timeLimit * 0.8) {
|
|
timerDisplay.style.color = '#FF9800';
|
|
timerDisplay.style.animation = 'timerWarning 0.5s infinite';
|
|
}
|
|
|
|
// Game over si temps limite dépassé
|
|
if (this.mode.timeLimit && elapsed >= this.mode.timeLimit) {
|
|
this.endGame(false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mettre à jour le HUD
|
|
*/
|
|
updateHUD() {
|
|
// Score
|
|
document.getElementById('score-display').textContent = this.gameState.score;
|
|
|
|
// Mouvements
|
|
document.getElementById('moves-display').textContent = this.gameState.moves;
|
|
|
|
// Paires trouvées
|
|
document.getElementById('pairs-display').textContent = `${this.gameState.matched}/${this.mode.paires}`;
|
|
|
|
// Barre de progression
|
|
const progress = (this.gameState.matched / this.mode.paires) * 100;
|
|
document.getElementById('progress-bar').style.width = `${progress}%`;
|
|
|
|
// Modifier le scroll progress bar en haut
|
|
const scrollProgress = (window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100;
|
|
document.getElementById('progress-fill').style.width = `${Math.min(scrollProgress, 100)}%`;
|
|
}
|
|
|
|
/**
|
|
* Fin du jeu
|
|
*/
|
|
endGame(isWon) {
|
|
clearInterval(this.timerInterval);
|
|
|
|
const endTime = Date.now();
|
|
const gameData = {
|
|
duration: Math.floor((endTime - this.startTime) / 1000),
|
|
moves: this.gameState.moves,
|
|
score: this.gameState.score,
|
|
difficulty: this.difficulty,
|
|
won: isWon
|
|
};
|
|
|
|
// Calculer score final avec bonus
|
|
const finalScore = this.scoreEngine.calculateFinalScore(
|
|
gameData.moves,
|
|
gameData.duration,
|
|
this.mode.paires,
|
|
this.difficulty
|
|
);
|
|
|
|
// Détecter achievements
|
|
const achievements = this.scoreEngine.detectAchievements(
|
|
gameData.moves,
|
|
gameData.duration,
|
|
this.mode.paires,
|
|
this.difficulty
|
|
);
|
|
|
|
// Analytics
|
|
this.analytics.trackGameEnd({
|
|
...gameData,
|
|
finalScore: finalScore.totalScore,
|
|
achievements: achievements.map(a => a.id)
|
|
});
|
|
|
|
// Sauvegarder score
|
|
LeaderboardManager.addScore({
|
|
...gameData,
|
|
score: finalScore.totalScore
|
|
});
|
|
|
|
// Afficher modal victoire
|
|
this.showVictoryModal(finalScore, achievements, isWon);
|
|
}
|
|
|
|
/**
|
|
* Afficher la modal de victoire
|
|
*/
|
|
showVictoryModal(finalScore, achievements, isWon) {
|
|
const modal = document.getElementById('victory-modal');
|
|
const minutes = Math.floor(this.gameState.score / 60);
|
|
const seconds = this.gameState.score % 60;
|
|
|
|
// Remplir les données
|
|
document.getElementById('victory-title').textContent = isWon ? 'Bravo! 🎉' : 'Temps écoulé! ⏰';
|
|
document.getElementById('final-score').textContent = finalScore.totalScore;
|
|
document.getElementById('modal-moves').textContent = this.gameState.moves;
|
|
document.getElementById('modal-time').textContent = `${String(Math.floor(finalScore.duration / 60)).padStart(2, '0')}:${String(finalScore.duration % 60).padStart(2, '0')}`;
|
|
document.getElementById('modal-difficulty').textContent = this.mode.name;
|
|
document.getElementById('modal-bonus').textContent = `+${finalScore.timeBonus}`;
|
|
|
|
// Achievements
|
|
const achievementsList = document.getElementById('achievements-list');
|
|
achievementsList.innerHTML = '';
|
|
|
|
if (achievements.length > 0) {
|
|
const title = document.createElement('div');
|
|
title.style.fontSize = '14px';
|
|
title.style.fontWeight = '600';
|
|
title.style.marginBottom = '12px';
|
|
title.textContent = '🏅 Achievements Débloqués';
|
|
achievementsList.appendChild(title);
|
|
|
|
achievements.forEach((ach, index) => {
|
|
const achievementEl = document.createElement('div');
|
|
achievementEl.className = 'achievement';
|
|
achievementEl.style.animationDelay = `${(index * 0.1)}s`;
|
|
achievementEl.innerHTML = `
|
|
<div class="achievement-icon">${ach.icon}</div>
|
|
<div class="achievement-text">
|
|
<div class="achievement-name">${ach.name}</div>
|
|
<div class="achievement-desc">${ach.description}</div>
|
|
</div>
|
|
`;
|
|
achievementsList.appendChild(achievementEl);
|
|
});
|
|
}
|
|
|
|
// Boutons d'action
|
|
document.getElementById('play-again-btn').onclick = () => this.restart();
|
|
document.getElementById('view-leaderboard-btn').onclick = () => {
|
|
modal.classList.add('hidden');
|
|
document.querySelector('.leaderboard-section').scrollIntoView({ behavior: 'smooth' });
|
|
};
|
|
|
|
// Afficher modal
|
|
modal.classList.remove('hidden');
|
|
}
|
|
|
|
/**
|
|
* Redémarrer le jeu
|
|
*/
|
|
restart() {
|
|
document.getElementById('victory-modal').classList.add('hidden');
|
|
clearInterval(this.timerInterval);
|
|
|
|
// Réinitialiser l'état
|
|
this.gameState = {
|
|
flipped: [],
|
|
matched: 0,
|
|
moves: 0,
|
|
score: 0,
|
|
isProcessing: false,
|
|
gameStarted: false
|
|
};
|
|
|
|
// Réinitialiser les cartes
|
|
this.cards = this.initializeCards();
|
|
this.renderCards();
|
|
this.updateHUD();
|
|
|
|
// Reset timer display
|
|
document.getElementById('timer-display').textContent = '00:00';
|
|
document.getElementById('timer-display').style.color = '';
|
|
document.getElementById('timer-display').style.animation = '';
|
|
}
|
|
|
|
/**
|
|
* Setup event listeners
|
|
*/
|
|
setupEventListeners() {
|
|
// Bouton restart
|
|
document.getElementById('restart-btn').addEventListener('click', () => this.restart());
|
|
|
|
// Sélecteur de difficulté
|
|
document.querySelectorAll('.btn-difficulty').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const newDifficulty = btn.getAttribute('data-difficulty');
|
|
if (newDifficulty !== this.difficulty) {
|
|
this.difficulty = newDifficulty;
|
|
this.mode = DIFFICULTY_MODES[newDifficulty];
|
|
this.restart();
|
|
|
|
// Marquer comme actif
|
|
document.querySelectorAll('.btn-difficulty').forEach(b => {
|
|
b.classList.remove('active');
|
|
});
|
|
btn.classList.add('active');
|
|
|
|
this.analytics.trackDifficultyChange(newDifficulty);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Tabs du leaderboard
|
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const filter = btn.getAttribute('data-filter');
|
|
LeaderboardManager.updateDisplay(filter);
|
|
|
|
document.querySelectorAll('.tab-btn').forEach(b => {
|
|
b.classList.remove('active');
|
|
});
|
|
btn.classList.add('active');
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// INITIALISATION AU CHARGEMENT
|
|
// ============================================
|
|
let game;
|
|
|
|
/**
|
|
* Demander le nom du joueur à la première partie
|
|
*/
|
|
function setupPlayerNameModal(onReady) {
|
|
const nameConfirmed = localStorage.getItem('nextgn_name_confirmed') === 'true';
|
|
|
|
if (nameConfirmed) {
|
|
onReady();
|
|
return;
|
|
}
|
|
|
|
const modal = document.getElementById('player-name-modal');
|
|
const input = document.getElementById('player-name-input');
|
|
const confirmBtn = document.getElementById('player-name-confirm');
|
|
|
|
modal.classList.remove('hidden');
|
|
input.focus();
|
|
|
|
const confirmName = () => {
|
|
const name = input.value.trim();
|
|
localStorage.setItem('nextgn_playerName', name || getPlayerName());
|
|
localStorage.setItem('nextgn_name_confirmed', 'true');
|
|
modal.classList.add('hidden');
|
|
onReady();
|
|
};
|
|
|
|
confirmBtn.addEventListener('click', confirmName, { once: true });
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') confirmName();
|
|
}, { once: true });
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Afficher le leaderboard initial
|
|
LeaderboardManager.updateDisplay('all');
|
|
LeaderboardManager.updatePlayerStats();
|
|
|
|
setupPlayerNameModal(() => {
|
|
LeaderboardManager.updateDisplay('all');
|
|
LeaderboardManager.updatePlayerStats();
|
|
});
|
|
|
|
// Initialiser le jeu
|
|
game = new MemoryGameEngine('normal');
|
|
|
|
// Scroll progress
|
|
window.addEventListener('scroll', () => {
|
|
game.updateHUD();
|
|
});
|
|
});
|