Major Changes: - Moved legacy system to Legacy/ folder for archival - Built new modular architecture with strict separation of concerns - Created core system: Module, EventBus, ModuleLoader, Router - Added Application bootstrap with auto-start functionality - Implemented development server with ES6 modules support - Created comprehensive documentation and project context - Converted SBS-7-8 content to JSON format - Copied all legacy games and content to new structure New Architecture Features: - Sealed modules with WeakMap private data - Strict dependency injection system - Event-driven communication only - Inviolable responsibility patterns - Auto-initialization without commands - Component-based UI foundation ready Technical Stack: - Vanilla JS/HTML/CSS only - ES6 modules with proper imports/exports - HTTP development server (no file:// protocol) - Modular CSS with component scoping - Comprehensive error handling and debugging Ready for Phase 2: Converting legacy modules to new architecture 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
565 lines
18 KiB
JavaScript
565 lines
18 KiB
JavaScript
// === MOTEUR DE CONTENU FLEXIBLE ===
|
|
|
|
class ContentEngine {
|
|
constructor() {
|
|
this.loadedContent = {};
|
|
this.migrator = new ContentMigrator();
|
|
this.validator = new ContentValidator();
|
|
}
|
|
|
|
// Charger et traiter un module de contenu
|
|
async loadContent(contentId) {
|
|
if (this.loadedContent[contentId]) {
|
|
return this.loadedContent[contentId];
|
|
}
|
|
|
|
try {
|
|
// Charger le contenu brut
|
|
const rawContent = await this.loadRawContent(contentId);
|
|
|
|
// Valider et migrer si nécessaire
|
|
const processedContent = this.processContent(rawContent);
|
|
|
|
// Mettre en cache
|
|
this.loadedContent[contentId] = processedContent;
|
|
|
|
return processedContent;
|
|
} catch (error) {
|
|
logSh(`Erreur chargement contenu ${contentId}:`, error, 'ERROR');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async loadRawContent(contentId) {
|
|
// Charger depuis le module existant
|
|
const moduleName = this.getModuleName(contentId);
|
|
if (window.ContentModules && window.ContentModules[moduleName]) {
|
|
return window.ContentModules[moduleName];
|
|
}
|
|
|
|
// Charger dynamiquement le script
|
|
await this.loadScript(`js/content/${contentId}.js`);
|
|
return window.ContentModules[moduleName];
|
|
}
|
|
|
|
processContent(rawContent) {
|
|
// Vérifier le format
|
|
if (this.isOldFormat(rawContent)) {
|
|
logSh('Migration ancien format vers nouveau format...', 'INFO');
|
|
return this.migrator.migrateToNewFormat(rawContent);
|
|
}
|
|
|
|
// Enrichir le contenu avec la structure letters si présente
|
|
if (rawContent.letters) {
|
|
rawContent = this.enrichContentWithLetters(rawContent);
|
|
}
|
|
|
|
// Valider le nouveau format
|
|
if (!this.validator.validate(rawContent)) {
|
|
throw new Error('Format de contenu invalide');
|
|
}
|
|
|
|
return rawContent;
|
|
}
|
|
|
|
// Enrichir le contenu avec les mots extraits de la structure letters
|
|
enrichContentWithLetters(content) {
|
|
logSh('🔤 Enrichissement du contenu avec structure letters...', 'INFO');
|
|
|
|
const enrichedContent = { ...content };
|
|
|
|
// Extraire tous les mots de la structure letters
|
|
const letterWords = this.extractWordsFromLetters(content.letters);
|
|
|
|
// Si pas de vocabulaire existant, créer depuis letters
|
|
if (!enrichedContent.vocabulary) {
|
|
enrichedContent.vocabulary = {};
|
|
}
|
|
|
|
// Ajouter les mots de letters au vocabulaire (sans écraser)
|
|
letterWords.forEach(wordData => {
|
|
if (!enrichedContent.vocabulary[wordData.word]) {
|
|
enrichedContent.vocabulary[wordData.word] = {
|
|
translation: wordData.translation,
|
|
user_language: wordData.translation,
|
|
type: wordData.type,
|
|
pronunciation: wordData.pronunciation,
|
|
prononciation: wordData.pronunciation,
|
|
gender: wordData.gender
|
|
};
|
|
}
|
|
});
|
|
|
|
// Ajouter les métadonnées pour Letter Discovery
|
|
enrichedContent.supportsLetterDiscovery = true;
|
|
enrichedContent.totalLetters = Object.keys(content.letters).length;
|
|
enrichedContent.totalLetterWords = letterWords.length;
|
|
|
|
logSh(`📝 ${letterWords.length} mots extraits de ${Object.keys(content.letters).length} lettres`, 'INFO');
|
|
|
|
return enrichedContent;
|
|
}
|
|
|
|
// Extraire tous les mots de la structure letters
|
|
extractWordsFromLetters(letters) {
|
|
const allWords = [];
|
|
|
|
Object.keys(letters).forEach(letter => {
|
|
const wordsForLetter = letters[letter];
|
|
if (Array.isArray(wordsForLetter)) {
|
|
wordsForLetter.forEach(wordData => {
|
|
allWords.push({
|
|
word: wordData.word,
|
|
translation: wordData.translation,
|
|
pronunciation: wordData.pronunciation,
|
|
type: wordData.type,
|
|
gender: wordData.gender,
|
|
letter: letter
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
return allWords;
|
|
}
|
|
|
|
// Obtenir les mots pour une lettre spécifique
|
|
getWordsForLetter(content, letter) {
|
|
if (content.letters && content.letters[letter]) {
|
|
return content.letters[letter];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
// Obtenir toutes les lettres disponibles
|
|
getAvailableLetters(content) {
|
|
if (content.letters) {
|
|
return Object.keys(content.letters).sort();
|
|
}
|
|
return [];
|
|
}
|
|
|
|
isOldFormat(content) {
|
|
// Détecter l'ancien format (simple array vocabulary)
|
|
return content.vocabulary && Array.isArray(content.vocabulary) &&
|
|
!content.contentItems && !content.version;
|
|
}
|
|
|
|
// Filtrer le contenu par critères
|
|
filterContent(content, filters = {}) {
|
|
if (!content.contentItems) return content;
|
|
|
|
let filtered = [...content.contentItems];
|
|
|
|
// Filtrer par type
|
|
if (filters.type) {
|
|
filtered = filtered.filter(item =>
|
|
Array.isArray(filters.type) ?
|
|
filters.type.includes(item.type) :
|
|
item.type === filters.type
|
|
);
|
|
}
|
|
|
|
// Filtrer par difficulté
|
|
if (filters.difficulty) {
|
|
filtered = filtered.filter(item => item.difficulty === filters.difficulty);
|
|
}
|
|
|
|
// Filtrer par catégorie
|
|
if (filters.category) {
|
|
filtered = filtered.filter(item => item.category === filters.category);
|
|
}
|
|
|
|
// Filtrer par tags
|
|
if (filters.tags) {
|
|
filtered = filtered.filter(item =>
|
|
item.content.tags &&
|
|
filters.tags.some(tag => item.content.tags.includes(tag))
|
|
);
|
|
}
|
|
|
|
return { ...content, contentItems: filtered };
|
|
}
|
|
|
|
// Adapter le contenu pour un jeu spécifique
|
|
adaptForGame(content, gameType) {
|
|
const adapter = new GameContentAdapter(gameType);
|
|
return adapter.adapt(content);
|
|
}
|
|
|
|
// Obtenir des éléments aléatoires
|
|
getRandomItems(content, count = 10, filters = {}) {
|
|
const filtered = this.filterContent(content, filters);
|
|
const items = filtered.contentItems || [];
|
|
|
|
const shuffled = [...items].sort(() => Math.random() - 0.5);
|
|
return shuffled.slice(0, count);
|
|
}
|
|
|
|
// Obtenir des éléments par progression
|
|
getItemsByProgression(content, userLevel = 1, count = 10) {
|
|
const items = content.contentItems || [];
|
|
|
|
// Calculer la difficulté appropriée
|
|
const targetDifficulties = this.getDifficultiesForLevel(userLevel);
|
|
|
|
const appropriateItems = items.filter(item =>
|
|
targetDifficulties.includes(item.difficulty)
|
|
);
|
|
|
|
return this.shuffleArray(appropriateItems).slice(0, count);
|
|
}
|
|
|
|
getDifficultiesForLevel(level) {
|
|
if (level <= 2) return ['easy'];
|
|
if (level <= 4) return ['easy', 'medium'];
|
|
return ['easy', 'medium', 'hard'];
|
|
}
|
|
|
|
// Utilitaires
|
|
getModuleName(contentId) {
|
|
const names = {
|
|
'sbs-level-8': 'SBSLevel8',
|
|
'animals': 'Animals',
|
|
'colors': 'Colors',
|
|
'family': 'Family',
|
|
'food': 'Food',
|
|
'house': 'House'
|
|
};
|
|
return names[contentId] || contentId;
|
|
}
|
|
|
|
async loadScript(src) {
|
|
return new Promise((resolve, reject) => {
|
|
const existingScript = document.querySelector(`script[src="${src}"]`);
|
|
if (existingScript) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
const script = document.createElement('script');
|
|
script.src = src;
|
|
script.onload = resolve;
|
|
script.onerror = () => reject(new Error(`Impossible de charger ${src}`));
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
shuffleArray(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;
|
|
}
|
|
}
|
|
|
|
// === MIGRATEUR DE CONTENU ===
|
|
|
|
class ContentMigrator {
|
|
migrateToNewFormat(oldContent) {
|
|
const newContent = {
|
|
id: oldContent.name || 'migrated-content',
|
|
name: oldContent.name || 'Contenu Migré',
|
|
description: oldContent.description || '',
|
|
version: '2.0',
|
|
format: 'unified',
|
|
difficulty: oldContent.difficulty || 'intermediate',
|
|
|
|
// Métadonnées
|
|
metadata: {
|
|
totalItems: oldContent.vocabulary ? oldContent.vocabulary.length : 0,
|
|
categories: this.extractCategories(oldContent),
|
|
contentTypes: ['vocabulary'],
|
|
migrationDate: new Date().toISOString()
|
|
},
|
|
|
|
// Configuration
|
|
config: {
|
|
defaultInteraction: 'click',
|
|
supportedGames: ['whack-a-mole', 'memory-game'],
|
|
adaptiveEnabled: true
|
|
},
|
|
|
|
// Contenu principal
|
|
contentItems: []
|
|
};
|
|
|
|
// Migrer le vocabulaire
|
|
if (oldContent.vocabulary) {
|
|
oldContent.vocabulary.forEach((word, index) => {
|
|
newContent.contentItems.push(this.migrateVocabularyItem(word, index));
|
|
});
|
|
}
|
|
|
|
// Migrer les phrases si elles existent
|
|
if (oldContent.phrases) {
|
|
oldContent.phrases.forEach((phrase, index) => {
|
|
newContent.contentItems.push(this.migratePhraseItem(phrase, index));
|
|
});
|
|
}
|
|
|
|
return newContent;
|
|
}
|
|
|
|
migrateVocabularyItem(oldWord, index) {
|
|
return {
|
|
id: `vocab_${index + 1}`,
|
|
type: 'vocabulary',
|
|
difficulty: this.inferDifficulty(oldWord.english),
|
|
category: oldWord.category || 'general',
|
|
|
|
content: {
|
|
english: oldWord.english,
|
|
french: oldWord.french,
|
|
context: oldWord.category || '',
|
|
tags: oldWord.category ? [oldWord.category] : []
|
|
},
|
|
|
|
media: {
|
|
image: oldWord.image || null,
|
|
audio: null,
|
|
icon: this.getIconForCategory(oldWord.category)
|
|
},
|
|
|
|
pedagogy: {
|
|
learningObjective: `Apprendre le mot "${oldWord.english}"`,
|
|
prerequisites: [],
|
|
followUp: [],
|
|
grammarFocus: 'vocabulary'
|
|
},
|
|
|
|
interaction: {
|
|
type: 'click',
|
|
validation: 'exact',
|
|
hints: [oldWord.french],
|
|
feedback: {
|
|
correct: `Bien joué ! "${oldWord.english}" = "${oldWord.french}"`,
|
|
incorrect: `Non, "${oldWord.english}" = "${oldWord.french}"`
|
|
},
|
|
alternatives: [] // Sera rempli dynamiquement
|
|
}
|
|
};
|
|
}
|
|
|
|
migratePhraseItem(oldPhrase, index) {
|
|
return {
|
|
id: `phrase_${index + 1}`,
|
|
type: 'sentence',
|
|
difficulty: 'medium',
|
|
category: oldPhrase.category || 'general',
|
|
|
|
content: {
|
|
english: oldPhrase.english,
|
|
french: oldPhrase.french,
|
|
context: oldPhrase.category || '',
|
|
tags: [oldPhrase.category || 'phrase']
|
|
},
|
|
|
|
media: {
|
|
image: null,
|
|
audio: null,
|
|
icon: '💬'
|
|
},
|
|
|
|
pedagogy: {
|
|
learningObjective: `Comprendre la phrase "${oldPhrase.english}"`,
|
|
prerequisites: [],
|
|
followUp: [],
|
|
grammarFocus: 'sentence_structure'
|
|
},
|
|
|
|
interaction: {
|
|
type: 'click',
|
|
validation: 'exact',
|
|
hints: [oldPhrase.french],
|
|
feedback: {
|
|
correct: `Parfait ! Cette phrase signifie "${oldPhrase.french}"`,
|
|
incorrect: `Cette phrase signifie "${oldPhrase.french}"`
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
inferDifficulty(englishWord) {
|
|
if (englishWord.length <= 4) return 'easy';
|
|
if (englishWord.length <= 8) return 'medium';
|
|
return 'hard';
|
|
}
|
|
|
|
getIconForCategory(category) {
|
|
const icons = {
|
|
family: '👨👩👧👦',
|
|
animals: '🐱',
|
|
colors: '🎨',
|
|
food: '🍎',
|
|
school_objects: '📚',
|
|
daily_activities: '🌅',
|
|
numbers: '🔢'
|
|
};
|
|
return icons[category] || '📝';
|
|
}
|
|
|
|
extractCategories(oldContent) {
|
|
const categories = new Set();
|
|
|
|
if (oldContent.vocabulary) {
|
|
oldContent.vocabulary.forEach(word => {
|
|
if (word.category) categories.add(word.category);
|
|
});
|
|
}
|
|
|
|
if (oldContent.categories) {
|
|
Object.keys(oldContent.categories).forEach(cat => categories.add(cat));
|
|
}
|
|
|
|
return Array.from(categories);
|
|
}
|
|
}
|
|
|
|
// === VALIDATEUR DE CONTENU ===
|
|
|
|
class ContentValidator {
|
|
validate(content) {
|
|
try {
|
|
// Vérifications de base
|
|
if (!content.id || !content.name) {
|
|
logSh('Contenu manque ID ou nom', 'WARN');
|
|
return false;
|
|
}
|
|
|
|
if (!content.contentItems || !Array.isArray(content.contentItems)) {
|
|
logSh('contentItems manquant ou invalide', 'WARN');
|
|
return false;
|
|
}
|
|
|
|
// Valider chaque élément
|
|
for (let item of content.contentItems) {
|
|
if (!this.validateContentItem(item)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
} catch (error) {
|
|
logSh('Erreur validation:', error, 'ERROR');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
validateContentItem(item) {
|
|
// Champs requis
|
|
const requiredFields = ['id', 'type', 'content'];
|
|
|
|
for (let field of requiredFields) {
|
|
if (!item[field]) {
|
|
logSh(`Champ requis manquant: ${field}`, 'WARN');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Valider le contenu selon le type
|
|
switch (item.type) {
|
|
case 'vocabulary':
|
|
return this.validateVocabulary(item);
|
|
case 'sentence':
|
|
return this.validateSentence(item);
|
|
case 'dialogue':
|
|
return this.validateDialogue(item);
|
|
default:
|
|
logSh(`Type de contenu inconnu: ${item.type}`, 'WARN');
|
|
return true; // Permettre types inconnus pour extensibilité
|
|
}
|
|
}
|
|
|
|
validateVocabulary(item) {
|
|
return item.content.english && item.content.french;
|
|
}
|
|
|
|
validateSentence(item) {
|
|
return item.content.english && item.content.french;
|
|
}
|
|
|
|
validateDialogue(item) {
|
|
return item.content.conversation && Array.isArray(item.content.conversation);
|
|
}
|
|
}
|
|
|
|
// === ADAPTATEUR CONTENU/JEU ===
|
|
|
|
class GameContentAdapter {
|
|
constructor(gameType) {
|
|
this.gameType = gameType;
|
|
}
|
|
|
|
adapt(content) {
|
|
switch (this.gameType) {
|
|
case 'whack-a-mole':
|
|
return this.adaptForWhackAMole(content);
|
|
case 'memory-game':
|
|
return this.adaptForMemoryGame(content);
|
|
default:
|
|
return content;
|
|
}
|
|
}
|
|
|
|
adaptForWhackAMole(content) {
|
|
// Convertir vers format attendu par Whack-a-Mole
|
|
const vocabulary = content.contentItems
|
|
.filter(item => item.type === 'vocabulary' || item.type === 'sentence')
|
|
.map(item => ({
|
|
english: item.content.english,
|
|
french: item.content.french,
|
|
image: item.media?.image,
|
|
category: item.category,
|
|
difficulty: item.difficulty,
|
|
interaction: item.interaction
|
|
}));
|
|
|
|
return {
|
|
...content,
|
|
vocabulary: vocabulary,
|
|
// Maintenir la compatibilité
|
|
gameSettings: {
|
|
whackAMole: {
|
|
recommendedWords: Math.min(15, vocabulary.length),
|
|
timeLimit: 60,
|
|
maxErrors: 5
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
adaptForMemoryGame(content) {
|
|
const pairs = content.contentItems
|
|
.filter(item => ['vocabulary', 'sentence'].includes(item.type))
|
|
.map(item => ({
|
|
english: item.content.english,
|
|
french: item.content.french,
|
|
image: item.media?.image,
|
|
type: item.type
|
|
}));
|
|
|
|
return { ...content, pairs: pairs };
|
|
}
|
|
|
|
adaptForTempGames(content) {
|
|
// Format flexible pour les mini-jeux
|
|
return {
|
|
...content,
|
|
vocabulary: content.contentItems.map(item => ({
|
|
english: item.content.english,
|
|
french: item.content.french,
|
|
type: item.type,
|
|
difficulty: item.difficulty
|
|
}))
|
|
};
|
|
}
|
|
}
|
|
|
|
// Export global
|
|
window.ContentEngine = ContentEngine;
|
|
window.ContentMigrator = ContentMigrator;
|
|
window.ContentValidator = ContentValidator;
|
|
window.GameContentAdapter = GameContentAdapter; |