- Complete SPA architecture with dynamic module loading - 9 different educational games (whack-a-mole, memory, quiz, etc.) - Rich content system supporting multimedia (audio, images, video) - Chinese study mode with character recognition - Adaptive game system based on available content - Content types: vocabulary, grammar, poems, fill-blanks, corrections - AI-powered text evaluation for open-ended answers - Flexible content schema with backward compatibility 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
701 lines
22 KiB
JavaScript
701 lines
22 KiB
JavaScript
// === STORY BUILDER GAME - CONSTRUCTEUR D'HISTOIRES ===
|
||
|
||
class StoryBuilderGame {
|
||
constructor(options) {
|
||
this.container = options.container;
|
||
this.content = options.content;
|
||
this.contentEngine = options.contentEngine;
|
||
this.onScoreUpdate = options.onScoreUpdate || (() => {});
|
||
this.onGameEnd = options.onGameEnd || (() => {});
|
||
|
||
// État du jeu
|
||
this.score = 0;
|
||
this.currentStory = [];
|
||
this.availableElements = [];
|
||
this.storyTarget = null;
|
||
this.gameMode = 'sequence'; // 'sequence', 'dialogue', 'scenario'
|
||
|
||
// Configuration
|
||
this.maxElements = 6;
|
||
this.timeLimit = 180; // 3 minutes
|
||
this.timeLeft = this.timeLimit;
|
||
this.isRunning = false;
|
||
|
||
// Timers
|
||
this.gameTimer = null;
|
||
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
this.createGameBoard();
|
||
this.setupEventListeners();
|
||
this.loadStoryContent();
|
||
}
|
||
|
||
createGameBoard() {
|
||
this.container.innerHTML = `
|
||
<div class="story-builder-wrapper">
|
||
<!-- Mode Selection -->
|
||
<div class="mode-selector">
|
||
<button class="mode-btn active" data-mode="sequence">
|
||
📝 Séquence
|
||
</button>
|
||
<button class="mode-btn" data-mode="dialogue">
|
||
💬 Dialogue
|
||
</button>
|
||
<button class="mode-btn" data-mode="scenario">
|
||
🎭 Scénario
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Game Info -->
|
||
<div class="game-info">
|
||
<div class="story-objective" id="story-objective">
|
||
<h3>Objectif:</h3>
|
||
<p id="objective-text">Choisis un mode et commençons !</p>
|
||
</div>
|
||
<div class="game-stats">
|
||
<div class="stat-item">
|
||
<span class="stat-value" id="time-left">${this.timeLeft}</span>
|
||
<span class="stat-label">Temps</span>
|
||
</div>
|
||
<div class="stat-item">
|
||
<span class="stat-value" id="story-progress">0/${this.maxElements}</span>
|
||
<span class="stat-label">Progrès</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Story Construction Area -->
|
||
<div class="story-construction">
|
||
<div class="story-target" id="story-target">
|
||
<!-- Histoire à construire -->
|
||
</div>
|
||
|
||
<div class="drop-zone" id="drop-zone">
|
||
<div class="drop-hint">Glisse les éléments ici pour construire ton histoire</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Available Elements -->
|
||
<div class="elements-bank" id="elements-bank">
|
||
<!-- Éléments disponibles -->
|
||
</div>
|
||
|
||
<!-- Game Controls -->
|
||
<div class="game-controls">
|
||
<button class="control-btn" id="start-btn">🎮 Commencer</button>
|
||
<button class="control-btn" id="check-btn" disabled>✅ Vérifier</button>
|
||
<button class="control-btn" id="hint-btn" disabled>💡 Indice</button>
|
||
<button class="control-btn" id="restart-btn">🔄 Recommencer</button>
|
||
</div>
|
||
|
||
<!-- Feedback Area -->
|
||
<div class="feedback-area" id="feedback-area">
|
||
<div class="instruction">
|
||
Sélectionne un mode pour commencer à construire des histoires !
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
setupEventListeners() {
|
||
// Mode selection
|
||
document.querySelectorAll('.mode-btn').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
if (this.isRunning) return;
|
||
|
||
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
this.gameMode = btn.dataset.mode;
|
||
|
||
this.loadStoryContent();
|
||
});
|
||
});
|
||
|
||
// Game controls
|
||
document.getElementById('start-btn').addEventListener('click', () => this.start());
|
||
document.getElementById('check-btn').addEventListener('click', () => this.checkStory());
|
||
document.getElementById('hint-btn').addEventListener('click', () => this.showHint());
|
||
document.getElementById('restart-btn').addEventListener('click', () => this.restart());
|
||
|
||
// Drag and Drop setup
|
||
this.setupDragAndDrop();
|
||
}
|
||
|
||
loadStoryContent() {
|
||
if (!this.contentEngine) {
|
||
console.warn('ContentEngine non disponible, utilisation du contenu de base');
|
||
this.setupBasicContent();
|
||
return;
|
||
}
|
||
|
||
// Filtrer le contenu selon le mode
|
||
const filters = this.getModeFilters();
|
||
const filteredContent = this.contentEngine.filterContent(this.content, filters);
|
||
|
||
this.setupContentForMode(filteredContent);
|
||
}
|
||
|
||
getModeFilters() {
|
||
switch (this.gameMode) {
|
||
case 'sequence':
|
||
return { type: ['sequence', 'vocabulary'] };
|
||
case 'dialogue':
|
||
return { type: ['dialogue', 'sentence'] };
|
||
case 'scenario':
|
||
return { type: ['scenario', 'dialogue', 'sequence'] };
|
||
default:
|
||
return { type: ['vocabulary', 'sentence'] };
|
||
}
|
||
}
|
||
|
||
setupContentForMode(filteredContent) {
|
||
const contentItems = filteredContent.contentItems || [];
|
||
|
||
switch (this.gameMode) {
|
||
case 'sequence':
|
||
this.setupSequenceMode(contentItems);
|
||
break;
|
||
case 'dialogue':
|
||
this.setupDialogueMode(contentItems);
|
||
break;
|
||
case 'scenario':
|
||
this.setupScenarioMode(contentItems);
|
||
break;
|
||
}
|
||
}
|
||
|
||
setupSequenceMode(contentItems) {
|
||
const sequences = contentItems.filter(item => item.type === 'sequence');
|
||
|
||
if (sequences.length > 0) {
|
||
this.storyTarget = sequences[Math.floor(Math.random() * sequences.length)];
|
||
this.availableElements = this.shuffleArray([...this.storyTarget.content.steps]);
|
||
|
||
document.getElementById('objective-text').textContent =
|
||
`Remets en ordre l'histoire: "${this.storyTarget.content.title}"`;
|
||
} else {
|
||
this.setupBasicSequence();
|
||
}
|
||
}
|
||
|
||
setupDialogueMode(contentItems) {
|
||
const dialogues = contentItems.filter(item => item.type === 'dialogue');
|
||
|
||
if (dialogues.length > 0) {
|
||
this.storyTarget = dialogues[Math.floor(Math.random() * dialogues.length)];
|
||
this.availableElements = this.shuffleArray([...this.storyTarget.content.conversation]);
|
||
|
||
document.getElementById('objective-text').textContent =
|
||
`Reconstitue le dialogue: "${this.storyTarget.content.english}"`;
|
||
} else {
|
||
this.setupBasicDialogue();
|
||
}
|
||
}
|
||
|
||
setupScenarioMode(contentItems) {
|
||
const scenarios = contentItems.filter(item => item.type === 'scenario');
|
||
|
||
if (scenarios.length > 0) {
|
||
this.storyTarget = scenarios[Math.floor(Math.random() * scenarios.length)];
|
||
// Mélanger vocabulaire et phrases du scénario
|
||
const vocabElements = this.storyTarget.content.vocabulary || [];
|
||
const phraseElements = this.storyTarget.content.phrases || [];
|
||
|
||
this.availableElements = this.shuffleArray([...vocabElements, ...phraseElements]);
|
||
|
||
document.getElementById('objective-text').textContent =
|
||
`Crée une histoire dans le contexte: "${this.storyTarget.content.english}"`;
|
||
} else {
|
||
this.setupBasicScenario();
|
||
}
|
||
}
|
||
|
||
setupBasicContent() {
|
||
// Fallback pour l'ancien format
|
||
const vocabulary = this.content.vocabulary || [];
|
||
this.availableElements = vocabulary.slice(0, 6);
|
||
this.gameMode = 'vocabulary';
|
||
|
||
document.getElementById('objective-text').textContent =
|
||
'Construis une histoire avec ces mots !';
|
||
}
|
||
|
||
start() {
|
||
if (this.isRunning || this.availableElements.length === 0) return;
|
||
|
||
this.isRunning = true;
|
||
this.score = 0;
|
||
this.currentStory = [];
|
||
this.timeLeft = this.timeLimit;
|
||
|
||
this.renderElements();
|
||
this.startTimer();
|
||
this.updateUI();
|
||
|
||
document.getElementById('start-btn').disabled = true;
|
||
document.getElementById('check-btn').disabled = false;
|
||
document.getElementById('hint-btn').disabled = false;
|
||
|
||
this.showFeedback('Glisse les éléments dans l\'ordre pour construire ton histoire !', 'info');
|
||
}
|
||
|
||
renderElements() {
|
||
const elementsBank = document.getElementById('elements-bank');
|
||
elementsBank.innerHTML = '<h4>Éléments disponibles:</h4>';
|
||
|
||
this.availableElements.forEach((element, index) => {
|
||
const elementDiv = this.createElement(element, index);
|
||
elementsBank.appendChild(elementDiv);
|
||
});
|
||
}
|
||
|
||
createElement(element, index) {
|
||
const div = document.createElement('div');
|
||
div.className = 'story-element';
|
||
div.draggable = true;
|
||
div.dataset.index = index;
|
||
|
||
// Adapter l'affichage selon le type d'élément
|
||
if (element.english && element.french) {
|
||
// Vocabulaire ou phrase
|
||
div.innerHTML = `
|
||
<div class="element-content">
|
||
<div class="english">${element.english}</div>
|
||
<div class="french">${element.french}</div>
|
||
</div>
|
||
`;
|
||
} else if (element.text || element.english) {
|
||
// Dialogue ou séquence
|
||
div.innerHTML = `
|
||
<div class="element-content">
|
||
<div class="english">${element.text || element.english}</div>
|
||
${element.french ? `<div class="french">${element.french}</div>` : ''}
|
||
</div>
|
||
`;
|
||
} else if (typeof element === 'string') {
|
||
// Texte simple
|
||
div.innerHTML = `<div class="element-content">${element}</div>`;
|
||
}
|
||
|
||
if (element.icon) {
|
||
div.innerHTML = `<span class="element-icon">${element.icon}</span>` + div.innerHTML;
|
||
}
|
||
|
||
return div;
|
||
}
|
||
|
||
setupDragAndDrop() {
|
||
let draggedElement = null;
|
||
|
||
document.addEventListener('dragstart', (e) => {
|
||
if (e.target.classList.contains('story-element')) {
|
||
draggedElement = e.target;
|
||
e.target.style.opacity = '0.5';
|
||
}
|
||
});
|
||
|
||
document.addEventListener('dragend', (e) => {
|
||
if (e.target.classList.contains('story-element')) {
|
||
e.target.style.opacity = '1';
|
||
draggedElement = null;
|
||
}
|
||
});
|
||
|
||
const dropZone = document.getElementById('drop-zone');
|
||
|
||
dropZone.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
dropZone.classList.add('drag-over');
|
||
});
|
||
|
||
dropZone.addEventListener('dragleave', () => {
|
||
dropZone.classList.remove('drag-over');
|
||
});
|
||
|
||
dropZone.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
dropZone.classList.remove('drag-over');
|
||
|
||
if (draggedElement && this.isRunning) {
|
||
this.addToStory(draggedElement);
|
||
}
|
||
});
|
||
}
|
||
|
||
addToStory(elementDiv) {
|
||
const index = parseInt(elementDiv.dataset.index);
|
||
const element = this.availableElements[index];
|
||
|
||
// Ajouter à l'histoire
|
||
this.currentStory.push({ element, originalIndex: index });
|
||
|
||
// Créer élément dans la zone de construction
|
||
const storyElement = elementDiv.cloneNode(true);
|
||
storyElement.classList.add('in-story');
|
||
storyElement.draggable = false;
|
||
|
||
// Ajouter bouton de suppression
|
||
const removeBtn = document.createElement('button');
|
||
removeBtn.className = 'remove-element';
|
||
removeBtn.innerHTML = '×';
|
||
removeBtn.onclick = () => this.removeFromStory(storyElement, element);
|
||
storyElement.appendChild(removeBtn);
|
||
|
||
document.getElementById('drop-zone').appendChild(storyElement);
|
||
|
||
// Masquer l'élément original
|
||
elementDiv.style.display = 'none';
|
||
|
||
this.updateProgress();
|
||
}
|
||
|
||
removeFromStory(storyElement, element) {
|
||
// Supprimer de l'histoire
|
||
this.currentStory = this.currentStory.filter(item => item.element !== element);
|
||
|
||
// Supprimer visuellement
|
||
storyElement.remove();
|
||
|
||
// Réafficher l'élément original
|
||
const originalElement = document.querySelector(`[data-index="${this.availableElements.indexOf(element)}"]`);
|
||
if (originalElement) {
|
||
originalElement.style.display = 'block';
|
||
}
|
||
|
||
this.updateProgress();
|
||
}
|
||
|
||
checkStory() {
|
||
if (this.currentStory.length === 0) {
|
||
this.showFeedback('Ajoute au moins un élément à ton histoire !', 'error');
|
||
return;
|
||
}
|
||
|
||
const isCorrect = this.validateStory();
|
||
|
||
if (isCorrect) {
|
||
this.score += this.currentStory.length * 10;
|
||
this.showFeedback('Bravo ! Histoire parfaite ! 🎉', 'success');
|
||
this.onScoreUpdate(this.score);
|
||
|
||
setTimeout(() => {
|
||
this.nextChallenge();
|
||
}, 2000);
|
||
} else {
|
||
this.score = Math.max(0, this.score - 5);
|
||
this.showFeedback('Presque ! Vérifie l\'ordre de ton histoire 🤔', 'warning');
|
||
this.onScoreUpdate(this.score);
|
||
}
|
||
}
|
||
|
||
validateStory() {
|
||
switch (this.gameMode) {
|
||
case 'sequence':
|
||
return this.validateSequence();
|
||
case 'dialogue':
|
||
return this.validateDialogue();
|
||
case 'scenario':
|
||
return this.validateScenario();
|
||
default:
|
||
return true; // Mode libre
|
||
}
|
||
}
|
||
|
||
validateSequence() {
|
||
if (!this.storyTarget?.content?.steps) return true;
|
||
|
||
const expectedOrder = this.storyTarget.content.steps.sort((a, b) => a.order - b.order);
|
||
|
||
if (this.currentStory.length !== expectedOrder.length) return false;
|
||
|
||
return this.currentStory.every((item, index) => {
|
||
const expected = expectedOrder[index];
|
||
return item.element.order === expected.order;
|
||
});
|
||
}
|
||
|
||
validateDialogue() {
|
||
// Validation flexible du dialogue (ordre logique des répliques)
|
||
return this.currentStory.length >= 2;
|
||
}
|
||
|
||
validateScenario() {
|
||
// Validation flexible du scénario (cohérence contextuelle)
|
||
return this.currentStory.length >= 3;
|
||
}
|
||
|
||
showHint() {
|
||
if (!this.storyTarget) {
|
||
this.showFeedback('Astuce : Pense à l\'ordre logique des événements !', 'info');
|
||
return;
|
||
}
|
||
|
||
switch (this.gameMode) {
|
||
case 'sequence':
|
||
if (this.storyTarget.content?.steps) {
|
||
const nextStep = this.storyTarget.content.steps.find(step =>
|
||
!this.currentStory.some(item => item.element.order === step.order)
|
||
);
|
||
if (nextStep) {
|
||
this.showFeedback(`Prochaine étape : "${nextStep.english}"`, 'info');
|
||
}
|
||
}
|
||
break;
|
||
case 'dialogue':
|
||
this.showFeedback('Pense à l\'ordre naturel d\'une conversation !', 'info');
|
||
break;
|
||
case 'scenario':
|
||
this.showFeedback('Crée une histoire cohérente dans ce contexte !', 'info');
|
||
break;
|
||
}
|
||
}
|
||
|
||
nextChallenge() {
|
||
// Charger un nouveau défi
|
||
this.loadStoryContent();
|
||
this.currentStory = [];
|
||
document.getElementById('drop-zone').innerHTML = '<div class="drop-hint">Glisse les éléments ici pour construire ton histoire</div>';
|
||
this.renderElements();
|
||
this.updateProgress();
|
||
}
|
||
|
||
startTimer() {
|
||
this.gameTimer = setInterval(() => {
|
||
this.timeLeft--;
|
||
this.updateUI();
|
||
|
||
if (this.timeLeft <= 0) {
|
||
this.endGame();
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
endGame() {
|
||
this.isRunning = false;
|
||
if (this.gameTimer) {
|
||
clearInterval(this.gameTimer);
|
||
this.gameTimer = null;
|
||
}
|
||
|
||
document.getElementById('start-btn').disabled = false;
|
||
document.getElementById('check-btn').disabled = true;
|
||
document.getElementById('hint-btn').disabled = true;
|
||
|
||
this.onGameEnd(this.score);
|
||
}
|
||
|
||
restart() {
|
||
this.endGame();
|
||
this.score = 0;
|
||
this.currentStory = [];
|
||
this.timeLeft = this.timeLimit;
|
||
this.onScoreUpdate(0);
|
||
|
||
document.getElementById('drop-zone').innerHTML = '<div class="drop-hint">Glisse les éléments ici pour construire ton histoire</div>';
|
||
this.loadStoryContent();
|
||
this.updateUI();
|
||
}
|
||
|
||
updateProgress() {
|
||
document.getElementById('story-progress').textContent =
|
||
`${this.currentStory.length}/${this.maxElements}`;
|
||
}
|
||
|
||
updateUI() {
|
||
document.getElementById('time-left').textContent = this.timeLeft;
|
||
}
|
||
|
||
showFeedback(message, type = 'info') {
|
||
const feedbackArea = document.getElementById('feedback-area');
|
||
feedbackArea.innerHTML = `<div class="instruction ${type}">${message}</div>`;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
destroy() {
|
||
this.endGame();
|
||
this.container.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
// CSS pour Story Builder
|
||
const storyBuilderStyles = `
|
||
<style>
|
||
.story-builder-wrapper {
|
||
max-width: 900px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.story-construction {
|
||
background: #f8f9fa;
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
margin: 20px 0;
|
||
}
|
||
|
||
.story-target {
|
||
background: white;
|
||
border-radius: 8px;
|
||
padding: 15px;
|
||
margin-bottom: 15px;
|
||
border-left: 4px solid var(--primary-color);
|
||
}
|
||
|
||
.drop-zone {
|
||
min-height: 120px;
|
||
border: 3px dashed #ddd;
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
text-align: center;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.drop-zone.drag-over {
|
||
border-color: var(--primary-color);
|
||
background: rgba(59, 130, 246, 0.1);
|
||
}
|
||
|
||
.drop-hint {
|
||
color: #6b7280;
|
||
font-style: italic;
|
||
}
|
||
|
||
.elements-bank {
|
||
background: white;
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
margin: 20px 0;
|
||
border: 2px solid #e5e7eb;
|
||
}
|
||
|
||
.elements-bank h4 {
|
||
margin-bottom: 15px;
|
||
color: var(--primary-color);
|
||
}
|
||
|
||
.story-element {
|
||
display: inline-block;
|
||
background: white;
|
||
border: 2px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
margin: 8px;
|
||
cursor: grab;
|
||
transition: all 0.3s ease;
|
||
position: relative;
|
||
min-width: 150px;
|
||
}
|
||
|
||
.story-element:hover {
|
||
border-color: var(--primary-color);
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||
}
|
||
|
||
.story-element:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.story-element.in-story {
|
||
background: var(--secondary-color);
|
||
color: white;
|
||
border-color: var(--secondary-color);
|
||
cursor: default;
|
||
margin: 5px;
|
||
}
|
||
|
||
.element-content {
|
||
text-align: center;
|
||
}
|
||
|
||
.element-icon {
|
||
font-size: 1.5rem;
|
||
display: block;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.english {
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.french {
|
||
font-size: 0.9rem;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.story-element.in-story .french {
|
||
color: rgba(255,255,255,0.8);
|
||
}
|
||
|
||
.remove-element {
|
||
position: absolute;
|
||
top: -5px;
|
||
right: -5px;
|
||
width: 20px;
|
||
height: 20px;
|
||
background: var(--error-color);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 50%;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.story-objective {
|
||
background: linear-gradient(135deg, #f0f9ff, #dbeafe);
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
margin-bottom: 20px;
|
||
border-left: 4px solid var(--primary-color);
|
||
}
|
||
|
||
.story-objective h3 {
|
||
color: var(--primary-color);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.story-element {
|
||
min-width: 120px;
|
||
padding: 8px;
|
||
margin: 5px;
|
||
}
|
||
|
||
.drop-zone {
|
||
min-height: 100px;
|
||
padding: 15px;
|
||
}
|
||
|
||
.elements-bank {
|
||
padding: 15px;
|
||
}
|
||
}
|
||
</style>
|
||
`;
|
||
|
||
// Ajouter les styles
|
||
document.head.insertAdjacentHTML('beforeend', storyBuilderStyles);
|
||
|
||
// Enregistrement du module
|
||
window.GameModules = window.GameModules || {};
|
||
window.GameModules.StoryBuilder = StoryBuilderGame; |