// ---------- É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) {
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);
// 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: 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(); });
}
// ---------- 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
// 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: 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();
var raw = ev.dataTransfer.getData('application/json');
if (!raw) return;
var data;
try { data = JSON.parse(raw); } catch (e) { return; }
var canvas = document.getElementById('canvas');
var 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.
var x = ev.clientX - rect.left;
var 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) {
var file = inputEl.files && inputEl.files[0];
if (!file) return;
var body = new FormData();
body.append('file', file);
if (sceneKind) body.append('scene_kind', sceneKind);
fetch('/assets/upload', { method: 'POST', body: body })
.then(function (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.
return r.json().then(function (json) { return { ok: r.ok, json: json }; });
})
.then(function (result) {
if (!result.ok) { alert(result.json.error || "Impossible d'importer ce fichier."); return; }
window.location.reload();
})
.catch(function () { 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 (!confirm('Supprimer "' + name + '" ? Cette action est définitive.')) return;
fetch('/assets/' + assetId, { method: 'DELETE' })
.then(function (r) { return r.json().then(function (json) { return { ok: r.ok, json: json }; }); })
.then(function (result) {
if (!result.ok) { alert(result.json.error || "Impossible de supprimer ce fichier."); return; }
window.location.reload();
})
.catch(function () { 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(function (r) { return r.json().then(function (json) { return { ok: r.ok, json: json }; }); })
.then(function (result) {
if (!result.ok) { alert(result.json.error || "Reclassement impossible."); return; }
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 }),
}).then(function (r) { return r.json(); }).then(function (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;
var 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) {
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 '' + name + ' ';
}).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 (