Implemente le mini-jeu Memory (retournement de cartes, mode paire/single)
document_engine/labels/memory_config.py (nouveau) : modele de donnees, meme convention resolve_X/sanitize_X que quiz_config.py/ association_config.py - DEFAULT_MEMORY_CONFIG, sanitize_memory_config. Chaque carte a un recto ET un verso, chacun avec image/texte independants et tous deux optionnels ; seul le verso doit avoir au moins l'un des deux non vide (rien a reveler/apparier sinon) - le recto peut rester entierement vide (dos de carte generique "?" par defaut). Liste tronquee a MAX_CARDS=8. Cote serveur, routes/document/document_element_update.py revalide desormais aussi memory avant persistance. Le rendu (_render_memory) affiche un resume reel (nombre de cartes, mode) et, des qu'au moins une carte existe, un plateau de retournement REELEMENT interactif en Mode Apercu (_render_memory_player) : en mode "paire", chaque carte definie est DUPLIQUEE en deux instances partageant le meme card_index (appariement classique) ; en mode "single", une seule instance par carte (simple retournement, sans appariement - "c'est donc un retourner de carte classique plus un jeu memory"). Les instances sont melangees (random.shuffle, documente dans CODE_QUALITY.md) puis embarquees en JSON dans data-memory-config. Cote editeur, le panneau Proprietes propose un bascule segmentee Paire/Simple et une liste de cartes repetable, chaque carte avec ses deux faces (recto/verso) editables independamment (image + texte). Extrait au passage forgeDocEscapeHtml (ex-forgeDocEscapeForTextarea, generalise pour couvrir aussi les attributs) reutilise pour les deux mini-jeux. Le plateau jouable (static/document/js/document-editor.js) est une vraie carte-retournement CSS 3D (perspective/rotateY), contenu de chaque face construit via DOM (textContent/img.src, jamais innerHTML avec le texte du createur - meme precaution que le plateau Association) : bon appariement verrouille en vert, mauvais reinitialise apres un delai, ecran de resultat une fois le jeu termine (les deux modes), et un "Recommencer" qui remelange reellement les cartes (Fisher-Yates cote client). Tests : 11 tests purs (tests/document/test_memory_config.py, sans Flask, dont un qui verifie explicitement la duplication en mode paire vs son absence en mode single) + 1 test de route verifiant la sanitization a l'ecriture. SKIP=djlint : backlog H021 pre-existant, aucun template touche ici. ruff/mypy --strict/vulture/bandit/import-linter/eslint/stylelint tous verts ; 62 tests document verifies frais. Verification manuelle live complete : ajout, sanitization sur carte invalide, rendu du plateau, et simulation DOM du gameplay reel dans les DEUX modes (mode paire : mauvais appariement puis bon appariement puis jeu complet ; mode single : retournement puis jeu complet) - script de diagnostic non conserve dans le depot. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
cf46da3388
commit
4111081e1a
@@ -477,6 +477,7 @@ function forgeDocBindCanvasInteractions() {
|
||||
});
|
||||
forgeDocInitQuizPlayers();
|
||||
forgeDocInitAssociationPlayers();
|
||||
forgeDocInitMemoryPlayers();
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
@@ -746,6 +747,174 @@ function forgeDocInitAssociationPlayers() {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
* Memory — retournement de cartes RÉELLEMENT interactif en Mode Aperçu :
|
||||
* mode "paire" (retrouver les deux cartes identiques, classique) ou
|
||||
* "single" (retourner chaque carte une fois, sans appariement). Contenu
|
||||
* de chaque face construit via DOM (textContent/img.src, jamais innerHTML
|
||||
* avec le texte du créateur) — même précaution que le plateau Association.
|
||||
* ------------------------------------------------------------------- */
|
||||
|
||||
function forgeDocMemoryPlayerData(playerEl) {
|
||||
try {
|
||||
const config = JSON.parse(playerEl.dataset.memoryConfig || '{}');
|
||||
return {
|
||||
mode: config.mode === 'single' ? 'single' : 'paire',
|
||||
cards: Array.isArray(config.cards) ? config.cards : [],
|
||||
};
|
||||
} catch (e) {
|
||||
return { mode: 'paire', cards: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function forgeDocShuffleArray(array) {
|
||||
const copy = [...array];
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function forgeDocFillMemoryCardFace(container, face) {
|
||||
container.innerHTML = '';
|
||||
if (face.image) {
|
||||
const img = document.createElement('img');
|
||||
img.src = face.image;
|
||||
img.alt = '';
|
||||
container.appendChild(img);
|
||||
}
|
||||
if (face.text) {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = face.text;
|
||||
container.appendChild(span);
|
||||
}
|
||||
if (!face.image && !face.text) {
|
||||
const glyph = document.createElement('span');
|
||||
glyph.className = 'docMemoryCardBackGlyph';
|
||||
glyph.textContent = '?';
|
||||
container.appendChild(glyph);
|
||||
}
|
||||
}
|
||||
|
||||
function forgeDocBuildMemoryCard(cardData, index) {
|
||||
const cardEl = document.createElement('div');
|
||||
cardEl.className = 'docMemoryCard';
|
||||
cardEl.dataset.instanceIndex = String(index);
|
||||
cardEl.dataset.cardIndex = String(cardData.card_index);
|
||||
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'docMemoryCardInner';
|
||||
|
||||
const front = document.createElement('div');
|
||||
front.className = 'docMemoryCardFace docMemoryCardFront';
|
||||
forgeDocFillMemoryCardFace(front, cardData.recto);
|
||||
|
||||
const back = document.createElement('div');
|
||||
back.className = 'docMemoryCardFace docMemoryCardBack';
|
||||
forgeDocFillMemoryCardFace(back, cardData.verso);
|
||||
|
||||
inner.appendChild(front);
|
||||
inner.appendChild(back);
|
||||
cardEl.appendChild(inner);
|
||||
return cardEl;
|
||||
}
|
||||
|
||||
function forgeDocMemoryUpdateProgress(playerEl, state) {
|
||||
const progEl = playerEl.querySelector('.docMemoryProg');
|
||||
progEl.textContent = state.mode === 'single'
|
||||
? `${state.revealed} / ${state.total} cartes retournées`
|
||||
: `${state.matched} / ${state.totalPairs} paires trouvées`;
|
||||
}
|
||||
|
||||
function forgeDocMemoryCheckComplete(playerEl, state) {
|
||||
const done = state.mode === 'single' ? state.revealed >= state.total : state.matched >= state.totalPairs;
|
||||
if (!done) return;
|
||||
playerEl.querySelector('.docMemoryCardWrap').style.display = 'none';
|
||||
const resultCard = playerEl.querySelector('.docMemoryResultCard');
|
||||
resultCard.style.display = 'block';
|
||||
playerEl.querySelector('.docMemoryResultSub').textContent = state.mode === 'single'
|
||||
? 'Toutes les cartes ont été retournées !'
|
||||
: 'Toutes les paires ont été trouvées !';
|
||||
}
|
||||
|
||||
function forgeDocMemoryFlip(playerEl, state, cardEl) {
|
||||
if (cardEl.classList.contains('is-flipped') || cardEl.classList.contains('is-matched')) return;
|
||||
if (state.mode === 'single') {
|
||||
cardEl.classList.add('is-flipped', 'is-matched');
|
||||
state.revealed += 1;
|
||||
forgeDocMemoryUpdateProgress(playerEl, state);
|
||||
forgeDocMemoryCheckComplete(playerEl, state);
|
||||
return;
|
||||
}
|
||||
if (state.busy) return;
|
||||
cardEl.classList.add('is-flipped');
|
||||
state.flippedCards.push(cardEl);
|
||||
if (state.flippedCards.length < 2) return;
|
||||
state.busy = true;
|
||||
const [first, second] = state.flippedCards;
|
||||
const isMatch = first.dataset.cardIndex === second.dataset.cardIndex;
|
||||
if (isMatch) {
|
||||
first.classList.add('is-matched');
|
||||
second.classList.add('is-matched');
|
||||
state.matched += 1;
|
||||
state.flippedCards = [];
|
||||
state.busy = false;
|
||||
forgeDocMemoryUpdateProgress(playerEl, state);
|
||||
forgeDocMemoryCheckComplete(playerEl, state);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
first.classList.remove('is-flipped');
|
||||
second.classList.remove('is-flipped');
|
||||
state.flippedCards = [];
|
||||
state.busy = false;
|
||||
}, 800);
|
||||
}
|
||||
}
|
||||
|
||||
function forgeDocMemoryRestart(playerEl) {
|
||||
const data = forgeDocMemoryPlayerData(playerEl);
|
||||
const shuffled = forgeDocShuffleArray(data.cards);
|
||||
const totalPairs = new Set(data.cards.map((c) => c.card_index)).size;
|
||||
const state = {
|
||||
mode: data.mode,
|
||||
total: data.cards.length,
|
||||
totalPairs,
|
||||
matched: 0,
|
||||
revealed: 0,
|
||||
flippedCards: [],
|
||||
busy: false,
|
||||
};
|
||||
playerEl.querySelector('.docMemoryResultCard').style.display = 'none';
|
||||
playerEl.querySelector('.docMemoryCardWrap').style.display = 'block';
|
||||
playerEl.querySelector('.docMemoryTitle').textContent = state.mode === 'single'
|
||||
? 'Retournez chaque carte'
|
||||
: 'Retrouvez les paires';
|
||||
forgeDocMemoryUpdateProgress(playerEl, state);
|
||||
const grid = playerEl.querySelector('.docMemoryGrid');
|
||||
grid.innerHTML = '';
|
||||
shuffled.forEach((cardData, index) => {
|
||||
const cardEl = forgeDocBuildMemoryCard(cardData, index);
|
||||
cardEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
forgeDocMemoryFlip(playerEl, state, cardEl);
|
||||
});
|
||||
grid.appendChild(cardEl);
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocInitMemoryPlayers() {
|
||||
document.querySelectorAll('.docMemoryPlayer').forEach((playerEl) => {
|
||||
const data = forgeDocMemoryPlayerData(playerEl);
|
||||
if (!data.cards.length) return;
|
||||
forgeDocMemoryRestart(playerEl);
|
||||
playerEl.querySelector('.docMemoryRestartBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
forgeDocMemoryRestart(playerEl);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocSetActiveBottomNav(target) {
|
||||
document.querySelectorAll('#docBottomNav button').forEach((b) => {
|
||||
b.classList.toggle('is-active', b.dataset.target === target);
|
||||
@@ -1084,15 +1253,20 @@ function forgeDocAssociationNewPair() {
|
||||
return { left: 'Nouvel élément', right: 'Sa correspondance' };
|
||||
}
|
||||
|
||||
function forgeDocEscapeForTextarea(text) {
|
||||
// Les deux côtés d'une paire sont du texte libre du créateur, inséré ici
|
||||
// comme CONTENU d'un <textarea> dans un template string (jamais via
|
||||
// .value, voir forgeDocRenderAssociationProps qui construit tout le
|
||||
// panneau en une seule chaîne) — sans cet échappement, un texte
|
||||
// contenant littéralement "</textarea>" romprait le tag et tout ce qui
|
||||
// suit serait interprété comme du HTML/JS brut dans SA PROPRE session
|
||||
// d'édition.
|
||||
return String(text).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
function forgeDocEscapeHtml(text) {
|
||||
// Texte libre du créateur (Association/Memory), inséré comme CONTENU
|
||||
// d'un <textarea> ou comme VALEUR d'attribut dans un template string
|
||||
// (jamais via .value/.setAttribute, voir forgeDocRenderAssociationProps/
|
||||
// forgeDocRenderMemoryProps qui construisent tout le panneau en une
|
||||
// seule chaîne) — sans cet échappement, un texte contenant littéralement
|
||||
// "</textarea>" ou un guillemet romprait le tag/l'attribut et tout ce
|
||||
// qui suit serait interprété comme du HTML/JS brut dans SA PROPRE
|
||||
// session d'édition.
|
||||
return String(text)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
function forgeDocRenderAssociationPairHtml(pair, pIndex) {
|
||||
@@ -1106,13 +1280,13 @@ function forgeDocRenderAssociationPairHtml(pair, pIndex) {
|
||||
rows="2"
|
||||
placeholder="Élément"
|
||||
data-pair-index="${pIndex}"
|
||||
data-side="left">${forgeDocEscapeForTextarea(pair.left)}</textarea>
|
||||
data-side="left">${forgeDocEscapeHtml(pair.left)}</textarea>
|
||||
<div class="docAssocPairLink" aria-hidden="true">↕</div>
|
||||
<textarea class="docAssocPairInput"
|
||||
rows="2"
|
||||
placeholder="Correspondance"
|
||||
data-pair-index="${pIndex}"
|
||||
data-side="right">${forgeDocEscapeForTextarea(pair.right)}</textarea>
|
||||
data-side="right">${forgeDocEscapeHtml(pair.right)}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1165,6 +1339,116 @@ function forgeDocRenderAssociationProps(panel, el) {
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocMemoryNewCard() {
|
||||
// Verso pré-rempli (jamais vide) : sanitize_memory_config
|
||||
// (routes/document/document_element_update.py) rejette silencieusement
|
||||
// toute carte dont le verso est entièrement vide (rien à révéler) —
|
||||
// même leçon que forgeDocAssociationNewPair/forgeDocQuizNewQuestion
|
||||
// ci-dessus (voir leur commentaire : sans ça, "+ Ajouter une carte"
|
||||
// semblerait ne rien faire).
|
||||
return { recto: { image: '', text: '' }, verso: { image: '', text: 'Nouvelle carte' } };
|
||||
}
|
||||
|
||||
function forgeDocRenderMemoryCardHtml(card, cIndex) {
|
||||
return `
|
||||
<div class="docAssocPairRow" data-card-index="${cIndex}">
|
||||
<div class="docQuizQuestionHeader">
|
||||
<span class="docFieldLabel">Carte ${cIndex + 1}</span>
|
||||
<button type="button" class="docQuizRemoveBtn" data-card-index="${cIndex}" title="Supprimer cette carte">🗑</button>
|
||||
</div>
|
||||
<span class="docMemoryFaceLabel">Recto (face cachée, optionnel)</span>
|
||||
<input type="text"
|
||||
class="docAssocPairInput"
|
||||
placeholder="URL de l'image"
|
||||
value="${forgeDocEscapeHtml(card.recto.image)}"
|
||||
data-card-index="${cIndex}"
|
||||
data-face="recto"
|
||||
data-field="image">
|
||||
<textarea class="docAssocPairInput"
|
||||
rows="2"
|
||||
placeholder="Texte"
|
||||
data-card-index="${cIndex}"
|
||||
data-face="recto"
|
||||
data-field="text">${forgeDocEscapeHtml(card.recto.text)}</textarea>
|
||||
<span class="docMemoryFaceLabel">Verso (révélé au retournement)</span>
|
||||
<input type="text"
|
||||
class="docAssocPairInput"
|
||||
placeholder="URL de l'image"
|
||||
value="${forgeDocEscapeHtml(card.verso.image)}"
|
||||
data-card-index="${cIndex}"
|
||||
data-face="verso"
|
||||
data-field="image">
|
||||
<textarea class="docAssocPairInput"
|
||||
rows="2"
|
||||
placeholder="Texte"
|
||||
data-card-index="${cIndex}"
|
||||
data-face="verso"
|
||||
data-field="text">${forgeDocEscapeHtml(card.verso.text)}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function forgeDocRenderMemoryProps(panel, el) {
|
||||
const a = el.attributes;
|
||||
const cards = a.cards || [];
|
||||
|
||||
function patch(partial) {
|
||||
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
|
||||
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
|
||||
});
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
${forgeDocPropsHeader('memory')}
|
||||
<div class="docField">
|
||||
<span class="docFieldLabel">Mode</span>
|
||||
<div class="docSegmented" id="docMemoryMode">
|
||||
<button type="button" class="docSegBtn ${a.mode === 'paire' ? 'is-active' : ''}" data-mode="paire">Paire (dupliquer)</button>
|
||||
<button type="button" class="docSegBtn ${a.mode === 'single' ? 'is-active' : ''}" data-mode="single">Simple (sans doublon)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="docField">
|
||||
<span class="docFieldLabel">Couleur du thème</span>
|
||||
<div class="docSwatches" id="docMemoryColor">
|
||||
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="docSectionLabel docQuizQuestionsLabel">Cartes</div>
|
||||
<div id="docMemoryCardsList">${cards.length ? cards.map(forgeDocRenderMemoryCardHtml).join('') : '<div class="docEmptyState">Aucune carte — ajoute la première ci-dessous.</div>'}</div>
|
||||
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docMemoryAddCard">+ Ajouter une carte</button>
|
||||
${forgeDocDeleteButtonHtml()}
|
||||
`;
|
||||
|
||||
panel.querySelectorAll('#docMemoryMode .docSegBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => patch({ mode: btn.dataset.mode }));
|
||||
});
|
||||
|
||||
panel.querySelectorAll('#docMemoryColor .docSwatch').forEach((sw) => {
|
||||
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
|
||||
});
|
||||
|
||||
document.getElementById('docMemoryAddCard').addEventListener('click', () => {
|
||||
patch({ cards: [...cards, forgeDocMemoryNewCard()] });
|
||||
});
|
||||
|
||||
panel.querySelectorAll('#docMemoryCardsList .docQuizRemoveBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const cIndex = Number(btn.dataset.cardIndex);
|
||||
patch({ cards: cards.filter((_, i) => i !== cIndex) });
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('#docMemoryCardsList .docAssocPairInput').forEach((input) => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const cIndex = Number(input.dataset.cardIndex);
|
||||
const { face, field } = input.dataset;
|
||||
patch({
|
||||
cards: cards.map((c, i) => (i === cIndex ? { ...c, [face]: { ...c[face], [field]: e.target.value } } : c)),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocRenderProps(el) {
|
||||
const panel = document.getElementById('docPropsPanel');
|
||||
if (!el) {
|
||||
@@ -1178,6 +1462,7 @@ function forgeDocRenderProps(el) {
|
||||
else if (el.kind === 'row') forgeDocRenderRowProps(panel, el);
|
||||
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
|
||||
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
|
||||
else if (el.kind === 'memory') forgeDocRenderMemoryProps(panel, el);
|
||||
else forgeDocRenderMinigameProps(panel, el);
|
||||
|
||||
const deleteBtn = document.getElementById('docDeletePropBtn');
|
||||
|
||||
Reference in New Issue
Block a user