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
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:
co-authored by
Claude Sonnet 5
parent
1b1307bb85
commit
d8be80ebd1
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user