Implemente le mini-jeu Mots meles (grille reelle, placement 4 directions)
Build and deploy / test-python (push) Failing after 1m0s
Build and deploy / test-js (push) Failing after 50s
Build and deploy / lint-python (push) Failing after 1m2s
Build and deploy / lint-js (push) Failing after 1m7s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m1s

Quatrieme mini-jeu du support de formation : le createur ecrit 5 a 10
mots (recommandation souple, comme MIN_PAIRS/MIN_CARDS), places par le
serveur dans une grille carree horizontalement, verticalement, ou en
diagonale (deux sens seulement, jamais a l'envers) via un vrai
algorithme de placement avec retry/agrandissement de grille en cas de
conflit. La grille ET la position exacte de chaque mot sont calculees
cote serveur puis embarquees en JSON ; le client valide chaque
selection (glisser ou cliquer-cliquer) par comparaison de coordonnees
exactes, jamais une simple comparaison de texte (qui se tromperait sur
des lettres partagees entre deux mots qui se croisent). Comme
Association/Memory, la grille reste affichee une fois tous les mots
trouves : seul le bouton Recommencer (.docMinigameRestartBar, partage)
apparait.

Verifie via simulation DOM reelle (jsdom) : selection au glisser ET au
clic-clic, mot invalide sans crash, barre de fin qui bascule, panneau
Proprietes (ajout/suppression de mot avec revalidation serveur).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-20 15:31:46 +02:00
co-authored by Claude Sonnet 5
parent 1b1307bb85
commit d8be80ebd1
11 changed files with 789 additions and 20 deletions
+84 -9
View File
@@ -500,31 +500,36 @@ img.docImage {
color: var(--doc-muted);
}
/* ---- Quiz/Association/Memory : mini-jeux interactifs en Mode Aperçu
(voir docs/plan/maquettes/document-formation-web.html — référence
visuelle du Quiz). Masqués en édition (seul .docMinigameBadge y est
visible, voir plus haut) ; jamais les deux visibles en même temps. ---- */
/* ---- Quiz/Association/Memory/Mots mêlés : mini-jeux interactifs en Mode
Aperçu (voir docs/plan/maquettes/document-formation-web.html —
référence visuelle du Quiz). Masqués en édition (seul
.docMinigameBadge y est visible, voir plus haut) ; jamais les deux
visibles en même temps. ---- */
.docQuizPlayer,
.docAssocPlayer,
.docMemoryPlayer {
.docMemoryPlayer,
.docMotsPlayer {
display: none;
}
.docEditor3.docEditor3--preview [data-kind="quiz"] .docMinigameBadge,
.docEditor3.docEditor3--preview [data-kind="association"] .docMinigameBadge,
.docEditor3.docEditor3--preview [data-kind="memory"] .docMinigameBadge {
.docEditor3.docEditor3--preview [data-kind="memory"] .docMinigameBadge,
.docEditor3.docEditor3--preview [data-kind="mots"] .docMinigameBadge {
display: none;
}
.docEditor3.docEditor3--preview [data-kind="quiz"] .docQuizPlayer,
.docEditor3.docEditor3--preview [data-kind="association"] .docAssocPlayer,
.docEditor3.docEditor3--preview [data-kind="memory"] .docMemoryPlayer {
.docEditor3.docEditor3--preview [data-kind="memory"] .docMemoryPlayer,
.docEditor3.docEditor3--preview [data-kind="mots"] .docMotsPlayer {
display: block;
}
.docEditor3.docEditor3--preview [data-kind="quiz"],
.docEditor3.docEditor3--preview [data-kind="association"],
.docEditor3.docEditor3--preview [data-kind="memory"] {
.docEditor3.docEditor3--preview [data-kind="memory"],
.docEditor3.docEditor3--preview [data-kind="mots"] {
border: none;
background: transparent;
}
@@ -534,7 +539,9 @@ img.docImage {
.docEditor3.docEditor3--preview .docAssocPlayer,
.docEditor3.docEditor3--preview .docAssocPlayer *,
.docEditor3.docEditor3--preview .docMemoryPlayer,
.docEditor3.docEditor3--preview .docMemoryPlayer * {
.docEditor3.docEditor3--preview .docMemoryPlayer *,
.docEditor3.docEditor3--preview .docMotsPlayer,
.docEditor3.docEditor3--preview .docMotsPlayer * {
/* .docEditor3--preview désactive pointer-events sur tout [data-element-id]
(voir plus bas) pour empêcher la sélection/le déplacement pendant
l'Aperçu — réactivé ici spécifiquement pour que ces mini-jeux restent
@@ -989,6 +996,74 @@ img.docImage {
color: var(--doc-quiz-success-text);
}
/* ---- Mots mêlés : grille interactive (voir docs/plan/PLAN.md, "mots
cachés horizontalement/verticalement/en diagonale"). Réutilise
.docAssocCard/.docAssocTitle/.docAssocMeta/.docMinigameRestartBar
(mêmes rôles visuels que l'Association/Memory ci-dessus). ---- */
.docMotsBoard {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
.docMotsGrid {
display: grid;
gap: 3px;
width: min(100%, 420px);
user-select: none;
}
.docMotsCell {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--doc-border);
background: var(--doc-bg-2);
color: var(--doc-text);
font-weight: 700;
font-size: 13px;
border-radius: 4px;
cursor: pointer;
}
.docMotsCell.is-selecting {
border-color: var(--doc-accent);
background: rgb(255 95 46 / 18%);
}
.docMotsCell.is-found {
border-color: var(--doc-quiz-success-border);
background: var(--doc-quiz-success-bg);
color: var(--doc-quiz-success-text);
cursor: default;
}
.docMotsWordList {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 140px;
}
.docMotsWordChip {
padding: 8px 12px;
border: 1px solid var(--doc-border);
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--doc-text);
background: var(--doc-bg-2);
}
.docMotsWordChip.is-found {
border-color: var(--doc-quiz-success-border);
background: var(--doc-quiz-success-bg);
color: var(--doc-quiz-success-text);
text-decoration: line-through;
}
.docUnknown {
font-size: 12.5px;
color: var(--doc-muted);
+289
View File
@@ -478,6 +478,7 @@ function forgeDocBindCanvasInteractions() {
forgeDocInitQuizPlayers();
forgeDocInitAssociationPlayers();
forgeDocInitMemoryPlayers();
forgeDocInitMotsPlayers();
}
/* ---------------------------------------------------------------------
@@ -915,6 +916,225 @@ function forgeDocInitMemoryPlayers() {
});
}
/* ---------------------------------------------------------------------
* Mots mêlés — grille RÉELLEMENT interactive en Mode Aperçu : la grille
* ET la position exacte de chaque mot sont calculées côté serveur (voir
* document_engine/rendering/render_document_element.py::_build_mots_grid,
* jamais recalculées ici) et embarquées en JSON. Une sélection est
* validée par comparaison de COORDONNÉES exactes avec celles d'un mot
* (jamais une comparaison de texte, qui se tromperait sur des lettres
* partagées entre deux mots qui se croisent). Deux façons de
* sélectionner, toutes deux réelles : glisser (mousedown puis survol des
* cases), ou cliquer une case de départ puis une case d'arrivée (repli
* pour les appareils sans glisser fiable — même principe que
* l'Association). État tenu en mémoire, jamais persisté.
* ------------------------------------------------------------------- */
function forgeDocMotsPlayerData(playerEl) {
try {
const config = JSON.parse(playerEl.dataset.motsConfig || '{}');
return {
size: Number(config.size) || 0,
grid: Array.isArray(config.grid) ? config.grid : [],
words: Array.isArray(config.words) ? config.words : [],
};
} catch (e) {
return { size: 0, grid: [], words: [] };
}
}
function forgeDocMotsCellsBetween(start, end) {
const rowDiff = end.row - start.row;
const colDiff = end.col - start.col;
// Seules 4 directions sont autorisées à la pose (voir _build_mots_grid) :
// horizontale, verticale, ou une vraie diagonale (même distance en
// ligne qu'en colonne) — tout le reste n'est pas une sélection valide.
if (rowDiff !== 0 && colDiff !== 0 && Math.abs(rowDiff) !== Math.abs(colDiff)) return null;
const steps = Math.max(Math.abs(rowDiff), Math.abs(colDiff));
const stepRow = steps === 0 ? 0 : rowDiff / steps;
const stepCol = steps === 0 ? 0 : colDiff / steps;
const cells = [];
for (let i = 0; i <= steps; i += 1) {
cells.push({ row: start.row + stepRow * i, col: start.col + stepCol * i });
}
return cells;
}
function forgeDocMotsCellsMatchWord(cells, wordCells) {
if (cells.length !== wordCells.length) return false;
const forward = cells.every((c, i) => c.row === wordCells[i][0] && c.col === wordCells[i][1]);
if (forward) return true;
// L'apprenant peut glisser dans les deux sens d'un même mot placé
// (départ->fin ou fin->départ) : seul le SENS DE POSE (voir
// _build_mots_grid, jamais à l'envers) est contraint, pas le sens de
// lecture de la sélection.
const reversed = [...wordCells].reverse();
return cells.every((c, i) => c.row === reversed[i][0] && c.col === reversed[i][1]);
}
function forgeDocMotsUpdateProgress(playerEl, state) {
playerEl.querySelector('.docMotsProg').textContent = `${state.found.size} / ${state.words.length} mots trouvés`;
}
function forgeDocMotsCheckComplete(playerEl, state) {
if (state.found.size < state.words.length) return;
// Même choix que l'Association/Memory ci-dessus : la grille reste
// affichée une fois tous les mots trouvés, seul ce bouton apparaît.
playerEl.querySelector('.docMinigameRestartBar').classList.add('is-visible');
}
function forgeDocMotsClearSelection(state) {
state.selectedCellEls.forEach((cellEl) => cellEl.classList.remove('is-selecting'));
state.selectedCellEls = [];
}
function forgeDocMotsHighlightPath(state, start, end) {
forgeDocMotsClearSelection(state);
const cells = forgeDocMotsCellsBetween(start, end);
if (!cells) return;
cells.forEach(({ row, col }) => {
const cellEl = state.cellEls[row]?.[col];
if (cellEl) {
cellEl.classList.add('is-selecting');
state.selectedCellEls.push(cellEl);
}
});
}
function forgeDocMotsTryMatch(playerEl, state, cells) {
const matchIndex = state.words.findIndex((w, i) => !state.found.has(i) && forgeDocMotsCellsMatchWord(cells, w.cells));
forgeDocMotsClearSelection(state);
if (matchIndex === -1) return;
state.found.add(matchIndex);
cells.forEach(({ row, col }) => {
const cellEl = state.cellEls[row]?.[col];
if (cellEl) cellEl.classList.add('is-found');
});
const chip = playerEl.querySelector(`.docMotsWordChip[data-word-index="${matchIndex}"]`);
if (chip) chip.classList.add('is-found');
forgeDocMotsUpdateProgress(playerEl, state);
forgeDocMotsCheckComplete(playerEl, state);
}
function forgeDocMotsFinishSelection(playerEl, state) {
state.dragging = false;
if (!state.start || !state.current) {
state.start = null;
state.current = null;
return;
}
const cells = forgeDocMotsCellsBetween(state.start, state.current);
state.start = null;
state.current = null;
if (!cells) {
forgeDocMotsClearSelection(state);
return;
}
forgeDocMotsTryMatch(playerEl, state, cells);
}
let forgeDocMotsGlobalMouseupBound = false;
function forgeDocMotsBindGlobalMouseup() {
// Un SEUL listener document-level pour toute la page (jamais un par
// joueur/par restart) : forgeDocInitMotsPlayers est réappelé à chaque
// rafraîchissement du canevas (voir forgeDocBindCanvasInteractions),
// un listener posé à chaque appel s'accumulerait indéfiniment sur
// `document`, qui n'est lui jamais recréé.
if (forgeDocMotsGlobalMouseupBound) return;
forgeDocMotsGlobalMouseupBound = true;
document.addEventListener('mouseup', () => {
document.querySelectorAll('.docMotsPlayer').forEach((playerEl) => {
const state = playerEl.forgeDocMotsState;
if (state && state.dragging) forgeDocMotsFinishSelection(playerEl, state);
});
});
}
function forgeDocRenderMotsBoard(playerEl, state) {
const gridEl = playerEl.querySelector('.docMotsGrid');
const listEl = playerEl.querySelector('.docMotsWordList');
gridEl.innerHTML = '';
gridEl.style.gridTemplateColumns = `repeat(${state.size}, 1fr)`;
state.cellEls = [];
for (let r = 0; r < state.size; r += 1) {
const rowEls = [];
for (let c = 0; c < state.size; c += 1) {
const cellEl = document.createElement('div');
cellEl.className = 'docMotsCell';
cellEl.textContent = state.grid[r][c];
cellEl.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
state.dragging = true;
state.start = { row: r, col: c };
state.current = { row: r, col: c };
forgeDocMotsHighlightPath(state, state.start, state.current);
});
cellEl.addEventListener('mouseenter', () => {
if (!state.dragging || !state.start) return;
state.current = { row: r, col: c };
forgeDocMotsHighlightPath(state, state.start, state.current);
});
cellEl.addEventListener('click', (e) => {
e.stopPropagation();
if (!state.start) {
state.start = { row: r, col: c };
state.current = { row: r, col: c };
forgeDocMotsHighlightPath(state, state.start, state.current);
return;
}
state.current = { row: r, col: c };
forgeDocMotsFinishSelection(playerEl, state);
});
rowEls.push(cellEl);
gridEl.appendChild(cellEl);
}
state.cellEls.push(rowEls);
}
listEl.innerHTML = '';
state.words.forEach((w, i) => {
const chip = document.createElement('span');
chip.className = `docMotsWordChip${state.found.has(i) ? ' is-found' : ''}`;
chip.dataset.wordIndex = String(i);
chip.textContent = w.text;
listEl.appendChild(chip);
});
}
function forgeDocMotsRestart(playerEl) {
const data = forgeDocMotsPlayerData(playerEl);
const state = {
size: data.size,
grid: data.grid,
words: data.words,
found: new Set(),
start: null,
current: null,
dragging: false,
cellEls: [],
selectedCellEls: [],
};
playerEl.forgeDocMotsState = state;
playerEl.querySelector('.docMinigameRestartBar').classList.remove('is-visible');
forgeDocMotsUpdateProgress(playerEl, state);
forgeDocRenderMotsBoard(playerEl, state);
}
function forgeDocInitMotsPlayers() {
forgeDocMotsBindGlobalMouseup();
document.querySelectorAll('.docMotsPlayer').forEach((playerEl) => {
const data = forgeDocMotsPlayerData(playerEl);
if (!data.words.length) return;
forgeDocMotsRestart(playerEl);
playerEl.querySelector('.docMotsRestartBtn').addEventListener('click', (e) => {
e.stopPropagation();
forgeDocMotsRestart(playerEl);
});
});
}
function forgeDocSetActiveBottomNav(target) {
document.querySelectorAll('#docBottomNav button').forEach((b) => {
b.classList.toggle('is-active', b.dataset.target === target);
@@ -1449,6 +1669,74 @@ function forgeDocRenderMemoryProps(panel, el) {
});
}
function forgeDocRenderMotsWordHtml(word, wIndex) {
return `
<div class="docAssocPairRow docMotsWordRow" data-word-index="${wIndex}">
<div class="docQuizQuestionHeader">
<span class="docFieldLabel">Mot ${wIndex + 1}</span>
<button type="button" class="docQuizRemoveBtn" data-word-index="${wIndex}" title="Supprimer ce mot">🗑</button>
</div>
<input type="text"
class="docAssocPairInput docMotsWordInput"
placeholder="Mot à cacher dans la grille"
maxlength="20"
value="${forgeDocEscapeHtml(word)}"
data-word-index="${wIndex}">
</div>
`;
}
function forgeDocRenderMotsProps(panel, el) {
const a = el.attributes;
const words = a.words || [];
function patch(partial) {
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
});
}
panel.innerHTML = `
${forgeDocPropsHeader('mots mêlés')}
<div class="docField">
<span class="docFieldLabel">Couleur du thème</span>
<div class="docSwatches" id="docMotsColor">
${['#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">Mots à cacher (5 à 10 recommandés)</div>
<div id="docMotsWordsList">${words.length ? words.map(forgeDocRenderMotsWordHtml).join('') : '<div class="docEmptyState">Aucun mot — ajoute le premier ci-dessous.</div>'}</div>
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docMotsAddWord">+ Ajouter un mot</button>
${forgeDocDeleteButtonHtml()}
`;
panel.querySelectorAll('#docMotsColor .docSwatch').forEach((sw) => {
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
});
document.getElementById('docMotsAddWord').addEventListener('click', () => {
// Mot par défaut non vide (jamais ""), même leçon que
// forgeDocAssociationNewPair/forgeDocMemoryNewCard : sanitize_mots_config
// (routes/document/document_element_update.py) rejette silencieusement
// tout mot qui ne contient aucune lettre exploitable une fois nettoyé.
patch({ words: [...words, 'NOUVEAU'] });
});
panel.querySelectorAll('#docMotsWordsList .docQuizRemoveBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const wIndex = Number(btn.dataset.wordIndex);
patch({ words: words.filter((_, i) => i !== wIndex) });
});
});
panel.querySelectorAll('#docMotsWordsList .docMotsWordInput').forEach((input) => {
input.addEventListener('change', (e) => {
const wIndex = Number(input.dataset.wordIndex);
patch({ words: words.map((w, i) => (i === wIndex ? e.target.value : w)) });
});
});
}
function forgeDocRenderProps(el) {
const panel = document.getElementById('docPropsPanel');
if (!el) {
@@ -1463,6 +1751,7 @@ function forgeDocRenderProps(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 if (el.kind === 'mots') forgeDocRenderMotsProps(panel, el);
else forgeDocRenderMinigameProps(panel, el);
const deleteBtn = document.getElementById('docDeletePropBtn');