// ---------- É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) { var 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); fetch("/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/scene-objects/add", { method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: body, }) .then(function (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. var contentType = r.headers.get('content-type') || ''; if (!r.ok || contentType.indexOf('application/json') === -1) { window.location.reload(); return null; } return r.json(); }) .then(function (json) { if (!json) return; var canvas = document.getElementById('canvas'); var 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 = '
' + json.rendered_html + '
' + '
'; 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(function () { window.location.reload(); }); } // "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. function uploadUserAsset(inputEl) { var file = inputEl.files && inputEl.files[0]; if (!file) return; var body = new FormData(); body.append('file', file); fetch('/assets/upload', { method: 'POST', body: body }) .then(function (r) { return r.json(); }) .then(function () { window.location.reload(); }) .catch(function () { 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
, // 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(function (r) { var contentType = r.headers.get('content-type') || ''; if (!r.ok || contentType.indexOf('application/json') === -1) { window.location.reload(); return null; } return r.json(); }) .then(function (json) { if (!json || !json.ok) return; var 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. var propsBody = document.querySelector('#propsPanelFloat .floatPanelBody'); if (propsBody) { propsBody.innerHTML = '
Sélectionne un objet (sur la scène ou dans la liste) pour voir et modifier ses propriétés ici.
'; } CURRENT_SELECTED_ID = null; history.replaceState(null, '', SCREEN_EDIT_URL); }) .catch(function () { 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() { var wEl = document.getElementById('scene-camera-w'), hEl = document.getElementById('scene-camera-h'); var width = parseInt(wEl.value, 10) || 1, height = 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: width, height: height }), }).then(function () { 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: x, y: y, width: width, height: height }), }); } function updateScenePosFields(x, y, w, h) { var xEl = document.getElementById('scene-pos-x'), yEl = document.getElementById('scene-pos-y'); var wEl = document.getElementById('scene-pos-w'), 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) { var MARGIN = 40, SPEED = 14; var timer = setInterval(function () { var frame = document.querySelector('.canvasFrame'); if (!frame) return; var rect = frame.getBoundingClientRect(); var mouse = getMouse(); var dx = 0, 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(); var el = document.getElementById('el-' + id); var startX = e.clientX, startY = e.clientY; var lastClientX = e.clientX, lastClientY = e.clientY; var curLeft = parseFloat(el.style.left) || 0, curTop = parseFloat(el.style.top) || 0; var width = parseFloat(el.style.width) || 0, height = parseFloat(el.style.height) || 0; var 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. var 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) { var minLeft = Math.min(0, SCENE_WIDTH - width), maxLeft = Math.max(0, SCENE_WIDTH - width); var minTop = Math.min(0, SCENE_HEIGHT - height), 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) { var dx = ev.clientX - lastClientX, 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); } var stopAutoScroll = forgeStartCanvasAutoScroll( function () { return { 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(); var el = document.getElementById('el-' + id); var lastClientX = e.clientX, lastClientY = e.clientY; var curWidth = parseFloat(el.style.width) || 0, curHeight = parseFloat(el.style.height) || 0; var left = parseFloat(el.style.left) || 0, top = 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. var 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) { var dx = ev.clientX - lastClientX, dy = ev.clientY - lastClientY; lastClientX = ev.clientX; lastClientY = ev.clientY; applyDelta(dx, dy); } var stopAutoScroll = forgeStartCanvasAutoScroll( function () { return { x: lastClientX, y: lastClientY }; }, applyDelta ); function onUp() { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); stopAutoScroll(); el.style.zIndex = previousZIndex; var newWidth = curWidth, 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() { var xEl = document.getElementById('scene-pos-x'), yEl = document.getElementById('scene-pos-y'); var wEl = document.getElementById('scene-pos-w'), hEl = document.getElementById('scene-pos-h'); if (!xEl || !wEl || !CURRENT_SELECTED_ID) return; function apply() { var canvasEl = document.getElementById('el-' + CURRENT_SELECTED_ID); var x = parseFloat(xEl.value) || 0, y = parseFloat(yEl.value) || 0; var w = parseFloat(wEl.value) || 1, h = 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(function (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) { var body = document.getElementById('personnagePanelBody'); if (!body) return; var objectId = parseInt(body.dataset.elementId, 10); document.querySelectorAll('#personnageGallerySwap .iconGalleryTile').forEach(function (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(function (r) { return r.json(); }) .then(function (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) { var body = document.getElementById('personnagePanelBody'); if (!body) return; var objectId = parseInt(body.dataset.elementId, 10); fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/role', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: 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() { var body = document.getElementById('collisionPanelBody'); if (!body) return; var objectId = parseInt(body.dataset.elementId, 10); var enabled = document.getElementById('collision-enabled').checked; var shape = document.getElementById('collision-shape').value; var width = parseFloat(document.getElementById('collision-width').value) || 0; var height = parseFloat(document.getElementById('collision-height').value) || 0; var offsetX = parseFloat(document.getElementById('collision-offset-x').value) || 0; var offsetY = parseFloat(document.getElementById('collision-offset-y').value) || 0; var overlay = document.getElementById('collisionBoxOverlay'); var objEl = document.getElementById('el-' + objectId); if (overlay && objEl) { var objWidth = parseFloat(objEl.style.width) || 0, objHeight = 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: enabled, shape: shape, width: width, height: height, offset_x: offsetX, offset_y: offsetY }), }); } function updateCollisionFields(width, height, offsetX, offsetY) { var wEl = document.getElementById('collision-width'), hEl = document.getElementById('collision-height'); var oxEl = document.getElementById('collision-offset-x'), 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(); var overlay = document.getElementById('collisionBoxOverlay'); var objEl = document.getElementById('el-' + id); var objWidth = parseFloat(objEl.style.width) || 0, objHeight = parseFloat(objEl.style.height) || 0; var startX = e.clientX, startY = e.clientY; var startLeft = parseFloat(overlay.style.left) || 0, startTop = parseFloat(overlay.style.top) || 0; var boxWidth = parseFloat(overlay.style.width) || 0, boxHeight = 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); var offsetX = (parseFloat(overlay.style.left) || 0) - (objWidth - boxWidth) / 2; var offsetY = (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(); var overlay = document.getElementById('collisionBoxOverlay'); var objEl = document.getElementById('el-' + id); var objWidth = parseFloat(objEl.style.width) || 0, objHeight = parseFloat(objEl.style.height) || 0; var startX = e.clientX, startY = e.clientY; var startWidth = parseFloat(overlay.style.width) || 0, startHeight = parseFloat(overlay.style.height) || 0; var left = parseFloat(overlay.style.left) || 0, top = 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); var newWidth = parseFloat(overlay.style.width), newHeight = parseFloat(overlay.style.height); var offsetX = left - (objWidth - newWidth) / 2; var 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() { var body = document.getElementById('personnageCommandsBody'); if (!body) return; var objectId = parseInt(body.dataset.elementId, 10); var extra = []; document.querySelectorAll('#personnageCommandsExtra .personnageCommandExtraRow').forEach(function (row) { var toucheEl = row.querySelector('.personnageKeyCapture'); var animEl = row.querySelector('.cmdExtraAnim'); if (toucheEl && toucheEl.value && animEl && 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: 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', function () { var 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) { var row = document.createElement('div'); row.className = 'field is-grouped personnageCommandExtraRow'; var animNames = Object.keys(ELEMENT_ANIMATIONS_MAP[objectId] || {}); var options = animNames.map(function (name) { return ''; }).join(''); row.innerHTML = '
' + '
' + '
'; var keyInput = row.querySelector('.personnageKeyCapture'); bindPersonnageKeyCapture(keyInput); keyInput.addEventListener('change', saveSceneObjectCommands); row.querySelector('.cmdExtraAnim').addEventListener('change', saveSceneObjectCommands); return row; } function addPersonnageCommandExtraRow() { var body = document.getElementById('personnageCommandsBody'); var container = document.getElementById('personnageCommandsExtra'); if (!body || !container) return; var objectId = 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: 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 (