Ajoute l'assistant IA "Ruby" (Claude + Scenario) et corrige plusieurs bugs de la scène
Build and deploy / test-python (push) Successful in 12m24s
Build and deploy / test-js (push) Successful in 1m9s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Intègre un chat IA capable de manipuler la scène via les mêmes fonctions
que l'éditeur manuel (objets, variables, déclencheurs, images générées),
avec conversations multiples par écran façon Claude. Corrige au passage
le rafraîchissement pjax hors-ordre, l'onglet IA/déclencheurs vide après
sélection d'un objet, la comparaison de booléens dans les conditions, et
le blocage du glisser-déposer hors du cadre caméra après un redimensionnement
de fond par l'IA.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-09 19:40:31 +02:00
co-authored by Claude Sonnet 5
parent ab6635eee0
commit b07b231a61
62 changed files with 2928 additions and 62 deletions
+334
View File
@@ -0,0 +1,334 @@
// ---------- Onglet "Générer avec l'IA" — chat scopé à L'ÉCRAN OUVERT ----------
// Persiste la conversation (routes/ia/ia_chat.py) et pilote l'agent
// Ruby (Claude Sonnet 5) via des tools qui appellent les mêmes fonctions
// que l'éditeur manuel (voir ai/tools.py) — ce que l'IA pose doit donc
// apparaître ICI sans recharger la page, sinon on croit que ça ne marche
// pas (demande explicite) : voir iaChatRefreshAfterTurn() plus bas.
//
// Plusieurs CONVERSATIONS par écran (demande explicite : gérer/créer/
// supprimer des fils, comme claude.ai) — _iaActiveConversationId retient
// laquelle est affichée dans le fil de droite.
var _iaActiveConversationId = null;
function iaChatEmptyStateHtml() {
return (
'<div class="iaChatEmpty">' +
'<img src="/static/branding/ruby-logo.svg" alt="" class="iaChatAvatar iaChatAvatar-large">' +
'<strong>Décris ce que tu veux à Ruby</strong>' +
'<span>Des images, des dialogues, une variable, ou un écran complet — décris-le en une phrase et envoie.</span>' +
'</div>'
);
}
// #iaChatLog défile lui-même (voir static/style.css — maquette validée :
// header/body/footer par colonne, seul le BODY de chaque colonne défile,
// jamais l'onglet entier).
function iaChatScrollToBottom() {
var log = document.getElementById('iaChatLog');
if (log) log.scrollTop = log.scrollHeight;
}
// "Ruby" — identité visuelle de l'IA (demande explicite) : même mark que
// Forge (static/branding/forge-logo.svg), recoloré rouge/rose pâle (voir
// static/branding/ruby-logo.svg), jamais un logo différent.
function iaChatSenderHtml(isUser) {
if (isUser) return 'Toi';
return '<img src="/static/branding/ruby-logo.svg" alt="" class="iaChatAvatar"> Ruby';
}
function iaChatRowHtml(message) {
var isUser = message.role === 'user';
var row = document.createElement('div');
row.className = 'iaChatRow ' + (isUser ? 'iaChatRow-user' : 'iaChatRow-assistant');
var sender = document.createElement('div');
sender.className = 'iaChatSender';
sender.innerHTML = iaChatSenderHtml(isUser);
var bubble = document.createElement('div');
bubble.className = 'iaChatBubble';
bubble.textContent = message.content;
row.appendChild(sender);
row.appendChild(bubble);
return row;
}
// Affichée à la place de la réponse le temps que Ruby/les outils
// travaillent (peut prendre plusieurs secondes, un écran complet enchaîne
// plusieurs appels d'outils) — sans ça, un créateur qui ne voit rien
// bouger clique à nouveau, pensant que ça n'a pas marché.
function iaChatLoaderRowHtml() {
var row = document.createElement('div');
row.className = 'iaChatRow iaChatRow-assistant';
var sender = document.createElement('div');
sender.className = 'iaChatSender';
sender.innerHTML = iaChatSenderHtml(false);
var bubble = document.createElement('div');
bubble.className = 'iaChatBubble iaChatTyping';
bubble.innerHTML = '<span></span><span></span><span></span>';
row.appendChild(sender);
row.appendChild(bubble);
return row;
}
function iaChatAutoGrow(textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
}
// Un tour peut poser des objets/variables/déclencheurs (voir ai/tools.py)
// — jamais su à l'avance lesquels depuis la seule réponse texte, donc on
// rafraîchit systématiquement les 3 zones que l'IA peut modifier :
// Déclencheurs (déjà un composant JS, triggerLoadAll() suffit), Variables
// et la scène (tous deux rendus côté serveur — on re-télécharge CETTE
// MÊME page et on ne récupère que ces deux fragments, jamais un
// rechargement visible/perte de l'onglet IA en cours).
function iaChatRefreshAfterTurn() {
if (typeof triggerLoadAll === 'function') triggerLoadAll();
fetch(window.location.href)
.then(function (r) { return r.text(); })
.then(function (html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var newCanvasFrame = doc.querySelector('.canvasFrame');
var oldCanvasFrame = document.querySelector('.canvasFrame');
if (newCanvasFrame && oldCanvasFrame) {
oldCanvasFrame.innerHTML = newCanvasFrame.innerHTML;
// Bug corrigé ("plus moyen de rien poser hors du champ de la
// caméra") : SCENE_WIDTH/SCENE_HEIGHT (posées une seule fois à
// l'ouverture de la page, voir templates/scene_edit.html) ne sont
// jamais rejouées par ce rafraîchissement partiel — un fond
// agrandi par l'IA (le "monde" devient plus grand que la caméra
// nominale) laissait ces globales à leur ANCIENNE valeur, et le
// glisser-déposer (scene-editor.js) continuait de tout borner à
// l'ancien cadre. #canvas vient d'être remplacé avec sa VRAIE
// taille à jour (voir world_width/world_height,
// routes/scenes/scene_edit_view.py) — on la relit directement
// dessus plutôt que de rejouer un script inline.
var canvas = document.getElementById('canvas');
if (canvas) {
var newWidth = parseFloat(canvas.style.width);
var newHeight = parseFloat(canvas.style.height);
if (!isNaN(newWidth)) window.SCENE_WIDTH = newWidth;
if (!isNaN(newHeight)) window.SCENE_HEIGHT = newHeight;
}
}
var newVariablesPanel = doc.getElementById('variablesTabPanel');
var oldVariablesPanel = document.getElementById('variablesTabPanel');
if (newVariablesPanel && oldVariablesPanel) oldVariablesPanel.innerHTML = newVariablesPanel.innerHTML;
// Relance les aperçus animés (voir personnage-preview.js) — un
// personnage tout juste posé par l'IA reste sur sa frame statique
// (ELEMENT_ANIMATIONS_MAP n'est mis à jour qu'au vrai rechargement
// de page), léger mais sans conséquence fonctionnelle.
if (typeof startAllPersonnagePreviews === 'function') startAllPersonnagePreviews();
});
}
function iaChatSend() {
var input = document.getElementById('iaChatInput');
var sendBtn = document.getElementById('iaChatSendBtn');
if (sendBtn.disabled) return; // déjà une requête en cours — jamais spammer l'IA
var message = (input.value || '').trim();
if (!message || !_iaActiveConversationId) return;
var log = document.getElementById('iaChatLog');
var emptyState = log.querySelector('.iaChatEmpty');
if (emptyState) emptyState.remove();
// Le message du créateur s'affiche TOUT DE SUITE (avant même la
// requête) — sinon, tant que l'IA travaille, l'écran semble ignorer ce
// qu'on vient de taper.
log.appendChild(iaChatRowHtml({ role: 'user', content: message }));
var loaderRow = iaChatLoaderRowHtml();
log.appendChild(loaderRow);
iaChatScrollToBottom();
input.value = '';
iaChatAutoGrow(input);
input.disabled = true;
sendBtn.disabled = true;
var conversationId = _iaActiveConversationId;
fetch('/game/' + GAME_SLUG + '/ia-conversations/' + conversationId + '/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: message }),
})
.then(function (r) { return r.json(); })
.then(function (json) {
loaderRow.remove();
if (json.assistant_message) log.appendChild(iaChatRowHtml(json.assistant_message));
iaChatScrollToBottom();
iaChatRefreshAfterTurn();
// Le titre de la conversation vient de se dériver de ce premier
// message (voir screens/ia/add_ia_chat_message.py) — recharge la
// colonne pour l'afficher sans attendre un changement d'onglet.
iaChatLoadConversationsList(conversationId);
})
.catch(function () {
loaderRow.remove();
log.appendChild(iaChatRowHtml({ role: 'assistant', content: 'Erreur réseau — réessaie dans un instant.' }));
iaChatScrollToBottom();
})
.finally(function () {
input.disabled = false;
sendBtn.disabled = false;
input.focus();
});
}
function iaChatInputKeydown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
iaChatSend();
}
}
// ---------- Colonne "Conversations" (CRUD, comme claude.ai) ----------
function iaChatConversationItemHtml(conversation) {
var active = conversation.id === _iaActiveConversationId;
return (
'<button type="button" class="iaConversationItem' + (active ? ' active' : '') + '" ' +
'data-conversation-id="' + conversation.id + '" onclick="iaChatSelectConversation(' + conversation.id + ')">' +
'<span class="iaConversationTitle">' + _iaEscapeHtml(conversation.title) + '</span>' +
'<span class="iaConversationDelete" title="Supprimer" ' +
'onclick="iaChatDeleteConversation(event, ' + conversation.id + ')">🗑️</span>' +
'</button>'
);
}
function _iaEscapeHtml(text) {
var div = document.createElement('div');
div.textContent = text || '';
return div.innerHTML;
}
function iaChatRenderConversationsList(conversations) {
var list = document.getElementById('iaConversationsList');
if (!list) return;
list.innerHTML = conversations.map(iaChatConversationItemHtml).join('');
}
function iaChatLoadConversationsList(activeIdOverride) {
fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations')
.then(function (r) { return r.json(); })
.then(function (conversations) {
if (activeIdOverride) _iaActiveConversationId = activeIdOverride;
iaChatRenderConversationsList(conversations);
});
}
function iaChatLoadMessages(conversationId) {
var log = document.getElementById('iaChatLog');
log.innerHTML = '';
fetch('/game/' + GAME_SLUG + '/ia-conversations/' + conversationId + '/messages')
.then(function (r) { return r.json(); })
.then(function (messages) {
if (!messages || !messages.length) {
log.innerHTML = iaChatEmptyStateHtml();
return;
}
messages.forEach(function (message) { log.appendChild(iaChatRowHtml(message)); });
iaChatScrollToBottom();
});
}
function iaChatSelectConversation(conversationId) {
_iaActiveConversationId = conversationId;
document.querySelectorAll('.iaConversationItem').forEach(function (el) { el.classList.remove('active'); });
var item = document.querySelector('.iaConversationItem[data-conversation-id="' + conversationId + '"]');
if (item) item.classList.add('active');
iaChatLoadMessages(conversationId);
}
function iaChatCreateConversation() {
fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations', { method: 'POST' })
.then(function (r) { return r.json(); })
.then(function (conversation) {
_iaActiveConversationId = conversation.id;
iaChatLoadConversationsList(conversation.id);
var log = document.getElementById('iaChatLog');
log.innerHTML = iaChatEmptyStateHtml();
document.getElementById('iaChatInput').focus();
});
}
function iaChatDeleteConversation(event, conversationId) {
event.stopPropagation(); // jamais sélectionner la conversation qu'on supprime
if (!confirm('Supprimer cette conversation ? Cette action est irréversible.')) return;
fetch('/game/' + GAME_SLUG + '/ia-conversations/' + conversationId, { method: 'DELETE' })
.then(function () { return fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations'); })
.then(function (r) { return r.json(); })
.then(function (conversations) {
if (conversationId === _iaActiveConversationId) {
if (conversations.length) {
_iaActiveConversationId = conversations[0].id;
iaChatLoadMessages(_iaActiveConversationId);
iaChatRenderConversationsList(conversations);
} else {
// Plus aucune conversation — en recrée une tout de suite (le
// fil de droite doit toujours pouvoir recevoir un message).
iaChatCreateConversation();
}
} else {
iaChatRenderConversationsList(conversations);
}
});
}
// Même patron que initTriggersTab() (trigger-editor.js) : appelé
// directement, pas de DOMContentLoaded — rejoué par pjax.js à chaque
// remplacement de <main>.
function initIaTab() {
var body = document.getElementById('iaTabPanelBody');
if (!body) return;
// Maquette validée (Artifact "Maquette Ruby") : une div pleine
// hauteur/largeur de l'onglet, 2 colonnes (gauche 40%, droite 60%),
// chacune header/body/footer. Les deux zones non précisées (footer
// gauche, header droite) restent vides pour l'instant.
body.innerHTML =
'<div class="iaTabLayout">' +
'<div class="iaCol iaCol-left">' +
'<div class="iaColHeader">' +
'<img src="/static/branding/ruby-logo.svg" alt="" class="iaHeaderLogo">' +
'<span class="iaHeaderName">Ruby</span>' +
'</div>' +
'<div class="iaColBody">' +
'<button type="button" class="button is-small primary iaNewConvBtn" onclick="iaChatCreateConversation()">+ Nouvelle conversation</button>' +
'<div class="iaConversationsList" id="iaConversationsList"></div>' +
'</div>' +
'<div class="iaColFooter"></div>' +
'</div>' +
'<div class="iaCol iaCol-right">' +
'<div class="iaColHeader"></div>' +
'<div class="iaColBody">' +
'<div class="iaChatLog" id="iaChatLog"></div>' +
'</div>' +
'<div class="iaColFooter">' +
'<div class="iaChatInputBar">' +
'<textarea id="iaChatInput" rows="1" placeholder="Décris ce que tu veux à Ruby…" ' +
'oninput="iaChatAutoGrow(this)" onkeydown="iaChatInputKeydown(event)"></textarea>' +
'<button type="button" class="iaChatSendBtn" id="iaChatSendBtn" onclick="iaChatSend()" title="Envoyer" aria-label="Envoyer">➤</button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations')
.then(function (r) { return r.json(); })
.then(function (conversations) {
if (!conversations.length) {
// Premier passage sur cet écran : aucune conversation encore —
// en crée une tout de suite, jamais un onglet vide sans rien à
// cliquer.
iaChatCreateConversation();
return;
}
_iaActiveConversationId = conversations[0].id; // la plus récente
iaChatRenderConversationsList(conversations);
iaChatLoadMessages(_iaActiveConversationId);
});
}
initIaTab();
@@ -41,12 +41,29 @@ test('compareValues — repli sur comparaison de chaînes si non numérique', ()
assert.equal(compareValues('abc', 'different', 'xyz'), true);
});
test('compareValues — "egal"/"different" sur du texte, insensible à la casse (bug corrigé)', () => {
assert.equal(compareValues('Vrai', 'egal', 'vrai'), true);
assert.equal(compareValues('VRAI', 'egal', 'vrai'), true);
assert.equal(compareValues('Vrai', 'different', 'vrai'), false);
assert.equal(compareValues('Vrai', 'egal', 'faux'), false);
});
test('compareValues — champ booléen, vocabulaire oui/non insensible à la casse', () => {
assert.equal(compareValues(true, 'egal', 'Oui', 'booleen'), true);
assert.equal(compareValues(false, 'egal', 'non', 'booleen'), true);
assert.equal(compareValues(true, 'different', 'non', 'booleen'), true);
});
test('compareValues — champ booléen, la valeur RÉELLE stockée en texte "faux" est bien fausse (bug corrigé)', () => {
// Une variable booléenne est stockée en texte ("vrai"/"faux") — la
// chaîne "faux" est non vide, donc VRAIE au sens JS naïf : sans
// normalisation des DEUX côtés, "faux" == "Vrai" ressortait vrai à tort.
assert.equal(compareValues('faux', 'egal', 'Vrai', 'booleen'), false);
assert.equal(compareValues('faux', 'different', 'Vrai', 'booleen'), true);
assert.equal(compareValues('vrai', 'egal', 'Vrai', 'booleen'), true);
assert.equal(compareValues('non', 'egal', 'oui', 'booleen'), false);
});
test('resolveVariablePath — chemin vide renvoie la valeur brute', () => {
assert.equal(resolveVariablePath('42', ''), '42');
});
+24 -9
View File
@@ -25,14 +25,22 @@ function readFieldValue(definitionId, rowId, fieldName) {
function compareValues(actual, operator, expected, fieldType) {
if (fieldType === 'booleen') {
const a = actual ? 1 : 0;
// Bug corrigé : une variable booléenne est stockée en TEXTE
// ("vrai"/"faux", voir gameData.variables) — `actual ? 1 : 0` prenait
// n'importe quelle chaîne non vide pour vraie, y compris la chaîne
// "faux" elle-même ! Les deux côtés doivent passer par la MÊME
// normalisation texte->booléen, pas seulement le côté "expected".
// "oui"/"non" (voir data_list.html) est le vocabulaire affiché
// partout ailleurs pour un champ booléen — une valeur de comparaison
// fixe tapée "Oui" doit donc être reconnue vraie ici aussi, pas
// seulement "1"/"true" (et insensible à la casse, aligné avec
// _compare() côté Python, voir filter_repeater_rows.py).
const expectedStr = String(expected).trim().toLowerCase();
const e = (expected === true || ['1', 'true', 'vrai', 'oui'].includes(expectedStr)) ? 1 : 0;
// partout ailleurs pour un champ booléen — une valeur tapée "Oui"
// doit donc être reconnue vraie ici aussi, pas seulement "1"/"true"
// (et insensible à la casse, aligné avec _compare() côté Python).
const normalizeBool = function (v) {
if (v === true) return 1;
if (v === false || v === null || v === undefined) return 0;
return ['1', 'true', 'vrai', 'oui'].includes(String(v).trim().toLowerCase()) ? 1 : 0;
};
const a = normalizeBool(actual);
const e = normalizeBool(expected);
return operator === 'different' ? a !== e : a === e;
}
const an = parseFloat(actual), en = parseFloat(expected);
@@ -49,9 +57,16 @@ function compareValues(actual, operator, expected, fieldType) {
}
const as = (actual === undefined || actual === null) ? '' : String(actual);
const es = (expected === undefined || expected === null) ? '' : String(expected);
// "egal"/"different" insensibles à la casse (bug corrigé : une valeur de
// comparaison texte tapée avec une casse différente de la valeur réelle
// — ex. "Vrai" vs "vrai" — était jugée différente à tort, alors que le
// créateur/l'IA n'a aucun moyen de connaître la casse EXACTE attendue).
// superieur/inferieur restent une comparaison lexicographique brute
// (l'ordre alphabétique dépend de la casse, hors du champ de ce bug).
const asLower = as.toLowerCase(), esLower = es.toLowerCase();
switch (operator) {
case 'egal': return as === es;
case 'different': return as !== es;
case 'egal': return asLower === esLower;
case 'different': return asLower !== esLower;
case 'superieur': return as > es;
case 'inferieur': return as < es;
case 'superieur_egal': return as >= es;
+67
View File
@@ -0,0 +1,67 @@
// ---------- Onglet "Variables" — CRUD en AJAX ----------
// Demande explicite : modifier/supprimer une variable rechargeait toute
// la page (formulaires classiques + navigation pjax) — remplacé par de
// vrais appels fetch(), sans jamais recharger ni naviguer.
// Enregistre automatiquement dès qu'un champ (type/valeur) change — plus
// besoin d'un bouton "Enregistrer" séparé (demande explicite : "que la
// modification s'enregistre automatiquement").
function saveGlobalVariable(varId) {
var row = document.querySelector('tr[data-var-row="' + varId + '"]');
if (!row) return;
var type = row.querySelector('select[name="type"]').value;
var value = row.querySelector('input[name="value"]').value;
var status = document.getElementById('varSaveStatus' + varId);
var body = new FormData();
body.append('type', type);
body.append('value', value);
fetch('/game/' + GAME_SLUG + '/variables/' + varId + '/edit', {
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: body,
})
.then(function (r) {
if (!r.ok) { window.location.reload(); return; }
if (status) {
status.textContent = 'Enregistré ✓';
setTimeout(function () { status.textContent = ''; }, 2000);
}
})
.catch(function () { window.location.reload(); });
}
function deleteGlobalVariable(varId, name) {
if (!confirm('Supprimer la variable « ' + name + ' » ? Toute condition/action qui la référence encore cessera de fonctionner.')) return;
var body = new FormData();
fetch('/game/' + GAME_SLUG + '/variables/' + varId + '/delete', {
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: body,
})
.then(function (r) {
if (!r.ok) { window.location.reload(); return; }
var row = document.querySelector('tr[data-var-row="' + varId + '"]');
if (row) row.remove();
var body2 = document.getElementById('globalVariablesBody');
var emptyHint = document.getElementById('globalVariablesEmptyHint');
if (body2 && emptyHint) emptyHint.style.display = body2.children.length ? 'none' : '';
})
.catch(function () { window.location.reload(); });
}
function createGlobalVariable(event) {
event.preventDefault();
var form = event.target;
var body = new FormData(form);
fetch('/game/' + GAME_SLUG + '/variables/new', {
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: body,
})
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
.then(function (json) {
if (!json.row_html) { window.location.reload(); return; }
var tbody = document.getElementById('globalVariablesBody');
tbody.insertAdjacentHTML('beforeend', json.row_html);
var emptyHint = document.getElementById('globalVariablesEmptyHint');
if (emptyHint) emptyHint.style.display = 'none';
form.reset();
form.style.display = 'none'; // même comportement que toggleDashCreate() après création
})
.catch(function () { window.location.reload(); });
return false;
}
+19 -1
View File
@@ -16,11 +16,14 @@
// 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) {
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,
})
@@ -62,6 +65,21 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug) {
.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
+7
View File
@@ -556,6 +556,13 @@ function refreshBuilderPanel(selectedId) {
// aperçu animé des personnages) — rien à faire ici pour l'éditeur
// document, où cette fonction n'existe simplement pas.
if (typeof initSceneBuilderPanel === "function") initSceneBuilderPanel();
// Bug corrigé : #builder3 contient AUSSI les onglets "Déclencheurs"
// et "Générer avec l'IA" (composants JS, pas de simple HTML statique)
// — curBuilder.innerHTML = ... ne réexécute aucun script, et sans cet
// appel explicite leur contenu restait vide (jusqu'à un vrai
// rechargement de page) dès qu'on sélectionnait un objet de la scène.
if (typeof initTriggersTab === "function") initTriggersTab();
if (typeof initIaTab === "function") initIaTab();
})
.catch(function () {
// Échec réseau : on retombe sur une vraie navigation plutôt que de