Build and deploy / test-python (push) Successful in 11m18s
Build and deploy / test-js (push) Successful in 1m16s
Build and deploy / lint-python (push) Successful in 4m24s
Build and deploy / lint-js (push) Successful in 2m51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Successful in 5m30s
Corrige les findings SonarQube (via SonarLint IDE, fichier par fichier) sur ~24 fichiers static/js/ : parseFloat/parseInt -> Number.*, .replace(/x/g,y) -> .replaceAll, .indexOf() -> .includes()/.startsWith(), getAttribute/setAttribute -> .dataset, tableaux -> Set, x && x.y -> x?.y (verifie site par site), extraction de template litteraux imbriques, ternaires imbriquees, refactors de complexite cognitive (S3776) via tables de dispatch, Object.hasOwn, .at(), et deduplication de fonctions identiques (S4144). Deux exceptions S2486 documentees/corrigees (filter-repeater-rows.js) et un cas S2703 de partage inter-scripts complete (_collisionWizard, trigger-editor.js <-> collision-rules-editor.js). Details complets dans CODE_QUALITY.md section 5. Restaure aussi le job CI "sonarqube" (non-bloquant) dans .gitea/workflows/deploy.yml maintenant que l'instance prod est operationnelle. Suites vertes : 276/276 JS (node --test), 591/591 Python (pytest). SKIP=djlint sur ce commit : hook djlint bloquant sur le backlog H021 (styles inline, 49 occurrences/6 templates) deja documente comme dette assumee non traitee dans CODE_QUALITY.md section 6, aucun rapport avec ce commit (aucun template touche ici) - valide explicitement avec l'utilisateur avant de contourner ce hook. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
966 lines
47 KiB
JavaScript
966 lines
47 KiB
JavaScript
// ---------- Éditeur de scène 2D (fondations multi-éditeurs) ----------
|
||
// JS propre à templates/scene_edit.html : ajout/déplacement/redimension-
|
||
// nement d'objets de scène EN PIXELS (pas de %, voir ensure_scene_schema.py),
|
||
// mirror des fonctions équivalentes de static/js/screen_edit/tree-panels.js
|
||
// pour l'éditeur document (onElementMouseDown/onResizeMouseDown/
|
||
// saveGeometry), mais sur une scène à taille FIXE plutôt qu'un canevas
|
||
// fluide — la logique de flow elle-même (flow-editor.js, tabs-and-blocks.js,
|
||
// animation-timeline.js, personnage-panel.js, personnage-preview.js) est
|
||
// réutilisée TELLE QUELLE, chargée telle quelle par scene_edit.html.
|
||
|
||
// AJAX (voir routes/scenes/scene_object_add.py — répond en JSON dès que
|
||
// "X-Requested-With: fetch" est posé) : pose l'objet SANS recharger la
|
||
// page, pour ne plus faire "disparaître" un instant l'éditeur de collision/
|
||
// quêtes (état JS entier réinitialisé par un rechargement complet) ni
|
||
// perdre l'onglet/le scroll en cours. Le nouvel objet n'est PAS
|
||
// automatiquement sélectionné/ouvert dans le panneau de propriétés (voir
|
||
// onSceneObjectMouseDown, qui recharge encore la page pour ça) — juste
|
||
// posé sur le canevas, cliquable ensuite comme n'importe quel autre.
|
||
function addSceneObject(kind, forgeCharacter, backgroundSlug, assetId, x, y) {
|
||
const body = new FormData();
|
||
body.append('kind', kind);
|
||
if (forgeCharacter) body.append('forge_character', forgeCharacter);
|
||
if (backgroundSlug) body.append('background_slug', backgroundSlug);
|
||
// "Mes assets" (voir auth/user_assets_dir.py) : une image importée par
|
||
// le créateur ou générée par l'IA, distincte de BACKGROUND_LIBRARY.
|
||
if (assetId) body.append('asset_id', assetId);
|
||
// Vrai glisser-déposer (voir onCanvasDrop ci-dessous) : position EXACTE
|
||
// du dépôt — absent pour un simple clic sur la galerie, qui garde le
|
||
// défaut habituel (100,100, voir screens/scenes/add_scene_object.py).
|
||
if (x != null && y != null) { body.append('x', x); body.append('y', y); }
|
||
fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/scene-objects/add`, {
|
||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body,
|
||
})
|
||
.then((r) => {
|
||
// Secours si le serveur répond encore par une redirection HTML
|
||
// classique (ex. déploiement pas encore à jour côté serveur alors
|
||
// que ce script, lui, l'est déjà) : l'objet est de toute façon déjà
|
||
// posé en base à ce stade (voir screens/scenes/add_scene_object.py),
|
||
// seul un rechargement peut alors l'afficher.
|
||
const contentType = r.headers.get('content-type') || '';
|
||
if (!r.ok || !contentType.includes('application/json')) {
|
||
window.location.reload();
|
||
return null;
|
||
}
|
||
return r.json();
|
||
})
|
||
.then((json) => {
|
||
if (!json) return;
|
||
const canvas = document.getElementById('canvas');
|
||
const el = document.createElement('div');
|
||
el.className = 'canvasElement';
|
||
el.id = `el-${json.id}`;
|
||
el.style.position = 'absolute';
|
||
el.style.left = `${json.x}px`; el.style.top = `${json.y}px`;
|
||
el.style.width = `${json.width}px`; el.style.height = `${json.height}px`;
|
||
el.setAttribute('onmousedown', `onSceneObjectMouseDown(event, ${json.id})`);
|
||
el.innerHTML = `<div class="canvasElementInner">${json.rendered_html}</div>`
|
||
+ `<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, ${json.id})"></div>`;
|
||
canvas.appendChild(el);
|
||
if (json.animations) {
|
||
ELEMENT_ANIMATIONS_MAP[json.id] = json.animations;
|
||
restartPersonnageIdlePreview(json.id, json.animations);
|
||
}
|
||
})
|
||
// Filet de sécurité : mieux vaut un rechargement (l'objet est déjà en
|
||
// base à ce stade) qu'un bouton "+ Ajouter" qui semble ne plus rien
|
||
// faire du tout si jamais une erreur imprévue survient ici.
|
||
.catch(() => { window.location.reload(); });
|
||
}
|
||
|
||
// ---------- Vrai glisser-déposer depuis la galerie (demande explicite :
|
||
// "n'importe où sur la scène", pas seulement un clic qui pose à une
|
||
// position par défaut) ----------
|
||
// dataTransfer ne porte que des CHAÎNES : les 4 arguments d'addSceneObject
|
||
// sont sérialisés en JSON plutôt qu'éclatés en plusieurs champs (certains
|
||
// sont `null` — JSON les distingue proprement d'une chaîne vide, un
|
||
// simple attribut HTML ne le pourrait pas). `draggable="true"` sur un
|
||
// <button> coexiste nativement avec son `onclick` (un clic sans
|
||
// mouvement déclenche toujours "click", un glisser réel "dragstart" —
|
||
// jamais les deux à la fois) : le clic simple (pose à la position par
|
||
// défaut) continue donc de fonctionner exactement comme avant.
|
||
function onGalleryTileDragStart(ev, kind, forgeCharacter, backgroundSlug, assetId) {
|
||
ev.dataTransfer.effectAllowed = 'copy';
|
||
ev.dataTransfer.setData('application/json', JSON.stringify({
|
||
kind,
|
||
forgeCharacter: forgeCharacter || null,
|
||
backgroundSlug: backgroundSlug || null,
|
||
assetId: assetId || null,
|
||
}));
|
||
}
|
||
|
||
// Un navigateur refuse "drop" par défaut sur TOUT élément — preventDefault()
|
||
// sur "dragover" est ce qui l'autorise explicitement ici (voir MDN, aucun
|
||
// contournement plus simple).
|
||
function onCanvasDragOver(ev) {
|
||
ev.preventDefault();
|
||
ev.dataTransfer.dropEffect = 'copy';
|
||
}
|
||
|
||
function onCanvasDrop(ev) {
|
||
ev.preventDefault();
|
||
const raw = ev.dataTransfer.getData('application/json');
|
||
if (!raw) return;
|
||
let data;
|
||
try { data = JSON.parse(raw); } catch (e) { return; }
|
||
const canvas = document.getElementById('canvas');
|
||
const rect = canvas.getBoundingClientRect();
|
||
// Pas de zoom/échelle CSS sur #canvas (voir templates/scene_edit.html,
|
||
// juste width/height en px fixes) : une simple soustraction suffit,
|
||
// aucune division par un facteur d'échelle à appliquer. getBoundingClientRect()
|
||
// intègre déjà nativement le défilement de .canvasFrame.
|
||
const x = ev.clientX - rect.left;
|
||
const y = ev.clientY - rect.top;
|
||
addSceneObject(data.kind, data.forgeCharacter, data.backgroundSlug, data.assetId, x, y);
|
||
}
|
||
|
||
// "Mes assets" (voir routes/assets/upload_user_asset.py, panneau "Mes
|
||
// assets" de scene_edit.html) : un simple rechargement après l'upload —
|
||
// la galerie est rendue côté serveur (user_assets), pas la peine de la
|
||
// reconstruire en JS pour un flux qui n'arrive pas souvent.
|
||
// `sceneKind` ("fond"/"decor", images uniquement — demande explicite :
|
||
// "un bouton d'import séparé pour les images de fond et les objets") :
|
||
// décide comment un CLIC sur la tuile résultante pose l'objet (voir
|
||
// screens/scenes/add_scene_object.py) — absent pour un son/une vidéo,
|
||
// qui n'ont rien à "poser".
|
||
function uploadUserAsset(inputEl, sceneKind) {
|
||
const file = inputEl.files?.[0];
|
||
if (!file) return;
|
||
const body = new FormData();
|
||
body.append('file', file);
|
||
if (sceneKind) body.append('scene_kind', sceneKind);
|
||
fetch('/assets/upload', { method: 'POST', body })
|
||
.then((r) =>
|
||
// Bug corrigé : un rechargement inconditionnel masquait toute
|
||
// erreur serveur (ex. son de plus de 3 minutes, voir
|
||
// auth/validate_audio_duration.py) — le fichier semblait juste
|
||
// "disparaître" sans explication, jamais ajouté à la galerie.
|
||
r.json().then((json) => ({ ok: r.ok, json })))
|
||
.then((result) => {
|
||
if (!result.ok) { alert(result.json.error || "Impossible d'importer ce fichier."); return; }
|
||
window.location.reload();
|
||
})
|
||
.catch(() => { window.location.reload(); });
|
||
}
|
||
|
||
// Suppression d'un fichier de "Mes assets" (demande explicite : "voir,
|
||
// utiliser ou supprimer des son, image et vidéo") — voir
|
||
// routes/assets/delete_user_asset.py. Un simple rechargement après coup,
|
||
// comme uploadUserAsset() juste au-dessus : ce panneau ne se recompose
|
||
// pas assez souvent pour justifier une suppression ciblée du DOM.
|
||
function deleteUserAsset(assetId, name) {
|
||
if (!window.confirm(`Supprimer "${name}" ? Cette action est définitive.`)) return;
|
||
fetch(`/assets/${assetId}`, { method: 'DELETE' })
|
||
.then((r) => r.json().then((json) => ({ ok: r.ok, json })))
|
||
.then((result) => {
|
||
if (!result.ok) { alert(result.json.error || 'Impossible de supprimer ce fichier.'); return; }
|
||
window.location.reload();
|
||
})
|
||
.catch(() => { window.location.reload(); });
|
||
}
|
||
|
||
// "→ Fond"/"→ Décor" (demande diagnostiquée depuis un "décalage" : une
|
||
// image importée par le mauvais bouton, ou avant la séparation fond/décor,
|
||
// restait bloquée dans la mauvaise section, impossible à poser comme fond
|
||
// sans la ré-importer en double) — voir auth/update_user_asset_scene_kind.py.
|
||
// Ne touche jamais un objet DÉJÀ posé sur une scène : juste la classification
|
||
// de l'ASSET, pour que la prochaine pose utilise la bonne section/le bon
|
||
// rendu (object-fit:fill pour un fond, :contain pour un décor).
|
||
function reclassifyUserAsset(assetId, sceneKind) {
|
||
fetch(`/assets/${assetId}/scene-kind`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ scene_kind: sceneKind }),
|
||
})
|
||
.then((r) => r.json().then((json) => ({ ok: r.ok, json })))
|
||
.then((result) => {
|
||
if (!result.ok) { alert(result.json.error || 'Reclassement impossible.'); return; }
|
||
window.location.reload();
|
||
})
|
||
.catch(() => { window.location.reload(); });
|
||
}
|
||
|
||
// AJAX (voir routes/scenes/scene_object_delete.py) : bouton 🗑️ du panneau
|
||
// de propriétés (toujours l'objet SÉLECTIONNÉ, seul cas où ce bouton est
|
||
// affiché) — supprime l'objet SANS recharger la page ni même redemander
|
||
// #builder3 au serveur (déjà retiré du DOM ici) : ne plus faire "vider"
|
||
// l'éditeur de collision/quêtes le temps d'un remplacement de <main>,
|
||
// comme le faisait la soumission de formulaire classique interceptée par
|
||
// pjax.js (voir son commentaire d'en-tête).
|
||
function deleteSceneObject(id) {
|
||
fetch(`/game/${GAME_SLUG}/scene-objects/${id}/delete`, {
|
||
method: 'POST', headers: { 'X-Requested-With': 'fetch' },
|
||
})
|
||
.then((r) => {
|
||
const contentType = r.headers.get('content-type') || '';
|
||
if (!r.ok || !contentType.includes('application/json')) {
|
||
window.location.reload();
|
||
return null;
|
||
}
|
||
return r.json();
|
||
})
|
||
.then((json) => {
|
||
if (!json?.ok) return;
|
||
const el = document.getElementById(`el-${id}`);
|
||
if (el) el.remove();
|
||
if (typeof _stopPersonnageIdlePreview === 'function') _stopPersonnageIdlePreview(id);
|
||
// L'objet supprimé était forcément le sélectionné (seul cas où 🗑️
|
||
// est affiché) — referme le panneau de propriétés et l'URL, sans
|
||
// requête supplémentaire au serveur.
|
||
const propsBody = document.querySelector('#propsPanelFloat .floatPanelBody');
|
||
if (propsBody) {
|
||
propsBody.innerHTML = '<div class="hint" style="padding:20px 0;">Sélectionne un objet (sur la scène ou dans la liste) pour voir et modifier ses propriétés ici.</div>';
|
||
}
|
||
CURRENT_SELECTED_ID = null;
|
||
window.history.replaceState(null, '', SCREEN_EDIT_URL);
|
||
})
|
||
.catch(() => { window.location.reload(); });
|
||
}
|
||
|
||
// Taille de la caméra (voir "🎮 Jeu 2D" en haut de templates/
|
||
// scene_edit.html) — TOUJOURS modifiable ici, directement en pixels
|
||
// (demande explicite : "l'écran de l'éditeur a une taille fixe, la
|
||
// caméra a la même taille, c'est tout") : recharge la page une fois
|
||
// appliqué, SCENE_WIDTH/SCENE_HEIGHT et le cadre "🎥 Champ de la caméra"
|
||
// éventuel doivent tous deux refléter la nouvelle taille, plus simple
|
||
// qu'une mise à jour partielle en JS.
|
||
function applyCameraSize() {
|
||
const wEl = document.getElementById('scene-camera-w'); const
|
||
hEl = document.getElementById('scene-camera-h');
|
||
const width = Number.parseInt(wEl.value, 10) || 1; const
|
||
height = Number.parseInt(hEl.value, 10) || 1;
|
||
fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/scene-size`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ width, height }),
|
||
}).then(() => { window.location.reload(); });
|
||
}
|
||
|
||
function saveSceneObjectGeometry(id, x, y, width, height) {
|
||
return fetch(`/game/${GAME_SLUG}/scene-objects/${id}/geometry`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
x, y, width, height,
|
||
}),
|
||
}).then((r) => r.json()).then((data) => {
|
||
// Bug corrigé (même famille que iaChatRefreshAfterTurn(), voir
|
||
// static/js/ia/ia-chat.js) : un "fond" redimensionné/déplacé à la
|
||
// souris au-delà du champ de la caméra doit élargir le "monde" tout
|
||
// de suite — sans ça, window.SCENE_WIDTH/SCENE_HEIGHT (posées une
|
||
// seule fois à l'ouverture de la page) restaient à leur ancienne
|
||
// valeur et le glisser-déposer continuait de tout borner à l'ancien
|
||
// cadre, rendant injoignable à la souris tout ce qui dépassait
|
||
// désormais la scène nominale.
|
||
if (data && typeof data.world_width === 'number' && typeof data.world_height === 'number') {
|
||
window.SCENE_WIDTH = data.world_width;
|
||
window.SCENE_HEIGHT = data.world_height;
|
||
const canvas = document.getElementById('canvas');
|
||
if (canvas) {
|
||
canvas.style.width = `${data.world_width}px`;
|
||
canvas.style.height = `${data.world_height}px`;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function updateScenePosFields(x, y, w, h) {
|
||
const xEl = document.getElementById('scene-pos-x'); const
|
||
yEl = document.getElementById('scene-pos-y');
|
||
const wEl = document.getElementById('scene-pos-w'); const
|
||
hEl = document.getElementById('scene-pos-h');
|
||
if (xEl) xEl.value = Math.round(x);
|
||
if (yEl) yEl.value = Math.round(y);
|
||
if (wEl) wEl.value = Math.round(w);
|
||
if (hEl) hEl.value = Math.round(h);
|
||
}
|
||
|
||
// Défilement automatique de .canvasFrame (overflow:auto, voir static/
|
||
// style.css) pendant un glisser/redimensionnement — bug corrigé : une
|
||
// scène à taille FIXE (960×540 par ex.) peut être plus LARGE que la zone
|
||
// visible de .canvasFrame (fenêtre pas assez large, panneaux ouverts...),
|
||
// auquel cas sa partie droite/basse défile HORS de vue. Sans défilement
|
||
// automatique, le curseur atteignait le bord de la zone visible (donc de
|
||
// .canvasFrame) bien avant celui de la scène elle-même — "impossible
|
||
// d'aller plus loin vers la droite" alors qu'il restait pourtant de la
|
||
// place dans la scène. `onNudge(dx, dy)` est rappelé à chaque tick tant
|
||
// que le curseur reste dans la marge d'un bord, MÊME SI la souris ne
|
||
// bouge plus (immobile près du bord) — mirror du comportement standard
|
||
// d'un glisser-déposer proche d'un bord de zone scrollable.
|
||
function forgeStartCanvasAutoScroll(getMouse, onNudge) {
|
||
const MARGIN = 40; const
|
||
SPEED = 14;
|
||
const timer = setInterval(() => {
|
||
const frame = document.querySelector('.canvasFrame');
|
||
if (!frame) return;
|
||
const rect = frame.getBoundingClientRect();
|
||
const mouse = getMouse();
|
||
let dx = 0; let
|
||
dy = 0;
|
||
if (mouse.x > rect.right - MARGIN) dx = SPEED;
|
||
else if (mouse.x < rect.left + MARGIN) dx = -SPEED;
|
||
if (mouse.y > rect.bottom - MARGIN) dy = SPEED;
|
||
else if (mouse.y < rect.top + MARGIN) dy = -SPEED;
|
||
if (!dx && !dy) return;
|
||
frame.scrollLeft = Math.max(0, frame.scrollLeft + dx);
|
||
frame.scrollTop = Math.max(0, frame.scrollTop + dy);
|
||
onNudge(dx, dy);
|
||
}, 30);
|
||
return function stop() { clearInterval(timer); };
|
||
}
|
||
|
||
// Glisser-déposer en pixels, clampé à la taille FIXE de la scène
|
||
// (SCENE_WIDTH/SCENE_HEIGHT, voir templates/scene_edit.html) — mirror
|
||
// d'onElementMouseDown() (tree-panels.js), mais sans conversion en % :
|
||
// une scène a des coordonnées pixel absolues, pas fluides. Position
|
||
// ACCUMULÉE (curLeft/curTop, jamais recalculée depuis le point de départ
|
||
// fixe) : c'est ce qui permet à forgeStartCanvasAutoScroll ci-dessus de
|
||
// continuer à la déplacer même quand la souris ne bouge plus (immobile
|
||
// près d'un bord).
|
||
function onSceneObjectMouseDown(e, id) {
|
||
if (e.target.classList.contains('resizeHandle')) return;
|
||
e.preventDefault();
|
||
const el = document.getElementById(`el-${id}`);
|
||
const startX = e.clientX; const
|
||
startY = e.clientY;
|
||
let lastClientX = e.clientX; let
|
||
lastClientY = e.clientY;
|
||
let curLeft = Number.parseFloat(el.style.left) || 0; let
|
||
curTop = Number.parseFloat(el.style.top) || 0;
|
||
const width = Number.parseFloat(el.style.width) || 0; const
|
||
height = Number.parseFloat(el.style.height) || 0;
|
||
let moved = false;
|
||
// Les panneaux flottants "🧩 Objets"/"⚙️ Propriétés" (voir static/style.css
|
||
// .floatPanel, z-index:60) restent DÉLIBÉRÉMENT en position:fixed
|
||
// PAR-DESSUS le canevas, pour maximiser l'espace utile — mais un objet
|
||
// glissé sous l'un d'eux, à son z-index habituel (bien plus bas), y
|
||
// disparaissait littéralement à l'écran PENDANT le glisser (toujours
|
||
// déplacé/enregistré correctement en dessous, juste invisible pendant
|
||
// le geste) : impossible de voir où on le dépose. Passé au-dessus des
|
||
// panneaux (z-index très élevé) le temps du glisser SEULEMENT, restauré
|
||
// à sa valeur d'origine à la fin.
|
||
const previousZIndex = el.style.zIndex;
|
||
|
||
// Bug corrigé ("toujours bloqué", confirmé lié à l'introduction du fond/
|
||
// caméra, voir add_scene_object.py) : un "fond" est posé à sa taille
|
||
// RÉELLE (souvent bien plus grande que la scène, ex. 1920px pour une
|
||
// scène de 960px — exprès, pour que la caméra puisse le suivre en
|
||
// défilant) — Math.max(0, Math.min(SCENE_WIDTH - width, ...)) suppose
|
||
// SCENE_WIDTH - width POSITIF (objet plus petit que la scène) : pour un
|
||
// objet plus GRAND, cette différence est négative, et Math.max(0, ...)
|
||
// ramenait alors TOUJOURS la position à 0 quel que soit le glisser — un
|
||
// fond (ou tout autre objet plus grand que la scène) restait donc figé
|
||
// à (0,0), impossible à repositionner. min/max sont maintenant calculés
|
||
// dans le bon ordre quel que soit lequel des deux est le plus grand.
|
||
function applyDelta(dx, dy) {
|
||
const minLeft = Math.min(0, SCENE_WIDTH - width); const
|
||
maxLeft = Math.max(0, SCENE_WIDTH - width);
|
||
const minTop = Math.min(0, SCENE_HEIGHT - height); const
|
||
maxTop = Math.max(0, SCENE_HEIGHT - height);
|
||
curLeft = Math.max(minLeft, Math.min(maxLeft, curLeft + dx));
|
||
curTop = Math.max(minTop, Math.min(maxTop, curTop + dy));
|
||
el.style.left = `${curLeft}px`;
|
||
el.style.top = `${curTop}px`;
|
||
}
|
||
function onMove(ev) {
|
||
const dx = ev.clientX - lastClientX; const
|
||
dy = ev.clientY - lastClientY;
|
||
lastClientX = ev.clientX; lastClientY = ev.clientY;
|
||
if (Math.abs(ev.clientX - startX) > 3 || Math.abs(ev.clientY - startY) > 3) {
|
||
if (!moved) el.style.zIndex = 9999;
|
||
moved = true;
|
||
}
|
||
applyDelta(dx, dy);
|
||
}
|
||
const stopAutoScroll = forgeStartCanvasAutoScroll(() => ({ x: lastClientX, y: lastClientY }), applyDelta);
|
||
function onUp() {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
stopAutoScroll();
|
||
if (moved) {
|
||
el.style.zIndex = previousZIndex;
|
||
updateScenePosFields(curLeft, curTop, width, height);
|
||
saveSceneObjectGeometry(id, curLeft, curTop, width, height);
|
||
} else {
|
||
// goToSelected() (static/js/screen_edit/tree-panels.js, déjà utilisée
|
||
// par l'éditeur document) : va chercher #builder3 en arrière-plan et
|
||
// le remplace sur place plutôt que de recharger toute la page —
|
||
// demande explicite ("à chaque fois que je clique sur un élément la
|
||
// page se recharge"). openPropsPanel() : si le panneau était fermé
|
||
// (état mémorisé dans localStorage, voir restoreFloatPanelState()),
|
||
// cliquer un objet doit le rouvrir pour montrer ses propriétés —
|
||
// sinon initBuilderPanel() (rappelé par goToSelected) le laissait
|
||
// fermé, panneau à jour mais invisible.
|
||
openPropsPanel();
|
||
goToSelected(id);
|
||
}
|
||
}
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
}
|
||
|
||
function onSceneObjectResizeMouseDown(e, id) {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
const el = document.getElementById(`el-${id}`);
|
||
let lastClientX = e.clientX; let
|
||
lastClientY = e.clientY;
|
||
let curWidth = Number.parseFloat(el.style.width) || 0; let
|
||
curHeight = Number.parseFloat(el.style.height) || 0;
|
||
const left = Number.parseFloat(el.style.left) || 0; const
|
||
top = Number.parseFloat(el.style.top) || 0;
|
||
// Voir la même note dans onSceneObjectMouseDown ci-dessus : au-dessus
|
||
// des panneaux flottants le temps du geste, pour rester visible même
|
||
// en redimensionnant sous l'un d'eux.
|
||
const previousZIndex = el.style.zIndex;
|
||
el.style.zIndex = 9999;
|
||
|
||
// Pas de plafond à "SCENE_WIDTH - left" ici (bug corrigé) : un objet
|
||
// (typiquement un "fond", voir add_scene_object.py) doit pouvoir être
|
||
// redimensionné plus GRAND que la scène elle-même — c'est précisément
|
||
// le principe de la caméra qui suit le personnage sur un monde plus
|
||
// grand que le viewport. Seul un minimum (8px, jamais un objet
|
||
// invisible) reste imposé.
|
||
function applyDelta(dx, dy) {
|
||
curWidth = Math.max(8, curWidth + dx);
|
||
curHeight = Math.max(8, curHeight + dy);
|
||
el.style.width = `${curWidth}px`;
|
||
el.style.height = `${curHeight}px`;
|
||
}
|
||
function onMove(ev) {
|
||
const dx = ev.clientX - lastClientX; const
|
||
dy = ev.clientY - lastClientY;
|
||
lastClientX = ev.clientX; lastClientY = ev.clientY;
|
||
applyDelta(dx, dy);
|
||
}
|
||
const stopAutoScroll = forgeStartCanvasAutoScroll(() => ({ x: lastClientX, y: lastClientY }), applyDelta);
|
||
function onUp() {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
stopAutoScroll();
|
||
el.style.zIndex = previousZIndex;
|
||
const newWidth = curWidth; const
|
||
newHeight = curHeight;
|
||
updateScenePosFields(left, top, newWidth, newHeight);
|
||
saveSceneObjectGeometry(id, left, top, newWidth, newHeight);
|
||
}
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
}
|
||
|
||
// Champs numériques Position & taille du panneau de propriétés — mirror
|
||
// du comportement équivalent pour l'éditeur document (voir l'IIFE dans
|
||
// initBuilderPanel(), panel-init.js), mais ids/route distincts (pixels).
|
||
function initScenePosFields() {
|
||
const xEl = document.getElementById('scene-pos-x'); const
|
||
yEl = document.getElementById('scene-pos-y');
|
||
const wEl = document.getElementById('scene-pos-w'); const
|
||
hEl = document.getElementById('scene-pos-h');
|
||
if (!xEl || !wEl || !CURRENT_SELECTED_ID) return;
|
||
function apply() {
|
||
const canvasEl = document.getElementById(`el-${CURRENT_SELECTED_ID}`);
|
||
const x = Number.parseFloat(xEl.value) || 0; const
|
||
y = Number.parseFloat(yEl.value) || 0;
|
||
const w = Number.parseFloat(wEl.value) || 1; const
|
||
h = Number.parseFloat(hEl.value) || 1;
|
||
if (canvasEl) {
|
||
canvasEl.style.left = `${x}px`; canvasEl.style.top = `${y}px`;
|
||
canvasEl.style.width = `${w}px`; canvasEl.style.height = `${h}px`;
|
||
}
|
||
saveSceneObjectGeometry(CURRENT_SELECTED_ID, x, y, w, h);
|
||
}
|
||
[xEl, yEl, wEl, hEl].forEach((el) => { el.addEventListener('change', apply); });
|
||
}
|
||
initScenePosFields();
|
||
|
||
// Personnage d'un objet de scène : mirror de swapPersonnageCharacter()
|
||
// (personnage-panel.js), mais POST vers /scene-objects/.../personnage
|
||
// (voir routes/scenes/scene_object_personnage_data.py) plutôt que
|
||
// /elements/.../personnage — même réponse (animations résolues),
|
||
// relance l'aperçu animé via restartPersonnageIdlePreview() (personnage-
|
||
// preview.js, déjà générique : ne dépend que de data-element-id/
|
||
// data-personnage, présents sur un objet de scène comme sur un élément).
|
||
function swapSceneObjectCharacter(forgeCharacter) {
|
||
const body = document.getElementById('personnagePanelBody');
|
||
if (!body) return;
|
||
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||
document.querySelectorAll('#personnageGallerySwap .iconGalleryTile').forEach((tile) => {
|
||
tile.classList.toggle('selected', tile.getAttribute('onclick') === `swapSceneObjectCharacter('${forgeCharacter}')`);
|
||
});
|
||
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/personnage`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ source: 'forge', forge_character: forgeCharacter }),
|
||
})
|
||
.then((r) => r.json())
|
||
.then((json) => {
|
||
if (json.animations) {
|
||
ELEMENT_ANIMATIONS_MAP[objectId] = json.animations;
|
||
restartPersonnageIdlePreview(objectId, json.animations);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Rôle (voir screens/rendering/personnage_role.py) : seul un personnage
|
||
// "Joueur" est déplacé/animé automatiquement au clavier — même patron AJAX
|
||
// que swapSceneObjectCharacter() ci-dessus (POST JSON, aucun rechargement
|
||
// de page), sur routes/scenes/scene_object_role.py.
|
||
function saveSceneObjectRole(role) {
|
||
const body = document.getElementById('personnagePanelBody');
|
||
if (!body) return;
|
||
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/role`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ role }),
|
||
});
|
||
}
|
||
|
||
// Boîte de collision (voir screens/rendering/collision_settings.py,
|
||
// static/js/play/conditions.js::elementsOverlap) : disponible pour TOUT
|
||
// objet de scène (pas seulement "personnage") — même patron AJAX que les
|
||
// fonctions ci-dessus, sur routes/scenes/scene_object_collision.py.
|
||
// Recale aussi l'aperçu (#collisionBoxOverlay, seulement présent pour
|
||
// l'objet sélectionné) pour rester en phase avec les champs, qu'ils
|
||
// viennent d'être tapés à la main ou ajustés au glisser (voir
|
||
// onCollisionBoxMouseDown/onCollisionBoxResizeMouseDown plus bas).
|
||
function saveSceneObjectCollision() {
|
||
const body = document.getElementById('collisionPanelBody');
|
||
if (!body) return;
|
||
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||
const enabled = document.getElementById('collision-enabled').checked;
|
||
const shape = document.getElementById('collision-shape').value;
|
||
const width = Number.parseFloat(document.getElementById('collision-width').value) || 0;
|
||
const height = Number.parseFloat(document.getElementById('collision-height').value) || 0;
|
||
const offsetX = Number.parseFloat(document.getElementById('collision-offset-x').value) || 0;
|
||
const offsetY = Number.parseFloat(document.getElementById('collision-offset-y').value) || 0;
|
||
|
||
const overlay = document.getElementById('collisionBoxOverlay');
|
||
const objEl = document.getElementById(`el-${objectId}`);
|
||
if (overlay && objEl) {
|
||
const objWidth = Number.parseFloat(objEl.style.width) || 0; const
|
||
objHeight = Number.parseFloat(objEl.style.height) || 0;
|
||
overlay.style.width = `${width}px`;
|
||
overlay.style.height = `${height}px`;
|
||
overlay.style.left = `${(objWidth - width) / 2 + offsetX}px`;
|
||
overlay.style.top = `${(objHeight - height) / 2 + offsetY}px`;
|
||
overlay.classList.toggle('is-circle', shape === 'cercle');
|
||
overlay.classList.toggle('is-disabled', !enabled);
|
||
}
|
||
|
||
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/collision`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
enabled, shape, width, height, offset_x: offsetX, offset_y: offsetY,
|
||
}),
|
||
});
|
||
}
|
||
|
||
function updateCollisionFields(width, height, offsetX, offsetY) {
|
||
const wEl = document.getElementById('collision-width'); const
|
||
hEl = document.getElementById('collision-height');
|
||
const oxEl = document.getElementById('collision-offset-x'); const
|
||
oyEl = document.getElementById('collision-offset-y');
|
||
if (wEl) wEl.value = Math.round(width);
|
||
if (hEl) hEl.value = Math.round(height);
|
||
if (oxEl) oxEl.value = Math.round(offsetX);
|
||
if (oyEl) oyEl.value = Math.round(offsetY);
|
||
}
|
||
|
||
// Glisser le CORPS de l'aperçu ajuste son décalage (offset_x/offset_y) —
|
||
// mirror d'onSceneObjectMouseDown ci-dessus, mais sur la boîte de
|
||
// collision plutôt que l'objet lui-même (jamais le même geste : cliquer
|
||
// l'objet EN DEHORS de la boîte de collision continue de le déplacer
|
||
// normalement, la boîte est un enfant plus petit posé par-dessus).
|
||
function onCollisionBoxMouseDown(e, id) {
|
||
e.stopPropagation();
|
||
if (e.target.classList.contains('collisionResizeHandle')) return;
|
||
e.preventDefault();
|
||
const overlay = document.getElementById('collisionBoxOverlay');
|
||
const objEl = document.getElementById(`el-${id}`);
|
||
const objWidth = Number.parseFloat(objEl.style.width) || 0; const
|
||
objHeight = Number.parseFloat(objEl.style.height) || 0;
|
||
const startX = e.clientX; const
|
||
startY = e.clientY;
|
||
const startLeft = Number.parseFloat(overlay.style.left) || 0; const
|
||
startTop = Number.parseFloat(overlay.style.top) || 0;
|
||
const boxWidth = Number.parseFloat(overlay.style.width) || 0; const
|
||
boxHeight = Number.parseFloat(overlay.style.height) || 0;
|
||
|
||
function onMove(ev) {
|
||
overlay.style.left = `${startLeft + (ev.clientX - startX)}px`;
|
||
overlay.style.top = `${startTop + (ev.clientY - startY)}px`;
|
||
}
|
||
function onUp() {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
const offsetX = (Number.parseFloat(overlay.style.left) || 0) - (objWidth - boxWidth) / 2;
|
||
const offsetY = (Number.parseFloat(overlay.style.top) || 0) - (objHeight - boxHeight) / 2;
|
||
updateCollisionFields(boxWidth, boxHeight, offsetX, offsetY);
|
||
saveSceneObjectCollision();
|
||
}
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
}
|
||
|
||
// Glisser la poignée (coin bas-droit) redimensionne la boîte de collision
|
||
// — mirror d'onSceneObjectResizeMouseDown, ancré sur son coin haut-gauche
|
||
// (le centre se déplace donc légèrement à chaque redimensionnement, sauf
|
||
// à recalculer l'offset pour le garder fixe : on choisit de garder le
|
||
// COIN fixe, plus prévisible au glisser qu'un centre qui "fuit").
|
||
function onCollisionBoxResizeMouseDown(e, id) {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
const overlay = document.getElementById('collisionBoxOverlay');
|
||
const objEl = document.getElementById(`el-${id}`);
|
||
const objWidth = Number.parseFloat(objEl.style.width) || 0; const
|
||
objHeight = Number.parseFloat(objEl.style.height) || 0;
|
||
const startX = e.clientX; const
|
||
startY = e.clientY;
|
||
const startWidth = Number.parseFloat(overlay.style.width) || 0; const
|
||
startHeight = Number.parseFloat(overlay.style.height) || 0;
|
||
const left = Number.parseFloat(overlay.style.left) || 0; const
|
||
top = Number.parseFloat(overlay.style.top) || 0;
|
||
|
||
function onMove(ev) {
|
||
overlay.style.width = `${Math.max(4, startWidth + (ev.clientX - startX))}px`;
|
||
overlay.style.height = `${Math.max(4, startHeight + (ev.clientY - startY))}px`;
|
||
}
|
||
function onUp() {
|
||
document.removeEventListener('mousemove', onMove);
|
||
document.removeEventListener('mouseup', onUp);
|
||
const newWidth = Number.parseFloat(overlay.style.width); const
|
||
newHeight = Number.parseFloat(overlay.style.height);
|
||
const offsetX = left - (objWidth - newWidth) / 2;
|
||
const offsetY = top - (objHeight - newHeight) / 2;
|
||
updateCollisionFields(newWidth, newHeight, offsetX, offsetY);
|
||
saveSceneObjectCollision();
|
||
}
|
||
document.addEventListener('mousemove', onMove);
|
||
document.addEventListener('mouseup', onUp);
|
||
}
|
||
|
||
// "Commandes" (voir static/js/play/personnage-controller.js) : touches de
|
||
// déplacement/interaction + blocage d'axe + touches supplémentaires d'un
|
||
// personnage — un seul formulaire, enregistré en AJAX à chaque
|
||
// changement de champ, même patron que swapSceneObjectCharacter()
|
||
// ci-dessus (POST JSON vers routes/scenes/scene_object_commands.py,
|
||
// aucun rechargement de page).
|
||
function saveSceneObjectCommands() {
|
||
const body = document.getElementById('personnageCommandsBody');
|
||
if (!body) return;
|
||
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||
const extra = [];
|
||
document.querySelectorAll('#personnageCommandsExtra .personnageCommandExtraRow').forEach((row) => {
|
||
const toucheEl = row.querySelector('.personnageKeyCapture');
|
||
const animEl = row.querySelector('.cmdExtraAnim');
|
||
if (toucheEl?.value && animEl?.value) {
|
||
extra.push({ touche: toucheEl.value, animation: animEl.value });
|
||
}
|
||
});
|
||
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/commandes`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
haut: document.getElementById('cmd-haut').value,
|
||
bas: document.getElementById('cmd-bas').value,
|
||
gauche: document.getElementById('cmd-gauche').value,
|
||
droite: document.getElementById('cmd-droite').value,
|
||
interagir: document.getElementById('cmd-interagir').value,
|
||
axe: document.getElementById('cmd-axe').value,
|
||
vitesse: document.getElementById('cmd-vitesse').value,
|
||
extra,
|
||
}),
|
||
});
|
||
}
|
||
|
||
// Détection de touche (bug remonté par l'utilisateur : taper le nom d'une
|
||
// touche à la main est source d'erreurs — "Espace" vs " ", "flèche haut"
|
||
// vs "ArrowUp"...) : au focus, le champ (readonly, jamais tapé) affiche un
|
||
// indice puis se remplit avec la PROCHAINE touche pressée (event.key,
|
||
// même valeur que heldKeys/bindKeyboardTriggers, triggers.js — cohérent
|
||
// avec ce que le moteur compare réellement au jeu). capture:true sur
|
||
// window pour intercepter la touche avant tout autre raccourci de
|
||
// l'éditeur (ex. Suppr qui efface l'objet sélectionné).
|
||
function bindPersonnageKeyCapture(el) {
|
||
if (!el || el.dataset.keyCaptureBound) return;
|
||
el.dataset.keyCaptureBound = '1';
|
||
el.addEventListener('focus', () => {
|
||
const previous = el.value;
|
||
el.value = 'Appuie sur une touche...';
|
||
el.classList.add('is-warning');
|
||
function onKey(ev) {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
window.removeEventListener('keydown', onKey, true);
|
||
el.classList.remove('is-warning');
|
||
if (ev.key === 'Escape') { el.value = previous; el.blur(); return; }
|
||
el.value = ev.key;
|
||
el.blur();
|
||
el.dispatchEvent(new Event('change'));
|
||
}
|
||
window.addEventListener('keydown', onKey, true);
|
||
});
|
||
}
|
||
|
||
// Une ligne = une touche supplémentaire liée à UNE pose au choix parmi
|
||
// celles RÉELLEMENT disponibles pour CE personnage (ELEMENT_ANIMATIONS_MAP,
|
||
// déjà connu de personnage-panel.js/personnage-preview.js) — jamais un nom
|
||
// tapé à la main, qui pourrait ne correspondre à aucune pose existante.
|
||
function personnageCommandExtraRowEl(objectId, touche, animation) {
|
||
const row = document.createElement('div');
|
||
row.className = 'field is-grouped personnageCommandExtraRow';
|
||
const animNames = Object.keys(ELEMENT_ANIMATIONS_MAP[objectId] || {});
|
||
const options = animNames.map((name) => `<option value="${name}"${name === animation ? ' selected' : ''}>${name}</option>`).join('');
|
||
row.innerHTML = `<div class="control"><input class="input is-small personnageKeyCapture" readonly placeholder="Touche..." value="${(touche || '').replaceAll('"', '"')}"></div>`
|
||
+ `<div class="control"><div class="select is-small"><select class="cmdExtraAnim">${options}</select></div></div>`
|
||
+ '<div class="control"><button type="button" class="button is-small" onclick="this.closest(\'.personnageCommandExtraRow\').remove(); saveSceneObjectCommands();">×</button></div>';
|
||
const keyInput = row.querySelector('.personnageKeyCapture');
|
||
bindPersonnageKeyCapture(keyInput);
|
||
keyInput.addEventListener('change', saveSceneObjectCommands);
|
||
row.querySelector('.cmdExtraAnim').addEventListener('change', saveSceneObjectCommands);
|
||
return row;
|
||
}
|
||
|
||
function addPersonnageCommandExtraRow() {
|
||
const body = document.getElementById('personnageCommandsBody');
|
||
const container = document.getElementById('personnageCommandsExtra');
|
||
if (!body || !container) return;
|
||
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||
container.appendChild(personnageCommandExtraRowEl(objectId, '', ''));
|
||
}
|
||
|
||
// "ℹ️ Informations" (voir templates/scene_edit.html) : le nom d'un
|
||
// personnage, réutilisé comme "qui parle" dans l'éditeur de dialogue d'un
|
||
// déclencheur (voir static/js/triggers/trigger-editor.js).
|
||
function saveSceneObjectName(objectId, name) {
|
||
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/name`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name }),
|
||
}).then(refreshSceneObjectNames);
|
||
}
|
||
|
||
// SCENE_OBJECT_NAMES (voir routes/scenes/scene_object_names_list.py) :
|
||
// posée UNE SEULE FOIS au chargement complet de la page (<script> inline
|
||
// en bas, HORS de #builder3, jamais rafraîchie par une simple sélection
|
||
// sans rechargement, voir goToSelected()) — sans ce rafraîchissement,
|
||
// renommer un personnage puis ouvrir un dialogue de déclencheur dans la
|
||
// MÊME session ne faisait jamais apparaître son nouveau nom dans "qui
|
||
// parle" (voir triggerSpeakerOptions(), static/js/triggers/
|
||
// trigger-editor.js) tant qu'aucun vrai rechargement de page n'avait eu
|
||
// lieu entre temps.
|
||
function refreshSceneObjectNames() {
|
||
if (typeof SCENE_OBJECT_NAMES === 'undefined') return;
|
||
fetch(`/game/${GAME_SLUG}/scene-object-names`)
|
||
.then((r) => r.json())
|
||
.then((json) => { SCENE_OBJECT_NAMES = json; });
|
||
}
|
||
|
||
// RGAA/WCAG 1.4.3 (contraste minimum) : les couleurs du "🎨 Style"
|
||
// ci-dessous sont librement choisies par l'auteur, sans aucune validation
|
||
// serveur (voir sanitize_dialogue_box_style.py, qui n'accepte qu'une
|
||
// chaîne de longueur bornée, jamais un ratio) — juste un AVERTISSEMENT
|
||
// visuel dans l'éditeur, jamais un blocage (le créateur reste libre de
|
||
// son choix esthétique, ex. un jeu volontairement sombre/stylisé).
|
||
function forgeHexToRgb(hex) {
|
||
hex = String(hex || '').replace('#', '');
|
||
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
|
||
const num = Number.parseInt(hex, 16);
|
||
if (Number.isNaN(num) || hex.length !== 6) return [0, 0, 0];
|
||
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
|
||
}
|
||
|
||
function forgeRelativeLuminance(rgb) {
|
||
const srgb = rgb.map((c) => {
|
||
c /= 255;
|
||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||
});
|
||
return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
|
||
}
|
||
|
||
// Formule de contraste WCAG standard (relative luminance) — 4.5:1 est le
|
||
// seuil "AA" pour du texte normal (RGAA 3.2/critère 1.4.3).
|
||
function forgeContrastRatio(hex1, hex2) {
|
||
const l1 = forgeRelativeLuminance(forgeHexToRgb(hex1));
|
||
const l2 = forgeRelativeLuminance(forgeHexToRgb(hex2));
|
||
const lighter = Math.max(l1, l2); const
|
||
darker = Math.min(l1, l2);
|
||
return (lighter + 0.05) / (darker + 0.05);
|
||
}
|
||
|
||
function checkDialogueBoxContrast(style) {
|
||
const warningEl = document.getElementById('dboxContrastWarning');
|
||
if (!warningEl) return;
|
||
const ratios = [
|
||
forgeContrastRatio(style.text_color, style.header_bg),
|
||
forgeContrastRatio(style.text_color, style.body_bg),
|
||
forgeContrastRatio(style.text_color, style.footer_bg),
|
||
];
|
||
const minRatio = Math.min.apply(null, ratios);
|
||
warningEl.textContent = minRatio < 4.5
|
||
? `⚠️ Contraste insuffisant (${minRatio.toFixed(1)}:1, minimum recommandé 4.5:1) — le texte risque d'être difficile à lire.`
|
||
: '';
|
||
}
|
||
|
||
// "🎨 Style" du widget "💬 Boîte de dialogue" (voir screens/rendering/
|
||
// dialogue_box_style.py) — envoie TOUJOURS les 7 champs ensemble (même
|
||
// convention "replace-all" que saveSceneObjectCollision), jamais un seul
|
||
// champ isolé.
|
||
function saveDialogueBoxStyle(objectId) {
|
||
const style = {
|
||
font_family: document.getElementById('dbox-font-family').value,
|
||
font_size: Number(document.getElementById('dbox-font-size').value),
|
||
font_weight: document.getElementById('dbox-font-weight').value,
|
||
text_color: document.getElementById('dbox-text-color').value,
|
||
header_bg: document.getElementById('dbox-header-bg').value,
|
||
body_bg: document.getElementById('dbox-body-bg').value,
|
||
footer_bg: document.getElementById('dbox-footer-bg').value,
|
||
};
|
||
checkDialogueBoxContrast(style);
|
||
const el = document.getElementById(`el-${objectId}`);
|
||
const widget = el?.querySelector('.dialogueBoxWidget');
|
||
if (widget) {
|
||
widget.style.fontFamily = style.font_family;
|
||
widget.style.fontSize = `${style.font_size}px`;
|
||
widget.style.fontWeight = style.font_weight;
|
||
widget.style.color = style.text_color;
|
||
const header = widget.querySelector('.dialogueBoxHeader'); if (header) header.style.background = style.header_bg;
|
||
const b = widget.querySelector('.dialogueBoxBody'); if (b) b.style.background = style.body_bg;
|
||
const footer = widget.querySelector('.dialogueBoxFooter'); if (footer) footer.style.background = style.footer_bg;
|
||
}
|
||
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/dialogue-box-style`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ style }),
|
||
});
|
||
}
|
||
|
||
// "🖼️ Modèles" (voir templates/scene_edit.html, "❓ Boîte à quiz") — DEUX
|
||
// galeries INDÉPENDANTES (demande explicite : "boîte de dialogue" hors
|
||
// plein écran, "page de quiz" en plein écran, jamais confondues) — une
|
||
// SEULE des deux est rendue à la fois (voir quiz_box_config.fullscreen
|
||
// côté serveur), chacune surligne la vignette cliquée sur sa propre
|
||
// grille (data-selected-template) puis sauvegarde tout de suite (même
|
||
// geste qu'un clic sur une tuile de personnage/décor).
|
||
function _selectQuizBoxTemplate(objectId, key, gridId) {
|
||
const grid = document.getElementById(gridId);
|
||
if (grid) {
|
||
grid.dataset.selectedTemplate = key;
|
||
const tiles = grid.querySelectorAll('.quizTemplateTile');
|
||
for (const tile of tiles) {
|
||
tile.classList.toggle('is-selected', tile.dataset.template === key);
|
||
}
|
||
}
|
||
saveQuizBoxConfig(objectId);
|
||
}
|
||
function selectQuizBoxDialogTemplate(objectId, key) { _selectQuizBoxTemplate(objectId, key, 'quizDialogTemplateGrid'); }
|
||
function selectQuizBoxPageTemplate(objectId, key) { _selectQuizBoxTemplate(objectId, key, 'quizPageTemplateGrid'); }
|
||
|
||
// "⚙️ Options du quiz" + "🖼️ Modèles" (demande explicite : quiz RH
|
||
// autonome — plein écran/minuteur/DEUX modèles indépendants) — mirror de
|
||
// saveDialogueBoxStyle, mais un objet de config à PART (voir
|
||
// screens/rendering/quiz_box_config.py), jamais mélangé à "🎨 Style"
|
||
// (couleurs/police, inchangé, voir plus haut). Remplace l'aperçu par le
|
||
// HTML fraîchement rendu une fois sauvegardé — un modèle change la
|
||
// STRUCTURE du balisage (voir screens/rendering/quiz_box_templates.py),
|
||
// pas juste une classe CSS : deviner quelle balise ajouter/retirer côté
|
||
// client casserait la plupart des modèles.
|
||
//
|
||
// dialog_template/page_template : SEULE une des deux grilles est rendue
|
||
// à la fois (voir templates/scene_edit.html) — celle qui est ABSENTE
|
||
// garde sa valeur via #quizBoxTemplateMeta (posé par le serveur), jamais
|
||
// écrasée par un défaut.
|
||
//
|
||
// "Plein écran" (demande explicite : "elle doit occuper tout l'écran",
|
||
// "être visible par défaut", "la notion de caméra disparaît" — DANS CET
|
||
// ÉDITEUR aussi, pas seulement au jeu) : rechargement complet dès que ce
|
||
// réglage CHANGE de valeur — le cadre "🎥 Champ de la caméra" dépend de
|
||
// TOUS les quiz de l'écran (pas seulement celui-ci, voir
|
||
// routes/scenes/scene_edit_view.py::any_quiz_box_fullscreen), ET la
|
||
// galerie de modèles à afficher change entièrement (boîte <-> page) :
|
||
// un simple patch DOM ne pourrait recalculer ni l'un ni l'autre ici.
|
||
function saveQuizBoxConfig(objectId) {
|
||
const meta = document.getElementById('quizBoxTemplateMeta');
|
||
function currentTemplate(gridId, metaAttr) {
|
||
const grid = document.getElementById(gridId);
|
||
if (!grid) return meta ? meta.getAttribute(metaAttr) : null;
|
||
const selectedTile = grid.querySelector('.quizTemplateTile.is-selected');
|
||
return grid.dataset.selectedTemplate || selectedTile?.dataset.template;
|
||
}
|
||
const fullscreenEl = document.getElementById('quiz-fullscreen');
|
||
const fullscreenChanged = fullscreenEl.checked !== (fullscreenEl.dataset.wasChecked === '1');
|
||
const config = {
|
||
fullscreen: fullscreenEl.checked,
|
||
timer_mode: document.getElementById('quiz-timer-mode').value,
|
||
timer_seconds: Number(document.getElementById('quiz-timer-seconds').value),
|
||
dialog_template: currentTemplate('quizDialogTemplateGrid', 'data-dialog-template') || 'defaut',
|
||
page_template: currentTemplate('quizPageTemplateGrid', 'data-page-template') || 'manga',
|
||
};
|
||
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/quiz-config`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ config }),
|
||
})
|
||
.then((r) => r.json())
|
||
.then((data) => {
|
||
if (fullscreenChanged) { window.location.reload(); return; }
|
||
const el = document.getElementById(`el-${objectId}`);
|
||
const widget = el?.querySelector('.quizBoxWidget');
|
||
if (widget && data.rendered_html) widget.outerHTML = data.rendered_html;
|
||
});
|
||
}
|
||
|
||
function initPersonnageCommandsPanel() {
|
||
const body = document.getElementById('personnageCommandsBody');
|
||
if (!body) return;
|
||
const objectId = Number.parseInt(body.dataset.elementId, 10);
|
||
['cmd-haut', 'cmd-bas', 'cmd-gauche', 'cmd-droite', 'cmd-interagir'].forEach((id) => {
|
||
bindPersonnageKeyCapture(document.getElementById(id));
|
||
});
|
||
['cmd-haut', 'cmd-bas', 'cmd-gauche', 'cmd-droite', 'cmd-interagir', 'cmd-axe', 'cmd-vitesse'].forEach((id) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.addEventListener('change', saveSceneObjectCommands);
|
||
});
|
||
const container = document.getElementById('personnageCommandsExtra');
|
||
// Lu depuis data-commands-extra (posé sur CET élément par le serveur,
|
||
// voir templates/scene_edit.html) plutôt que la variable globale
|
||
// PERSONNAGE_COMMANDS_EXTRA (déclarée dans le <script> inline en bas de
|
||
// page, HORS de #builder3) : cette fonction est aussi rappelée après un
|
||
// changement de sélection SANS rechargement (voir initSceneBuilderPanel
|
||
// ci-dessous), qui ne remplace que #builder3 — cette variable globale
|
||
// resterait alors bloquée sur les commandes du PRÉCÉDENT objet
|
||
// sélectionné.
|
||
let extra = [];
|
||
try { extra = JSON.parse(body.dataset.commandsExtra || '[]'); } catch (e) { extra = []; }
|
||
extra.forEach((e) => {
|
||
container.appendChild(personnageCommandExtraRowEl(objectId, e.touche, e.animation));
|
||
});
|
||
}
|
||
initPersonnageCommandsPanel();
|
||
|
||
// Rappelée après chaque changement de sélection d'objet de scène SANS
|
||
// rechargement de page (voir onSceneObjectMouseDown -> goToSelected(),
|
||
// static/js/screen_edit/tree-panels.js::refreshBuilderPanel, qui l'appelle
|
||
// juste après avoir remplacé #builder3 et initBuilderPanel()) — reproduit
|
||
// ici ce que ce script fait déjà tout seul au chargement initial de la
|
||
// page (voir initScenePosFields()/initPersonnageCommandsPanel() ci-dessus)
|
||
// + relance l'aperçu animé de tous les personnages du canevas (leurs <img>
|
||
// viennent d'être recréés par le même remplacement, voir
|
||
// startAllPersonnagePreviews()/personnage-preview.js).
|
||
//
|
||
// .builderCanvasArea (les onglets "🖥️ Scène"/"Déclencheurs"/"📣 Événements")
|
||
// est ENTIÈREMENT à l'intérieur de #builder3 (voir templates/scene_edit.html)
|
||
// — le remplacer réinjecte donc aussi le conteneur des déclencheurs tel
|
||
// que le SERVEUR le rend, c'est-à-dire VIDE (rendu entièrement en JS,
|
||
// jamais en Jinja, voir trigger-editor.js) : sans le redessiner ici,
|
||
// l'onglet "Déclencheurs" semblait se "vider" à chaque sélection d'un
|
||
// objet de scène.
|
||
function initSceneBuilderPanel() {
|
||
initScenePosFields();
|
||
initPersonnageCommandsPanel();
|
||
if (typeof startAllPersonnagePreviews === 'function') startAllPersonnagePreviews();
|
||
if (typeof initTriggersTab === 'function') initTriggersTab();
|
||
}
|