Supprime le type d'écran "document" et recentre le produit sur le jeu 2D
Build and deploy / test-python (push) Successful in 9m41s
Build and deploy / test-js (push) Successful in 1m0s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Le document de cadrage produit cible des formateurs non techniques créant
des serious games/quiz gamifiés — l'éditeur générique "document" (blocs de
logique en nœuds, timeline d'animation, définitions d'objets/relations,
templates réutilisables) est une complexité hors cible que l'effort
d'ingénierie récent avait déjà abandonnée au profit du jeu_2d.

- Onboarding : ne garde que le parcours "RPG" (jeu_2d), retire
  Quiz/Embranchement/Créer mon jeu de A à Z (tous document-only)
- Suppression en bloc des modules exclusifs au document : routes/elements,
  routes/element_types, routes/objects, routes/legacy_actions,
  screens/elements, screens/element_types, screens/widgets,
  screens/legacy_actions, le rendu render_element_html.py et son cluster,
  templates/screen_edit.html, templates/game_dashboard.html,
  flow-editor.js/tabs-and-blocks.js/animation-timeline.js
- Dashboard toujours simplifié (un seul mode possible désormais)
- Tests document-only supprimés, tests de logique partagée (flow,
  événements personnalisés, animations) retargetés sur des écrans jeu_2d
- Aucune régression jeu_2d : 299 tests passent

Carte d'onboarding retravaillée : argumentaire RH non technique (liste à
coche, badge "Compatible LMS"), taille et interaction de retournement
ajustées.
This commit is contained in:
william
2026-09-04 20:56:32 +02:00
parent 00f7191a8b
commit 50835a18e2
221 changed files with 1239 additions and 12245 deletions
+44 -17
View File
@@ -18,7 +18,7 @@
// de la quête au moment où le joueur parle au PNJ).
var QUEST_STATUS_LABELS = { nouvelle: 'Nouvelle', en_cours: 'En cours', terminee: 'Terminée' };
var QUEST_RESULT_LABELS = { reussi: '✅ Réussi', echoue: '❌ Échoué' };
var QUEST_RESULT_LABELS = { reussi: 'Réussi', echoue: 'Échoué' };
var QUEST_STATUS_ORDER = ['nouvelle', 'en_cours', 'terminee'];
// Quêtes connues côté client — préremplies par QUESTS_JSON (voir
@@ -52,7 +52,7 @@ function questListRowHtml(quest) {
'<tr class="questListRow" onclick="openQuestFieldsModal(' + quest.id + ')">' +
'<td><strong>' + (quest.title || '').replace(/</g, '&lt;') + '</strong></td>' +
'<td>' + (quest.objectif ? quest.objectif.replace(/</g, '&lt;') : '<span class="hint">—</span>') + '</td>' +
'<td><span class="questPill questPill-score">🏆 +' + (quest.recompense_score || 0) + '</span></td>' +
'<td><span class="questPill questPill-score">+' + (quest.recompense_score || 0) + '</span></td>' +
'<td><span class="questPill questPill-status questPill-status-' + quest.status + '">' + QUEST_STATUS_LABELS[quest.status] + '</span></td>' +
'<td>' + resultatHtml + '</td>' +
'<td class="fieldsTableActions">' +
@@ -129,7 +129,27 @@ function ensureQuestFieldsModal() {
// `onUse(quest)` optionnel : posé par collisionWizardChooseAction('quete', ...)
// pour finaliser la règle de collision avec cette quête, affiche alors un
// bouton "✓ Utiliser cette quête" dans les deux modales.
//
// Masque #collisionRuleModal s'il est ouvert, SANS effacer _collisionWizard
// (contrairement à closeCollisionRuleModal(), static/js/scenes/
// collision-rules-editor.js) : appelée depuis questPickerChoose()/
// questPickerCreate() (assistant "+ Action" de l'onglet Collision, action
// "quete" — voir renderQuestPickerStep()), CETTE modale et
// #collisionRuleModal partagent le même z-index (.modal de base) — sans la
// masquer, les deux restaient actives en même temps, empilées dans l'ordre
// du DOM plutôt que l'une à la place de l'autre : le bouton "+ Action"
// semblait ne plus rien faire ensuite (il rouvrait bien #collisionRuleModal,
// mais caché derrière celle-ci restée active). _collisionWizard doit
// SURVIVRE à ce masquage : "✓ Utiliser cette quête" (questUseThisQuest)
// finalise la règle en cours via collisionWizardFinalizeAction(), qui en a
// encore besoin (objectId, trigger, pendingInteragir pour "interagir").
function _hideCollisionRuleModalKeepingWizardState() {
var modal = document.getElementById('collisionRuleModal');
if (modal) modal.classList.remove('is-active');
}
function openQuestFieldsModal(questId, onUse) {
_hideCollisionRuleModalKeepingWizardState();
_questModalOnUse = onUse || null;
_questModalCurrentId = questId;
var modal = ensureQuestFieldsModal();
@@ -179,16 +199,16 @@ function renderQuestFieldsModalBody(questId) {
var body = document.getElementById('questFieldsModalBody');
if (!quest || !body) return;
var useBtn = _questModalOnUse
? '<button type="button" class="collisionRuleAddBtn" onclick="questUseThisQuest(' + quest.id + ')">✓ Utiliser cette quête</button>'
? '<button type="button" class="collisionRuleAddBtn" onclick="questUseThisQuest(' + quest.id + ')">Utiliser cette quête</button>'
: '';
body.innerHTML =
'<div class="fieldRow questDialogueModalHeader" style="align-items:center; justify-content:space-between;">' +
'<h2 class="title is-5" style="margin:0;">🗺️ ' + (quest.title || '').replace(/</g, '&lt;') + '</h2>' +
'<h2 class="title is-5" style="margin:0;">' + (quest.title || '').replace(/</g, '&lt;') + '</h2>' +
'<button type="button" class="button is-small" onclick="closeQuestFieldsModal()">✕</button>' +
'</div>' +
questFieldRowHtml(quest) +
'<div class="fieldRow" style="justify-content:space-between; align-items:center; margin-top:6px;">' +
'<button type="button" class="collisionRuleAddBtn" onclick="openQuestDialogueModal(' + quest.id + ')">💬 Ajouter les dialogues</button>' +
'<button type="button" class="collisionRuleAddBtn" onclick="openQuestDialogueModal(' + quest.id + ')">Ajouter les dialogues</button>' +
useBtn +
'</div>';
}
@@ -212,7 +232,7 @@ function questSpeakerOptions(currentSpeaker) {
function questDialogueBubbleHtml(line, status, index) {
var kind = questBubbleKind(line.speaker);
var speakerSelect = questSpeakerOptions(line.speaker).map(function (name) {
return '<option value="' + name.replace(/"/g, '&quot;') + '"' + (name === line.speaker ? ' selected' : '') + '>' + (name === 'Joueur' ? '🧑 Joueur' : '🗨️ ' + name) + '</option>';
return '<option value="' + name.replace(/"/g, '&quot;') + '"' + (name === line.speaker ? ' selected' : '') + '>' + (name === 'Joueur' ? 'Joueur' : name) + '</option>';
}).join('');
return (
'<div class="questBubbleRow">' +
@@ -262,9 +282,9 @@ function questQuestionBubbleHtml(line, status, index) {
'<div class="questBubbleRow">' +
'<div class="questBubble questBubble-question">' +
'<div class="questBubbleHeader">' +
'<label class="questQuestionHeaderField" title="Nombre de choix">🔢 <input type="number" min="2" max="4" value="' + line.choices.length + '" ' +
'<label class="questQuestionHeaderField" title="Nombre de choix"><input type="number" min="2" max="4" value="' + line.choices.length + '" ' +
'onchange="questSetQuestionChoiceCount(\'' + status + '\', ' + index + ', this.value)"></label>' +
'<label class="questQuestionHeaderField" title="Récompense (points de score)">🏆 <input type="number" min="0" value="' + line.reward_amount + '" ' +
'<label class="questQuestionHeaderField" title="Récompense (points de score)"><input type="number" min="0" value="' + line.reward_amount + '" ' +
'onchange="questSetQuestionRewardAmount(\'' + status + '\', ' + index + ', this.value)"> pts</label>' +
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="questDeleteBubble(\'' + status + '\', ' + index + ')">✕</button>' +
'</div>' +
@@ -288,8 +308,8 @@ function questDialogueColumnHtml(quest, status) {
'<h4 class="questDialogueColumnTitle">' + QUEST_STATUS_LABELS[status] + '</h4>' +
'<div class="questDialogueColumnBubbles" id="questDialogueColumn-' + status + '">' + bubblesHtml + '</div>' +
'<div class="questDialogueColumnButtons">' +
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn" onclick="questAddBubble(\'' + status + '\')">✚ Réplique</button>' +
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn questQuestionAddBtn" onclick="questAddQuestion(\'' + status + '\')">❓ Question</button>' +
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn" onclick="questAddBubble(\'' + status + '\')">Réplique</button>' +
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn questQuestionAddBtn" onclick="questAddQuestion(\'' + status + '\')">Question</button>' +
'</div>' +
'</div>'
);
@@ -338,13 +358,13 @@ function renderQuestDialogueModalBody(questId) {
if (!quest || !body) return;
_questModalCurrentId = questId;
var useBtn = _questModalOnUse
? '<button type="button" class="collisionRuleAddBtn" onclick="questUseThisQuest(' + quest.id + ')">✓ Utiliser cette quête</button>'
? '<button type="button" class="collisionRuleAddBtn" onclick="questUseThisQuest(' + quest.id + ')">Utiliser cette quête</button>'
: '';
body.innerHTML =
'<div class="fieldRow questDialogueModalHeader" style="align-items:center; justify-content:space-between;">' +
'<div style="display:flex; align-items:center; gap:10px;">' +
'<button type="button" class="button is-small" onclick="questBackToFieldsModal(' + quest.id + ')" title="Retour à la fiche">←</button>' +
'<h2 class="title is-5" style="margin:0;">🗺️ ' + (quest.title || '').replace(/</g, '&lt;') + ' — dialogues</h2>' +
'<h2 class="title is-5" style="margin:0;">' + (quest.title || '').replace(/</g, '&lt;') + ' — dialogues</h2>' +
'</div>' +
'<div style="display:flex; gap:8px; align-items:center;">' + useBtn +
'<button type="button" class="button is-small" onclick="closeQuestDialogueModal()">✕</button>' +
@@ -520,7 +540,7 @@ function questPickerHtml(quests) {
}
return '<div class="collisionWizardChoices">' + quests.map(function (q, i) {
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="questPickerChoose(' + q.id + ')">' +
'<span class="emoji">🗺️</span><span>' + (q.title || '').replace(/</g, '&lt;') + '</span>' +
'<span>' + (q.title || '').replace(/</g, '&lt;') + '</span>' +
'</button>';
}).join('') + '</div>';
}
@@ -531,7 +551,7 @@ function renderQuestPickerStep(onUse) {
_questPickerOnUse = onUse;
var body = collisionWizardBodyEl();
body.innerHTML = '<p class="hint mb-2">Quelle quête déclencher ?</p><div id="questPickerList">Chargement…</div>' +
'<button type="button" class="collisionRuleAddBtn mt-2" onclick="questPickerCreate()">✚ Nouvelle quête</button>';
'<button type="button" class="collisionRuleAddBtn mt-2" onclick="questPickerCreate()">Nouvelle quête</button>';
fetch('/game/' + GAME_SLUG + '/quests/list.json')
.then(function (r) { return r.json(); })
.then(function (json) {
@@ -556,6 +576,13 @@ function questPickerCreate() {
});
}
document.addEventListener('DOMContentLoaded', function () {
if (typeof QUESTS_JSON !== 'undefined') renderQuestListPage();
});
// Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé par
// un <script src> posé à la fin du contenu de la page — les éléments dont
// on a besoin existent déjà à ce stade. Nécessaire aussi pour la
// navigation pjax (voir static/pjax.js — runScriptsIn ré-exécute ce
// script à chaque page swappée, mais DOMContentLoaded, lui, ne se
// redéclenche jamais après le tout premier chargement réel) — sans ça,
// l'onglet "🗺️ Quêtes" restait vide après un remplacement de <main> (ex.
// suppression d'un objet de scène, voir scene_object_delete.py) qui ne
// redéclenche jamais cet événement.
if (typeof QUESTS_JSON !== 'undefined') renderQuestListPage();
+49 -26
View File
@@ -9,15 +9,15 @@
// elle est validée une seconde fois avant d'être enregistrée).
var COLLISION_ACTION_LABELS = {
quete: '🗺️ Déclencher une quête', attaque: '⚔️ Attaquer la cible',
evenement: '📣 Déclencher un événement', interagir: '🔑 Interagir avec une touche',
quete: 'Déclencher une quête', attaque: 'Attaquer la cible',
evenement: 'Déclencher un événement', interagir: 'Interagir avec une touche',
};
// "attaque"/"evenement" retirés de l'assistant (pas besoin pour le
// moment, demande explicite) — server-side (screens/rendering/
// collision_rules.py::ACTION_TYPES) les accepte encore telles quelles,
// pour ne jamais casser une règle déjà enregistrée avec l'un des deux.
var COLLISION_WIZARD_ACTION_TYPES = ['quete', 'interagir'];
var COLLISION_TRIGGER_LABELS = { collision: '💥 À la collision', perimetre: '📍 Dans un périmètre' };
var COLLISION_TRIGGER_LABELS = { collision: 'À la collision', perimetre: 'Dans un périmètre' };
function collisionRuleObjectLabel(obj) {
var kindLabel = obj.kind === 'personnage' ? (obj.role === 'ennemie' ? 'Ennemi' : 'PNJ') : 'Décor';
@@ -52,15 +52,26 @@ function collisionRuleActionSummary(action) {
function collisionRuleChainHtml(rule, ruleIndex, objectId) {
var links = [
{ kind: 'trigger', text: collisionRuleTriggerSummary(rule) },
{ kind: rule.action.type, text: collisionRuleActionSummary(rule.action) },
{ kind: rule.action.type, text: collisionRuleActionSummary(rule.action), questId: rule.action.quete_id },
];
var action = rule.action;
while (action.type === 'interagir' && action.sub_action) {
links.push({ kind: action.sub_action.type, text: collisionRuleActionSummary(action.sub_action) });
links.push({ kind: action.sub_action.type, text: collisionRuleActionSummary(action.sub_action), questId: action.sub_action.quete_id });
action = action.sub_action;
}
// Un maillon "quete" (kind==='quete') ouvre directement sa modale de
// dialogue (voir openQuestDialogueModal(), static/js/quests/
// quest-editor.js) au clic — demande explicite, plutôt qu'un simple
// texte informatif comme les autres maillons. Reste un <span> comme les
// autres (jamais un <button>, dont le style natif — apparence système,
// marges par défaut — grignotait la pointe en clip-path malgré les
// resets CSS) : un onclick sur un <span> suffit à le rendre cliquable,
// ".collisionRuleLink[data-kind='quete']" ajoute juste le curseur/hover
// qui le distingue visuellement (voir static/style.css).
var linksHtml = links.map(function (link, i) {
return '<span class="collisionRuleLink" data-kind="' + link.kind + '" style="animation-delay:' + (i * 60) + 'ms">' +
var clickable = link.kind === 'quete' && link.questId != null;
var onclick = clickable ? ' onclick="openQuestDialogueModal(' + link.questId + ')"' : '';
return '<span class="collisionRuleLink" data-kind="' + link.kind + '" style="animation-delay:' + (i * 60) + 'ms"' + onclick + '>' +
link.text.replace(/</g, '&lt;') + '</span>';
}).join('');
return (
@@ -72,6 +83,21 @@ function collisionRuleChainHtml(rule, ruleIndex, objectId) {
);
}
// obj.thumbnail_html (screens.render_scene_object) porte un style inline
// PENSÉ POUR LE CANEVAS (position:absolute + left/top en dur, voir
// screens/scenes/render_scene_object.py) — un personnage déplacé loin
// dans la scène (left/top de plusieurs centaines de px) ressortait donc
// hors du petit cadre 120×120 de cette miniature : la règle CSS
// `.collisionRuleCardThumb img` (voir static/style.css) est censée le
// neutraliser (position:static !important etc.), mais s'appuyer sur un
// forçage CSS pour annuler un style inline entier reste fragile. Plus
// robuste : reconstruire ici une balise <img> "propre", sans AUCUN style
// hérité du canevas — seul le src (l'image elle-même) est repris.
function collisionRuleThumbHtml(obj) {
var match = /\ssrc="([^"]*)"/.exec(obj.thumbnail_html || '');
return match ? '<img src="' + match[1] + '" alt="">' : '';
}
function collisionRuleCardHtml(obj) {
var rulesHtml = obj.rules.length
? obj.rules.map(function (rule, i) { return collisionRuleChainHtml(rule, i, obj.id); }).join('')
@@ -79,11 +105,11 @@ function collisionRuleCardHtml(obj) {
return (
'<div class="collisionRuleCard" id="collisionRuleCard-' + obj.id + '">' +
'<div class="collisionRuleCardHeader">' +
'<div class="collisionRuleCardThumb">' + obj.thumbnail_html + '</div>' +
'<div class="collisionRuleCardThumb">' + collisionRuleThumbHtml(obj) + '</div>' +
'<h3 class="collisionRuleCardTitle">' + collisionRuleObjectLabel(obj).replace(/</g, '&lt;') + '</h3>' +
'</div>' +
'<div class="collisionRuleCardRules">' + rulesHtml + '</div>' +
'<button type="button" class="collisionRuleAddBtn" onclick="openCollisionRuleModal(' + obj.id + ')">✚ Action</button>' +
'<button type="button" class="collisionRuleAddBtn" onclick="openCollisionRuleModal(' + obj.id + ')">Action</button>' +
'</div>'
);
}
@@ -154,24 +180,15 @@ function collisionWizardBodyEl() {
// Étape 1 — déclencheur : collision directe, ou périmètre (px réglable,
// valeur par défaut COLLISION_DEFAULT_PERIMETRE_PX).
// Sépare "emoji" et "libellé" d'un texte "🗺️ Déclencher une quête" pour
// les afficher sur deux lignes dans une carte-choix (voir .collisionWizardChoice).
function collisionSplitEmojiLabel(text) {
var m = text.match(/^(\S+)\s+(.*)$/);
return m ? { emoji: m[1], label: m[2] } : { emoji: '', label: text };
}
function renderCollisionWizardTriggerStep() {
var collision = collisionSplitEmojiLabel(COLLISION_TRIGGER_LABELS.collision);
var perimetre = collisionSplitEmojiLabel(COLLISION_TRIGGER_LABELS.perimetre);
collisionWizardBodyEl().innerHTML =
'<p class="hint mb-2">Cette règle se déclenche...</p>' +
'<div class="collisionWizardChoices">' +
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChooseTrigger(\'collision\')">' +
'<span class="emoji">' + collision.emoji + '</span><span>' + collision.label + '</span>' +
'<span>' + COLLISION_TRIGGER_LABELS.collision + '</span>' +
'</button>' +
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="collisionWizardChooseTrigger(\'perimetre\')">' +
'<span class="emoji">' + perimetre.emoji + '</span><span>' + perimetre.label + '</span>' +
'<span>' + COLLISION_TRIGGER_LABELS.perimetre + '</span>' +
'<span class="fieldRow" style="align-items:center; gap:6px;" onclick="event.stopPropagation()">' +
'<input type="number" min="1" id="collisionWizardPerimetrePx" value="' + COLLISION_DEFAULT_PERIMETRE_PX + '" style="width:64px;" class="input is-small">' +
'<span class="hint">px</span>' +
@@ -196,9 +213,8 @@ function collisionWizardChooseTrigger(trigger) {
function renderCollisionWizardActionStep(nested) {
var types = nested ? COLLISION_WIZARD_ACTION_TYPES.filter(function (t) { return t !== 'interagir'; }) : COLLISION_WIZARD_ACTION_TYPES;
var buttons = types.map(function (type, i) {
var split = collisionSplitEmojiLabel(COLLISION_ACTION_LABELS[type]);
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardChooseAction(\'' + type + '\', ' + (nested ? 'true' : 'false') + ')">' +
'<span class="emoji">' + split.emoji + '</span><span>' + split.label + '</span>' +
'<span>' + COLLISION_ACTION_LABELS[type] + '</span>' +
'</button>';
}).join('');
collisionWizardBodyEl().innerHTML =
@@ -253,18 +269,25 @@ function collisionWizardChooseAction(type, nested) {
if (type === 'evenement') {
var eventIds = Object.keys(CUSTOM_EVENTS_MAP);
if (!eventIds.length) {
collisionWizardBodyEl().innerHTML = '<p class="hint">Aucun événement personnalisé pour l\'instant — crée-en un dans l\'onglet "📣 Événements", puis reviens ici.</p>';
collisionWizardBodyEl().innerHTML = '<p class="hint">Aucun événement personnalisé pour l\'instant — crée-en un dans l\'onglet "Événements", puis reviens ici.</p>';
return;
}
var buttons = eventIds.map(function (id, i) {
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardFinalizeAction({type:\'evenement\', evenement_id:' + id + '}, ' + (nested ? 'true' : 'false') + ')">' +
'<span class="emoji">📣</span><span>' + CUSTOM_EVENTS_MAP[id].name.replace(/</g, '&lt;') + '</span>' +
'<span>' + CUSTOM_EVENTS_MAP[id].name.replace(/</g, '&lt;') + '</span>' +
'</button>';
}).join('');
collisionWizardBodyEl().innerHTML = '<p class="hint mb-2">Quel événement ?</p><div class="collisionWizardChoices">' + buttons + '</div>';
}
}
document.addEventListener('DOMContentLoaded', function () {
if (typeof COLLISION_RULE_OBJECTS !== 'undefined') renderCollisionRuleCards();
});
// Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé par
// un <script src> posé à la fin du contenu de la page — les éléments dont
// on a besoin existent déjà à ce stade. Nécessaire aussi pour la
// navigation pjax (voir static/pjax.js — runScriptsIn ré-exécute ce
// script à chaque page swappée, mais DOMContentLoaded, lui, ne se
// redéclenche jamais après le tout premier chargement réel) — sans ça,
// l'onglet "🧩 Collision" restait vide après un remplacement de <main>
// (ex. suppression d'un objet de scène, voir scene_object_delete.py) qui
// ne redéclenche jamais cet événement.
if (typeof COLLISION_RULE_OBJECTS !== 'undefined') renderCollisionRuleCards();
+184 -16
View File
@@ -8,16 +8,97 @@
// 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) {
var form = document.createElement('form');
form.method = 'post';
form.action = "/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/scene-objects/add";
var html = '<input type="hidden" name="kind" value="' + kind + '">';
if (forgeCharacter) html += '<input type="hidden" name="forge_character" value="' + forgeCharacter + '">';
if (backgroundSlug) html += '<input type="hidden" name="background_slug" value="' + backgroundSlug + '">';
form.innerHTML = html;
document.body.appendChild(form);
form.requestSubmit();
var body = new FormData();
body.append('kind', kind);
if (forgeCharacter) body.append('forge_character', forgeCharacter);
if (backgroundSlug) body.append('background_slug', backgroundSlug);
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 =
'<div class="canvasElementInner">' + json.rendered_html + '</div>' +
'<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, ' + json.id + ')"></div>';
canvas.appendChild(el);
refreshCollisionRuleObjects();
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(); });
}
// 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(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);
refreshCollisionRuleObjects();
// 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 = '<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;
history.replaceState(null, '', SCREEN_EDIT_URL);
})
.catch(function () { window.location.reload(); });
}
// Taille de la caméra (voir "🎮 Jeu 2D" en haut de templates/
@@ -152,7 +233,17 @@ function onSceneObjectMouseDown(e, id) {
updateScenePosFields(curLeft, curTop, width, height);
saveSceneObjectGeometry(id, curLeft, curTop, width, height);
} else {
window.location.href = SCREEN_EDIT_URL + '?selected=' + id;
// 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);
@@ -208,7 +299,7 @@ function onSceneObjectResizeMouseDown(e, id) {
// 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 () {
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;
@@ -223,7 +314,8 @@ function onSceneObjectResizeMouseDown(e, id) {
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
@@ -263,7 +355,25 @@ function saveSceneObjectRole(role) {
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/role', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: role }),
});
}).then(refreshCollisionRuleObjects);
}
// Réutilisé après ajout/suppression/changement de rôle ou de nom (voir
// routes/scenes/scene_collision_rule_objects.py) : un changement de rôle
// peut faire apparaître/disparaître une carte (passage à/depuis "joueur",
// jamais dans "🧩 Collision") et un changement de nom change son libellé
// affiché — recalculée entièrement côté serveur plutôt que rejouée en JS
// (même logique déjà nécessaire pour le rôle implicite d'une scène posée
// avant ce réglage, voir build_collision_rule_objects) SANS recharger la
// page.
function refreshCollisionRuleObjects() {
if (typeof COLLISION_RULE_OBJECTS === 'undefined') return;
fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/collision-rule-objects')
.then(function (r) { return r.json(); })
.then(function (json) {
COLLISION_RULE_OBJECTS = json;
renderCollisionRuleCards();
});
}
// Boîte de collision (voir screens/rendering/collision_settings.py,
@@ -475,9 +585,27 @@ 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(function () {
refreshCollisionRuleObjects();
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 la modale de dialogue dans la MÊME
// session ne faisait jamais apparaître son nouveau nom dans "qui parle"
// (voir questSpeakerOptions(), static/js/quests/quest-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(function (r) { return r.json(); })
.then(function (json) { SCENE_OBJECT_NAMES = json; });
}
// "🎨 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
@@ -509,7 +637,7 @@ function saveDialogueBoxStyle(objectId) {
});
}
(function () {
function initPersonnageCommandsPanel() {
var body = document.getElementById('personnageCommandsBody');
if (!body) return;
var objectId = parseInt(body.dataset.elementId, 10);
@@ -521,7 +649,47 @@ function saveDialogueBoxStyle(objectId) {
if (el) el.addEventListener('change', saveSceneObjectCommands);
});
var container = document.getElementById('personnageCommandsExtra');
(window.PERSONNAGE_COMMANDS_EXTRA || []).forEach(function (e) {
// 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é.
var extra = [];
try { extra = JSON.parse(body.dataset.commandsExtra || '[]'); } catch (e) { extra = []; }
extra.forEach(function (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"/"🧩 Collision"/"🗺️ Quêtes"/
// "📣 Événements") est ENTIÈREMENT à l'intérieur de #builder3 (voir
// templates/scene_edit.html) — le remplacer réinjecte donc aussi
// #collisionRulesCards/le conteneur des quêtes tels que le SERVEUR les
// rend, c'est-à-dire VIDES (ces listes sont rendues entièrement en JS,
// jamais en Jinja, voir collision-rules-editor.js/quest-editor.js) : sans
// les redessiner ici, les onglets "🧩 Collision"/"🗺️ Quêtes" semblaient se
// "vider" à chaque sélection d'un objet de scène. COLLISION_RULE_OBJECTS/
// QUESTS_JSON eux-mêmes ne sont PAS affectés par ce remplacement (déclarés
// dans le <script> inline en bas de page, HORS de #builder3) — il suffit
// donc de rejouer leur rendu, pas de les recharger.
function initSceneBuilderPanel() {
initScenePosFields();
initPersonnageCommandsPanel();
if (typeof startAllPersonnagePreviews === 'function') startAllPersonnagePreviews();
if (typeof renderCollisionRuleCards === 'function') renderCollisionRuleCards();
if (typeof renderQuestListPage === 'function') renderQuestListPage();
}
-404
View File
@@ -1,404 +0,0 @@
// ---------- Timeline d'animation : piste par élément, clips Animate.css ou
// personnalisés (images-clés), glissés/redimensionnés à la souris sur un
// axe du temps en secondes depuis l'affichage de l'écran. Même principe de
// panneau rétractable/redimensionnable que "Logique de la scène"
// ci-dessus, positionné juste en dessous. ----------
// ANIM_CLIPS/ANIMATE_CSS_LABELS/GAME_SLUG : voir le petit <script> inline
// en tête de screen_edit.html (données Jinja).
var ANIM_SCREEN_ID = SCREEN_ID;
var ANIM_PX_PER_SEC = 60;
var animCurrentClipId = null;
// Phase 7 — aperçu d'un clip "sprite" dans l'éditeur (voir previewAnimClip
// ci-dessous) : un intervalle à la fois suffit ici (contrairement au
// moteur de jeu, static/js/play/actions.js, PAS chargé sur cette page —
// runSpriteAnimation()/activeSpriteAnimations n'existent que côté jeu).
var animPreviewSpriteInterval = null;
function animUrl(path) { return "/game/" + GAME_SLUG + path; }
function animClipLabel(clip) {
if (clip.kind === 'custom') return '🎨 Personnalisée';
if (clip.kind === 'sprite') return '🏃 Sprite';
return '🎞️ ' + (ANIMATE_CSS_LABELS[clip.animate_name] || clip.animate_name);
}
function renderAnimTimeline() {
var lanesWrap = document.getElementById('animTracks');
var ticksWrap = document.getElementById('animRuler');
if (!lanesWrap || !ticksWrap) return;
lanesWrap.innerHTML = '';
ticksWrap.innerHTML = '';
var maxTime = ANIM_CLIPS.reduce(function(m, c) { return Math.max(m, (c.start_time || 0) + (c.duration || 1)); }, 5);
var totalSeconds = Math.ceil(maxTime) + 2;
var totalWidth = totalSeconds * ANIM_PX_PER_SEC;
var spacer = document.createElement('div');
spacer.className = 'animRulerSpacer';
ticksWrap.appendChild(spacer);
var ticksLane = document.createElement('div');
ticksLane.className = 'animRulerTicksWrap';
ticksLane.style.width = totalWidth + 'px';
for (var s = 0; s <= totalSeconds; s++) {
var tick = document.createElement('div');
tick.className = 'animRulerTick';
tick.style.left = (s * ANIM_PX_PER_SEC) + 'px';
tick.textContent = s + 's';
ticksLane.appendChild(tick);
}
ticksWrap.appendChild(ticksLane);
var elementIds = [];
ANIM_CLIPS.forEach(function(c) { if (elementIds.indexOf(c.element_id) === -1) elementIds.push(c.element_id); });
if (!elementIds.length) {
var empty = document.createElement('p');
empty.className = 'hint';
empty.style.padding = '10px 0';
empty.textContent = "Aucun clip pour l'instant — choisis un élément ci-dessus et clique « Ajouter un clip ».";
lanesWrap.appendChild(empty);
return;
}
elementIds.forEach(function(elId) {
var track = document.createElement('div');
track.className = 'animTrack';
var label = document.createElement('div');
label.className = 'animTrackLabel';
label.textContent = ELEMENTS_LABELS[elId] || ('elt-' + elId);
track.appendChild(label);
var lane = document.createElement('div');
lane.className = 'animTrackLane';
lane.style.width = totalWidth + 'px';
ANIM_CLIPS.filter(function(c) { return c.element_id === elId; }).forEach(function(clip) {
var block = document.createElement('div');
block.className = 'animClip';
block.style.left = ((clip.start_time || 0) * ANIM_PX_PER_SEC) + 'px';
block.style.width = Math.max(24, (clip.duration || 1) * ANIM_PX_PER_SEC) + 'px';
block.textContent = animClipLabel(clip);
block.dataset.clipId = clip.id;
block.addEventListener('mousedown', function(e) { onAnimClipMouseDown(e, clip.id); });
var handle = document.createElement('div');
handle.className = 'animClipResizeHandle';
handle.addEventListener('mousedown', function(e) { onAnimClipResizeMouseDown(e, clip.id); });
block.appendChild(handle);
lane.appendChild(block);
});
track.appendChild(lane);
lanesWrap.appendChild(track);
});
}
function onAnimClipMouseDown(e, clipId) {
if (e.target.classList.contains('animClipResizeHandle')) return;
e.preventDefault();
e.stopPropagation();
var clip = ANIM_CLIPS.find(function(c) { return c.id === clipId; });
var block = document.querySelector('.animClip[data-clip-id="' + clipId + '"]');
var startX = e.clientX;
var startLeft = parseFloat(block.style.left);
var moved = false;
function onMove(ev) {
var dx = ev.clientX - startX;
if (Math.abs(dx) > 3) moved = true;
block.style.left = Math.max(0, startLeft + dx) + 'px';
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (moved) {
var newStart = Math.round((parseFloat(block.style.left) / ANIM_PX_PER_SEC) * 10) / 10;
clip.start_time = newStart;
updateAnimClip(clipId, { start_time: newStart });
} else {
openAnimClipForm(clip);
}
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
function onAnimClipResizeMouseDown(e, clipId) {
e.preventDefault();
e.stopPropagation();
var clip = ANIM_CLIPS.find(function(c) { return c.id === clipId; });
var block = document.querySelector('.animClip[data-clip-id="' + clipId + '"]');
var startX = e.clientX;
var startWidth = parseFloat(block.style.width);
function onMove(ev) {
var dx = ev.clientX - startX;
block.style.width = Math.max(10, startWidth + dx) + 'px';
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
var newDuration = Math.round((parseFloat(block.style.width) / ANIM_PX_PER_SEC) * 10) / 10;
clip.duration = newDuration;
updateAnimClip(clipId, { duration: newDuration });
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
function updateAnimClip(clipId, fields) {
return fetch(animUrl('/animations/clips/' + clipId + '/update'), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fields)
}).then(function(r) { return r.json(); }).then(function(updated) {
var idx = ANIM_CLIPS.findIndex(function(c) { return c.id === updated.id; });
if (idx > -1) ANIM_CLIPS[idx] = updated;
return updated;
});
}
function addAnimTrack() {
var elId = parseInt(document.getElementById('anim-new-track-element').value, 10);
fetch(animUrl('/screens/' + ANIM_SCREEN_ID + '/animations/clips/add'), {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ element_id: elId, kind: 'animate_css', animate_name: 'fadeIn', start_time: 0, duration: 1 }),
}).then(function(r) { return r.json(); }).then(function(clip) {
ANIM_CLIPS.push(clip);
renderAnimTimeline();
openAnimClipForm(clip);
});
}
function toggleAnimClipKind() {
var kind = document.getElementById('anim-clip-kind').value;
document.getElementById('anim-clip-animate-css-fields').style.display = kind === 'animate_css' ? 'block' : 'none';
document.getElementById('anim-clip-custom-fields').style.display = kind === 'custom' ? 'block' : 'none';
document.getElementById('anim-clip-sprite-fields').style.display = kind === 'sprite' ? 'block' : 'none';
if (kind === 'sprite') populateAnimClipSpriteGrid();
}
// Phase 8 — mirror de onSpriteTargetElementChange() (flow-editor.js), mais
// scopé au personnage PROPRIÉTAIRE de ce clip (clip.element_id, fixé à la
// création — voir addAnimTrack()) : pas de sélecteur d'élément séparé ici,
// contrairement au nœud de flow (une action peut viser n'importe quel
// personnage, un clip appartient à un seul élément dès sa création).
function populateAnimClipSpriteGrid(preselectedName) {
var grid = document.getElementById('anim-clip-sprite-animation-grid');
var nameField = document.getElementById('anim-clip-sprite-animation-name');
var noAnimHint = document.getElementById('anim-clip-sprite-no-animations');
grid.innerHTML = '';
var clip = ANIM_CLIPS.find(function(c) { return c.id === animCurrentClipId; });
var animations = clip ? (ELEMENT_ANIMATIONS_MAP[clip.element_id] || {}) : {};
var hasAnimations = Object.keys(animations).length > 0;
noAnimHint.style.display = hasAnimations ? 'none' : 'block';
Object.keys(animations).forEach(function(animName) {
var frames = animations[animName];
if (!frames.length) return;
var tile = document.createElement('button');
tile.type = 'button';
tile.className = 'iconGalleryTile' + (animName === preselectedName ? ' selected' : '');
tile.title = animName;
tile.innerHTML = '<img src="' + frames[0] + '" alt="" class="personnageThumb">';
tile.onclick = function() {
grid.querySelectorAll('.iconGalleryTile').forEach(function(t) { t.classList.remove('selected'); });
tile.classList.add('selected');
nameField.value = animName;
// Vitesse par défaut proportionnelle au nombre d'images — voir le
// même correctif dans onSpriteTargetElementChange() (flow-editor.js).
var fpsField = document.getElementById('anim-clip-sprite-fps');
if (fpsField) fpsField.value = Math.max(8, Math.min(24, frames.length));
};
grid.appendChild(tile);
});
if (preselectedName && animations[preselectedName]) nameField.value = preselectedName;
}
function addAnimKeyframeRow(percent, cssText) {
var wrap = document.getElementById('anim-clip-keyframes');
var row = document.createElement('div');
row.className = 'fieldRow animKeyframeRow';
var percentInput = document.createElement('input');
percentInput.type = 'number'; percentInput.min = '0'; percentInput.max = '100'; percentInput.step = '1';
percentInput.className = 'anim-kf-percent'; percentInput.style.flex = '0 0 70px';
percentInput.value = percent != null ? percent : 0;
var cssInput = document.createElement('textarea');
cssInput.className = 'anim-kf-css'; cssInput.style.flex = '1'; cssInput.rows = 2;
cssInput.placeholder = 'ex. opacity: 0; transform: scale(0.5);';
cssInput.value = cssText || '';
var removeBtn = document.createElement('button');
removeBtn.type = 'button'; removeBtn.className = 'smallBtn danger';
removeBtn.textContent = '✕';
removeBtn.onclick = function() { row.remove(); };
row.appendChild(percentInput);
row.appendChild(cssInput);
row.appendChild(removeBtn);
wrap.appendChild(row);
}
function collectAnimKeyframes() {
var rows = document.querySelectorAll('#anim-clip-keyframes .animKeyframeRow');
var keyframes = [];
rows.forEach(function(row) {
var percent = parseFloat(row.querySelector('.anim-kf-percent').value) || 0;
var cssText = row.querySelector('.anim-kf-css').value;
var props = {};
cssText.split(';').forEach(function(decl) {
var parts = decl.split(':');
if (parts.length === 2) {
var prop = parts[0].trim(), val = parts[1].trim();
if (prop && val) props[prop] = val;
}
});
keyframes.push({ percent: percent, props: props });
});
return keyframes;
}
function openAnimClipForm(clip) {
animCurrentClipId = clip.id;
document.getElementById('animClipForm').style.display = 'block';
document.getElementById('animClipFormTitle').textContent = 'Clip — ' + (ELEMENTS_LABELS[clip.element_id] || ('elt-' + clip.element_id));
document.getElementById('anim-clip-kind').value = clip.kind || 'animate_css';
document.getElementById('anim-clip-animate-name').value = clip.animate_name || 'fadeIn';
document.getElementById('anim-clip-start').value = clip.start_time || 0;
document.getElementById('anim-clip-duration').value = clip.duration || 1;
document.getElementById('anim-clip-delay').value = clip.delay || 0;
document.getElementById('anim-clip-easing').value = clip.easing || 'ease';
document.getElementById('anim-clip-iteration').value = clip.iteration_count || '1';
document.getElementById('anim-clip-keyframes').innerHTML = '';
var keyframes = [];
if (clip.custom_keyframes) {
try { keyframes = JSON.parse(clip.custom_keyframes); } catch (e) { /* ignore */ }
}
if (!keyframes.length) keyframes = [{ percent: 0, props: { opacity: '0' } }, { percent: 100, props: { opacity: '1' } }];
keyframes.forEach(function(k) {
var cssText = Object.keys(k.props || {}).map(function(p) { return p + ': ' + k.props[p] + ';'; }).join(' ');
addAnimKeyframeRow(k.percent, cssText);
});
// Phase 8 — clip.custom_keyframes stocke {animation, fps} en JSON pour un
// clip kind="sprite" (pas une liste d'images-clés CSS, voir ci-dessus) :
// le NOM de l'animation, pas ses frames — résolues à l'exécution depuis
// le personnage ACTUELLEMENT assigné (voir resolveSpriteFrames() dans
// static/js/play/actions.js), jamais figées ici.
var spriteData = {};
if (clip.kind === 'sprite' && clip.custom_keyframes) {
try { spriteData = JSON.parse(clip.custom_keyframes); } catch (e) { /* ignore */ }
}
document.getElementById('anim-clip-sprite-fps').value = spriteData.fps || 8;
toggleAnimClipKind();
if (clip.kind === 'sprite') populateAnimClipSpriteGrid(spriteData.animation);
document.querySelectorAll('.animClip').forEach(function(b) {
b.classList.toggle('selected', parseInt(b.dataset.clipId, 10) === clip.id);
});
}
function closeAnimClipForm() {
animCurrentClipId = null;
document.getElementById('animClipForm').style.display = 'none';
document.querySelectorAll('.animClip').forEach(function(b) { b.classList.remove('selected'); });
}
function saveAnimClipForm() {
if (!animCurrentClipId) return Promise.resolve();
var kind = document.getElementById('anim-clip-kind').value;
var fields = {
kind: kind,
animate_name: document.getElementById('anim-clip-animate-name').value,
start_time: parseFloat(document.getElementById('anim-clip-start').value) || 0,
duration: parseFloat(document.getElementById('anim-clip-duration').value) || 1,
delay: parseFloat(document.getElementById('anim-clip-delay').value) || 0,
easing: document.getElementById('anim-clip-easing').value,
iteration_count: document.getElementById('anim-clip-iteration').value,
};
if (kind === 'custom') fields.custom_keyframes = collectAnimKeyframes();
if (kind === 'sprite') {
// Phase 8 — stocke le NOM de l'animation choisie (miniature cliquée,
// voir populateAnimClipSpriteGrid()), PAS ses frames — résolues à
// l'exécution depuis le personnage ACTUELLEMENT assigné (voir
// resolveSpriteFrames() dans static/js/play/actions.js), pour que
// changer de personnage Forge affecte aussi les clips déjà posés dans
// la Timeline. La boucle reste portée par iteration_count
// (1/2/3/infinite, ci-dessus).
fields.custom_keyframes = {
animation: document.getElementById('anim-clip-sprite-animation-name').value,
fps: parseInt(document.getElementById('anim-clip-sprite-fps').value, 10) || 8,
};
}
return updateAnimClip(animCurrentClipId, fields).then(function() { renderAnimTimeline(); });
}
function deleteAnimClipForm() {
if (!animCurrentClipId) return;
if (!confirm('Supprimer ce clip ?')) return;
var id = animCurrentClipId;
fetch(animUrl('/animations/clips/' + id + '/delete'), { method: 'POST' }).then(function() {
ANIM_CLIPS = ANIM_CLIPS.filter(function(c) { return c.id !== id; });
closeAnimClipForm();
renderAnimTimeline();
});
}
function previewAnimClip() {
if (!animCurrentClipId) return;
saveAnimClipForm().then(function() {
var clip = ANIM_CLIPS.find(function(c) { return c.id === animCurrentClipId; });
if (!clip) return;
var target = document.querySelector('#canvas [data-anim-target="' + clip.element_id + '"]');
if (!target) return;
var duration = clip.duration || 1, delay = clip.delay || 0;
var iterationCount = clip.iteration_count || '1';
if (clip.kind === 'custom') {
var keyframes = [];
try { keyframes = JSON.parse(clip.custom_keyframes || '[]'); } catch (e) { /* ignore */ }
var steps = keyframes.map(function(k) {
var props = Object.keys(k.props || {}).map(function(p) { return p + ':' + k.props[p] + ';'; }).join(' ');
return k.percent + '% { ' + props + ' }';
}).join(' ');
var styleTag = document.getElementById('animEditorPreviewStyle');
if (!styleTag) {
styleTag = document.createElement('style');
styleTag.id = 'animEditorPreviewStyle';
document.head.appendChild(styleTag);
}
styleTag.textContent = '@keyframes animEditorPreview { ' + steps + ' }';
target.style.animation = 'animEditorPreview ' + duration + 's ' + (clip.easing || 'ease') + ' ' + delay + 's ' + iterationCount;
target.addEventListener('animationend', function handler() {
target.style.animation = '';
target.removeEventListener('animationend', handler);
});
} else if (clip.kind === 'sprite') {
var spriteData = {};
try { spriteData = JSON.parse(clip.custom_keyframes || '{}'); } catch (e) { /* ignore */ }
// Phase 8 — résout le NOM de l'animation depuis les animations
// ACTUELLES du personnage (même logique que resolveSpriteFrames()
// côté jeu, mais ELEMENT_ANIMATIONS_MAP n'existe que dans l'éditeur).
var ownerAnimations = ELEMENT_ANIMATIONS_MAP[clip.element_id] || {};
var frames = ownerAnimations[spriteData.animation] || [];
var fps = spriteData.fps > 0 ? spriteData.fps : 8;
if (animPreviewSpriteInterval) { clearInterval(animPreviewSpriteInterval); animPreviewSpriteInterval = null; }
if (frames.length) {
var frameIndex = 0;
target.src = frames[0];
if (frames.length > 1) {
animPreviewSpriteInterval = setInterval(function() {
frameIndex = (frameIndex + 1) % frames.length;
target.src = frames[frameIndex];
}, 1000 / fps);
}
}
} else {
var animClass = 'animate__' + clip.animate_name;
target.style.setProperty('--animate-duration', duration + 's');
target.style.setProperty('--animate-delay', delay + 's');
target.style.animationIterationCount = iterationCount;
target.classList.add('animate__animated', animClass);
target.addEventListener('animationend', function handler() {
target.classList.remove('animate__animated', animClass);
target.removeEventListener('animationend', handler);
});
}
});
}
// Panneau timeline absent sur un écran-modèle (élément de jeu), comme le
// panneau de logique — voir le commentaire Jinja au-dessus de .builderTabs.
var HAS_ANIM_PANEL = !!document.getElementById('animTabPanel');
if (HAS_ANIM_PANEL) { renderAnimTimeline(); }
-875
View File
@@ -1,875 +0,0 @@
// ---------- Logique de la scène : éditeur de flow à nœuds ----------
//
// FLOW_NODES_INITIAL/FLOW_EDGES_INITIAL/FLOW_BLOCKS/ELEMENTS_LABELS/
// SCREENS_MAP/*_LABELS_MAP/CUSTOM_EVENTS_MAP/GAME_SLUG/SCREEN_ID sont
// posés par le petit <script> inline en tête de screen_edit.html (données
// Jinja) — voir static/js/play/ pour le même patron déjà en place côté
// moteur de jeu.
var FLOW_SCREEN_ID = SCREEN_ID;
// Fondations multi-éditeurs — ce même fichier (moteur de logique de flow
// partagé, voir le plan "Fondations d'une plateforme multi-éditeurs")
// sert aussi bien à l'éditeur document (cible trigger_element_id/
// target_element_id, vers _screen_elements) qu'à l'éditeur de scène 2D
// (cible trigger_object_id/target_object_id, vers _scene_objects) : posé
// à `true` par templates/scene_edit.html, sinon absent (donc falsy) sur
// templates/screen_edit.html — décide quelle paire de colonnes remplir à
// l'enregistrement d'un nœud (voir submitNodeForm()).
var FLOW_TARGETS_OBJECTS = typeof FLOW_TARGETS_OBJECTS !== 'undefined' ? FLOW_TARGETS_OBJECTS : false;
var FLOW_TRIGGER_ID_FIELD = FLOW_TARGETS_OBJECTS ? 'trigger_object_id' : 'trigger_element_id';
var FLOW_TARGET_ID_FIELD = FLOW_TARGETS_OBJECTS ? 'target_object_id' : 'target_element_id';
// Blocs de logique (voir screens/flow/blocks/) : ALL_FLOW porte TOUS les
// nœuds/arêtes de cet écran, tous blocs confondus (chargement inchangé) ;
// un seul bloc est ouvert à la fois (modale unique, à la Unity) — pas
// besoin d'une seconde copie filtrée à tenir synchronisée, currentBlockNodes()/
// currentBlockEdges() recalculent la vue du bloc ouvert à chaque rendu.
var ALL_FLOW = {
nodes: FLOW_NODES_INITIAL,
edges: FLOW_EDGES_INITIAL,
};
var CURRENT_BLOCK_ID = null;
function currentBlockNodes() {
return ALL_FLOW.nodes.filter(function(n) { return n.block_id === CURRENT_BLOCK_ID; });
}
function currentBlockEdges() {
var ids = {};
currentBlockNodes().forEach(function(n) { ids[n.id] = true; });
return ALL_FLOW.edges.filter(function(e) { return ids[e.from_node_id] && ids[e.to_node_id]; });
}
var flowPendingPort = null; // {nodeId, port} en attente de connexion
function flowUrl(path) { return "/game/" + GAME_SLUG + path; }
function nodeLabel(node) {
if (node.node_type === 'trigger') {
if (node.trigger_event === 'affichage') return '🖥️ ' + TRIGGER_LABELS_MAP['affichage'];
if (node.trigger_event === 'evenement') {
var listenedEvent = CUSTOM_EVENTS_MAP[node.trigger_custom_event_id];
return '📣 Sur « ' + (listenedEvent ? listenedEvent.name : '?') + ' »';
}
if (node.trigger_event === 'clavier') {
return '⌨️ Touche « ' + (node.trigger_key === ' ' ? 'Espace' : (node.trigger_key || '?')) + ' »';
}
if (node.trigger_event === 'minuteur') {
return '⏱️ Toutes les ' + (node.trigger_interval_ms || '?') + ' ms';
}
if (node.trigger_event === 'touche_maintenue') {
return '⌨️⏳ Touche maintenue « ' + (node.trigger_key === ' ' ? 'Espace' : (node.trigger_key || '?')) + ' »';
}
if (node.trigger_event === 'touche_relachee') {
return '⌨️⬆️ Relâchement de « ' + (node.trigger_key === ' ' ? 'Espace' : (node.trigger_key || '?')) + ' »';
}
return '🖱️ ' + (TRIGGER_LABELS_MAP[node.trigger_event] || 'Au clic') + ' sur ' +
(ELEMENTS_LABELS[node.trigger_element_id || node.trigger_object_id] || '?');
}
if (node.node_type === 'condition') {
var clauseSubject = function(c, field, variable) {
return (c.source ?? c.cond_source) === 'variable' ? ('🔧 ' + (variable || c.cond_variable || '?')) : (field || c.cond_field || '?');
};
// Phase 5 — condition de collision : pas de champ/opérateur/valeur,
// juste les deux éléments comparés (voir evaluateConditionClause()
// dans static/js/play/conditions.js).
var clauseFullLabel = function(c, field, variable) {
if ((c.source ?? c.cond_source) === 'collision') {
var elA = ELEMENTS_LABELS[c.element_a ?? c.cond_element_a] || '?';
var elB = ELEMENTS_LABELS[c.element_b ?? c.cond_element_b] || '?';
return '💥 ' + elA + ' touche ' + elB;
}
return clauseSubject(c, field, variable) + ' ' + (CONDITION_OPERATOR_LABELS_MAP[c.operator ?? c.cond_operator] || (c.operator ?? c.cond_operator)) + ' ' + ((c.value ?? c.cond_value) || '');
};
var mainClause = '❓ SI ' + clauseFullLabel(node, node.cond_field, node.cond_variable);
if (node.cond_clauses) {
// 2.4 — plusieurs clauses combinées : on affiche chaque champ/variable
// testé, relié par le connecteur ET/OU choisi, plutôt qu'une seule clause.
try {
var clauses = JSON.parse(node.cond_clauses);
var combLabel = node.cond_combinator === 'ou' ? ' OU ' : ' ET ';
return '❓ SI ' + clauses.map(function(c) {
return clauseFullLabel(c, c.field, c.variable);
}).join(combLabel);
} catch (e) { /* JSON invalide : on retombe sur la clause historique */ }
}
return mainClause;
}
// action
var base = '⚡ ' + (ACTION_TYPE_LABELS_MAP[node.action_type] || node.action_type);
if (node.action_type === 'aller_a') {
base += ' → ' + (SCREENS_MAP[node.target_screen_id] || '?');
} else if (node.action_type === 'ouvrir_ligne') {
base += ' → ' + (SCREENS_MAP[node.target_screen_id] || '?') + ' (ligne cliquée)';
} else if (node.action_type === 'activer_onglet') {
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' (masque ses frères)';
} else if (node.action_type === 'modifier_objet_scene') {
// Fondations multi-éditeurs — équivalent scène de "modifier_element"
// ci-dessous, palette réduite (visibilité/position en pixels/
// orientation seulement).
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id || node.target_object_id] || '?') + ' : ' + (ELEMENT_PROPERTY_LABELS_MAP[node.element_property] || node.element_property);
if (node.element_property === 'visibilite') {
base += ' = ' + (ELEMENT_VISIBILITY_LABELS_MAP[node.element_value] || node.element_value);
} else if (node.element_property === 'orientation') {
base += ' = ' + (ELEMENT_ORIENTATION_LABELS_MAP[node.element_value] || node.element_value);
} else {
base += ' = ' + node.element_value;
}
} else if (node.action_type === 'modifier_element') {
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' : ' + (ELEMENT_PROPERTY_LABELS_MAP[node.element_property] || node.element_property);
if (node.element_property === 'visibilite') {
base += ' = ' + (ELEMENT_VISIBILITY_LABELS_MAP[node.element_value] || node.element_value);
} else if (node.element_property === 'surbrillance') {
base += ' = ' + (ELEMENT_SURBRILLANCE_LABELS_MAP[node.element_value] || node.element_value);
} else if (node.element_property === 'desactive') {
base += ' = ' + (ELEMENT_DESACTIVE_LABELS_MAP[node.element_value] || node.element_value);
} else if (node.element_property === 'orientation') {
base += ' = ' + (ELEMENT_ORIENTATION_LABELS_MAP[node.element_value] || node.element_value);
} else if (node.element_value && node.element_value.indexOf('||') !== -1) {
var vparts = node.element_value.split('||');
base += ' = ' + vparts[0] + ' ↔ ' + vparts[1] + ' (va-et-vient)';
} else {
base += ' = ' + node.element_value;
}
} else if (node.action_type === 'modifier_donnee') {
base += ' → ' + (node.target_field || '?') + ' : ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation);
if (node.data_value) base += ' ' + node.data_value;
} else if (node.action_type === 'modifier_variable') {
base += ' → ' + (node.target_variable || '?') + ' : ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation);
if (node.data_value) base += ' ' + node.data_value;
} else if (node.action_type === 'modifier_score') {
base += ' → ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation);
if (node.data_value) base += ' ' + node.data_value;
} else if (node.action_type === 'definir_statut_partie') {
base += ' → ' + (SCORE_STATUS_LABELS_MAP[node.data_value] || node.data_value || '?');
} else if (node.action_type === 'attendre') {
base += ' (' + (node.data_value || '0') + ' s)';
} else if (node.action_type === 'declencher_evenement') {
// Notification pure : rien d'autre à afficher que le nom de
// l'événement (voir screens/custom_events/) — aucun élément/ligne
// n'est jamais transmis au déclenchement.
var dispatchedEvent = CUSTOM_EVENTS_MAP[node.target_custom_event_id];
base += ' « ' + (dispatchedEvent ? dispatchedEvent.name : '?') + ' »';
} else if (node.action_type === 'ajouter_ligne') {
base += ' → ' + (DEFINITIONS_NAMES[node.target_definition_id] || '?');
} else if (node.action_type === 'jouer_son') {
base += ' « ' + (node.data_value || '?') + ' »';
} else if (node.action_type === 'jouer_animation_sprite') {
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id || node.target_object_id] || '?');
try {
var spriteData = JSON.parse(node.data_value || '{}');
base += ' « ' + (spriteData.animation || '?') + ' » (' + (spriteData.fps || '?') + ' i/s' + (spriteData.loop ? ', boucle' : '') + ')';
} catch (e) { /* data_value invalide : pas de détail affiché */ }
}
return base;
}
function renderFlow() {
var canvas = document.getElementById('flowCanvas');
var svg = document.getElementById('flowEdges');
canvas.innerHTML = '';
svg.innerHTML = '';
currentBlockNodes().forEach(function(node) {
var div = document.createElement('div');
div.className = 'flowNode flowNode-' + node.node_type;
div.style.left = node.x + 'px';
div.style.top = node.y + 'px';
div.dataset.nodeId = node.id;
var del = document.createElement('button');
del.className = 'flowNodeDelete';
del.type = 'button';
del.textContent = '✕';
del.onclick = function(ev) { ev.stopPropagation(); deleteFlowNode(node.id); };
div.appendChild(del);
var body = document.createElement('div');
body.className = 'flowNodeBody';
body.textContent = nodeLabel(node);
div.appendChild(body);
if (node.node_type !== 'trigger') {
var inPort = document.createElement('div');
inPort.className = 'flowPort flowPortIn';
inPort.onclick = function(ev) { ev.stopPropagation(); onPortClick(node.id, 'in', null); };
div.appendChild(inPort);
}
if (node.node_type === 'condition') {
var outTrue = document.createElement('div');
outTrue.className = 'flowPort flowPortOut flowPortTrue';
outTrue.title = 'Vrai';
outTrue.onclick = function(ev) { ev.stopPropagation(); onPortClick(node.id, 'out', 'vrai'); };
div.appendChild(outTrue);
var outFalse = document.createElement('div');
outFalse.className = 'flowPort flowPortOut flowPortFalse';
outFalse.title = 'Faux';
outFalse.onclick = function(ev) { ev.stopPropagation(); onPortClick(node.id, 'out', 'faux'); };
div.appendChild(outFalse);
} else {
var outPort = document.createElement('div');
outPort.className = 'flowPort flowPortOut';
outPort.onclick = function(ev) { ev.stopPropagation(); onPortClick(node.id, 'out', 'out'); };
div.appendChild(outPort);
}
div.addEventListener('mousedown', function(e) { onFlowNodeMouseDown(e, node.id); });
canvas.appendChild(div);
});
// Redessine les fils après que les nœuds soient dans le DOM (pour lire leur position réelle).
requestAnimationFrame(drawFlowEdges);
}
function portPosition(nodeId, side, sub) {
var div = document.querySelector('.flowNode[data-node-id="' + nodeId + '"]');
if (!div) return null;
var wrap = document.querySelector('.flowCanvasWrap');
var wrapRect = wrap.getBoundingClientRect();
var selector = side === 'in' ? '.flowPortIn' : (sub === 'vrai' ? '.flowPortTrue' : (sub === 'faux' ? '.flowPortFalse' : '.flowPortOut'));
var port = div.querySelector(selector);
if (!port) return null;
var r = port.getBoundingClientRect();
return { x: r.left + r.width / 2 - wrapRect.left + wrap.scrollLeft, y: r.top + r.height / 2 - wrapRect.top + wrap.scrollTop };
}
function drawFlowEdges() {
var svg = document.getElementById('flowEdges');
svg.innerHTML = '';
currentBlockEdges().forEach(function(edge) {
var from = portPosition(edge.from_node_id, 'out', edge.from_port);
var to = portPosition(edge.to_node_id, 'in', null);
if (!from || !to) return;
var line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', from.x); line.setAttribute('y1', from.y);
line.setAttribute('x2', to.x); line.setAttribute('y2', to.y);
line.setAttribute('class', 'flowEdgeLine' + (edge.from_port === 'faux' ? ' flowEdgeFalse' : (edge.from_port === 'vrai' ? ' flowEdgeTrue' : '')));
line.style.pointerEvents = 'stroke';
line.addEventListener('click', function() { deleteFlowEdge(edge.id); });
svg.appendChild(line);
});
}
function onPortClick(nodeId, side, port) {
if (side === 'out') {
flowPendingPort = { nodeId: nodeId, port: port };
return;
}
// side === 'in'
if (!flowPendingPort) return;
if (flowPendingPort.nodeId === nodeId) { flowPendingPort = null; return; }
fetch(flowUrl('/screens/' + FLOW_SCREEN_ID + '/flow/edges/add'), {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ from_node_id: flowPendingPort.nodeId, from_port: flowPendingPort.port, to_node_id: nodeId })
}).then(function(r){ return r.json(); }).then(function(edge){
ALL_FLOW.edges.push({ id: edge.id, screen_id: FLOW_SCREEN_ID, from_node_id: flowPendingPort.nodeId, from_port: flowPendingPort.port, to_node_id: nodeId });
flowPendingPort = null;
drawFlowEdges();
});
}
function deleteFlowEdge(edgeId) {
fetch(flowUrl('/flow/edges/' + edgeId + '/delete'), { method: 'POST' }).then(function(){
ALL_FLOW.edges = ALL_FLOW.edges.filter(function(e){ return e.id !== edgeId; });
drawFlowEdges();
});
}
function deleteFlowNode(nodeId) {
fetch(flowUrl('/flow/nodes/' + nodeId + '/delete'), { method: 'POST' }).then(function(){
ALL_FLOW.nodes = ALL_FLOW.nodes.filter(function(n){ return n.id !== nodeId; });
ALL_FLOW.edges = ALL_FLOW.edges.filter(function(e){ return e.from_node_id !== nodeId && e.to_node_id !== nodeId; });
renderFlow();
});
}
function onFlowNodeMouseDown(e, nodeId) {
if (e.target.classList.contains('flowPort') || e.target.classList.contains('flowNodeDelete')) return;
e.preventDefault();
var node = ALL_FLOW.nodes.find(function(n){ return n.id === nodeId; });
var div = document.querySelector('.flowNode[data-node-id="' + nodeId + '"]');
var startX = e.clientX, startY = e.clientY;
var startLeft = node.x, startTop = node.y;
function onMove(ev) {
var dx = ev.clientX - startX, dy = ev.clientY - startY;
node.x = Math.max(0, startLeft + dx);
node.y = Math.max(0, startTop + dy);
div.style.left = node.x + 'px';
div.style.top = node.y + 'px';
drawFlowEdges();
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
fetch(flowUrl('/flow/nodes/' + nodeId + '/move'), {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ x: node.x, y: node.y })
});
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
// ---------- Formulaire d'ajout de nœud ----------
var currentNodeFormType = null;
function openNodeForm(type) {
currentNodeFormType = type;
document.getElementById('nodeForm').style.display = 'block';
document.getElementById('nodeFormTitle').textContent =
type === 'trigger' ? 'Nouveau déclencheur' : (type === 'condition' ? 'Nouvelle condition' : 'Nouvelle action');
document.getElementById('nodeFormTrigger').style.display = type === 'trigger' ? 'block' : 'none';
document.getElementById('nodeFormCondition').style.display = type === 'condition' ? 'block' : 'none';
document.getElementById('nodeFormAction').style.display = type === 'action' ? 'block' : 'none';
if (type === 'trigger') toggleFlowTriggerFields();
if (type === 'condition') {
document.getElementById('nf-cond-source').value = 'objet';
onFlowConditionSourceChange();
onFlowDefinitionChange('cond');
flowConditionExtraClauses = [];
renderFlowConditionClauses();
}
if (type === 'action') {
document.getElementById('nf-action-element-toggle').checked = false;
toggleFlowActionFields();
}
}
function toggleFlowTriggerFields() {
var eventVal = document.getElementById('nf-trigger-event').value;
var isAffichage = eventVal === 'affichage';
// "Sur un événement personnalisé" ne concerne ni un élément précis ni
// l'écran entier : c'est un scan global (voir dispatchGameEvent() dans
// play.html), le seul réglage propre à ce nœud est QUEL événement.
var isCustomEvent = eventVal === 'evenement';
// Idem "clavier"/"minuteur" (Phase 3) : ni élément ni écran précis.
// "touche_maintenue"/"touche_relachee" (mouvement continu) réutilisent
// le même champ trigger_key que "clavier".
var isKeyboard = eventVal === 'clavier' || eventVal === 'touche_maintenue' || eventVal === 'touche_relachee';
var isTimer = eventVal === 'minuteur';
var noElement = isAffichage || isCustomEvent || isKeyboard || isTimer;
document.getElementById('nf-trigger-element-row').style.display = noElement ? 'none' : 'block';
document.getElementById('nf-trigger-repeater-hint').style.display = noElement ? 'none' : 'block';
document.getElementById('nf-trigger-affichage-hint').style.display = isAffichage ? 'block' : 'none';
document.getElementById('nf-trigger-event-row').style.display = isCustomEvent ? 'block' : 'none';
document.getElementById('nf-trigger-key-row').style.display = isKeyboard ? 'block' : 'none';
document.getElementById('nf-trigger-interval-row').style.display = isTimer ? 'block' : 'none';
}
// Capture la touche pressée dans le champ "Touche à surveiller" (Phase 3)
// — évite de faire deviner la syntaxe attendue (ev.key du navigateur,
// ex. "ArrowUp", "a", " " pour la barre d'espace) : l'input lui-même
// écoute keydown et se remplit tout seul, jamais tapé à la main.
function captureFlowTriggerKey(event) {
event.preventDefault();
document.getElementById('nf-trigger-key').value = event.key;
document.getElementById('nf-trigger-key-display').value = event.key === ' ' ? 'Espace' : event.key;
}
function closeNodeForm() {
document.getElementById('nodeForm').style.display = 'none';
currentNodeFormType = null;
}
// CLICKED_ROW_ID (-1, voir screens/flow/constants.py) : "la ligne de
// Répéteur sur laquelle on vient de cliquer" — pour un déclencheur "Au
// clic" posé sur un Répéteur, chaque ligne exécute le MÊME graphe et la
// ligne cliquée n'est jamais connue à l'avance ; choisir une ligne fixe
// dans l'éditeur n'a donc pas de sens pour ce cas. Résolue au moment de
// l'exécution (window.lastClickedRowId, voir play.html), pas à la
// création du nœud — proposée en tête de TOUTE liste "Ligne concernée",
// avant les lignes réelles de l'objet.
var FLOW_CLICKED_ROW_ID = -1;
// LAST_INSERTED_ROW_ID (-3, voir screens/flow/constants.py) : "la ligne
// qu'un nœud Action 'Ajouter une ligne' vient de créer" (Phase 4) — même
// principe que CLICKED_ROW_ID ci-dessus, résolue au moment de l'exécution
// (window.lastInsertedRowId, voir static/js/play/actions.js) puisqu'un
// id de ligne n'existe qu'APRÈS l'insertion, jamais connu à la création
// du nœud. Permet d'enchaîner "Ajouter une ligne" -> "Modifier une
// donnée"/condition ciblant cette même ligne fraîchement créée.
var FLOW_LAST_INSERTED_ROW_ID = -3;
function _flowRowOptionsHtml(info) {
var options = '<option value="' + FLOW_CLICKED_ROW_ID + '">🖱️ Ligne cliquée (Répéteur)</option>';
options += '<option value="' + FLOW_LAST_INSERTED_ROW_ID + '">➕ Dernière ligne ajoutée</option>';
options += info.rows.length
? info.rows.map(function(r){ return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('')
: '<option value="" disabled>Aucune donnée dans cet objet</option>';
return options;
}
function onFlowDefinitionChange(prefix) {
var defSel = document.getElementById(prefix === 'cond' ? 'nf-cond-definition' : 'nf-action-data-definition');
if (!defSel) return;
var info = DEFINITIONS_DATA[defSel.value] || { rows: [], fields: [] };
var rowSel = document.getElementById(prefix === 'cond' ? 'nf-cond-row' : 'nf-action-data-row');
var fieldSel = document.getElementById(prefix === 'cond' ? 'nf-cond-field' : 'nf-action-data-field');
rowSel.innerHTML = _flowRowOptionsHtml(info);
fieldSel.innerHTML = info.fields.length
? info.fields.map(function(f){ return '<option value="' + f.name + '" data-type="' + f.type + '">' + f.name + ' (' + f.type + ')</option>'; }).join('')
: '<option value="">Aucun champ simple</option>';
}
function onFlowFieldChange() {
// Pas d'action nécessaire ici pour l'instant : le type du champ est relu
// directement sur l'option sélectionnée au moment de la création du nœud.
}
// Bascule la clause PRINCIPALE d'un nœud Condition entre "Champ d'un
// objet" (historique) et "Variable globale" — voir ensure_flow_schema.py
// (cond_source) et evaluateConditionClause() dans play.html. Même bascule
// pour une clause SUPPLÉMENTAIRE : voir onFlowClauseSourceChange() plus
// bas, sur le même principe mais scopée par index.
function onFlowConditionSourceChange() {
var source = document.getElementById('nf-cond-source').value;
var isVariable = source === 'variable';
var isCollision = source === 'collision';
document.getElementById('nf-cond-objet-fields').style.display = (isVariable || isCollision) ? 'none' : 'block';
document.getElementById('nf-cond-variable-fields').style.display = isVariable ? 'block' : 'none';
var hint = document.getElementById('nf-cond-no-objet-hint');
if (hint) hint.style.display = (isVariable || isCollision) ? 'none' : 'block';
// Collision (Phase 5) : ni opérateur ni valeur de comparaison — le
// chevauchement EST directement le booléen vrai/faux du nœud (voir
// evaluateConditionClause() dans static/js/play/conditions.js).
document.getElementById('nf-cond-collision-fields').style.display = isCollision ? 'block' : 'none';
document.getElementById('nf-cond-operator-value-row').style.display = isCollision ? 'none' : 'block';
}
// ---------- 2.4 — clauses supplémentaires (ET/OU) d'un nœud Condition ----------
// Chaque clause supplémentaire réutilise EXACTEMENT le même modèle que la
// clause principale (nf-cond-definition/row/field/operator/value) mais dans
// une ligne indépendante, numérotée par index dans flowConditionExtraClauses.
var flowConditionExtraClauses = [];
function addFlowConditionClause() {
var firstDefSel = document.getElementById('nf-cond-definition');
var defaultDefId = firstDefSel && firstDefSel.value ? firstDefSel.value : '';
flowConditionExtraClauses.push({
source: 'objet', definition_id: defaultDefId, row_id: '', field: '', field_type: '',
variable: '', variable_chemin: '', operator: 'egal', value: '',
});
renderFlowConditionClauses();
}
function removeFlowConditionClause(idx) {
flowConditionExtraClauses.splice(idx, 1);
renderFlowConditionClauses();
}
// Bascule une clause SUPPLÉMENTAIRE (indexée) entre "Champ d'un objet" et
// "Variable globale" — même principe que onFlowConditionSourceChange() pour
// la clause principale, scopé par index plutôt que par id fixe.
function onFlowClauseSourceChange(idx) {
var sel = document.getElementById('nf-cond-extra-source-' + idx);
if (!sel) return;
flowConditionExtraClauses[idx].source = sel.value;
var isVariable = sel.value === 'variable';
var objetFields = document.getElementById('nf-cond-extra-objet-fields-' + idx);
var varFields = document.getElementById('nf-cond-extra-variable-fields-' + idx);
if (objetFields) objetFields.style.display = isVariable ? 'none' : 'block';
if (varFields) varFields.style.display = isVariable ? 'block' : 'none';
}
function renderFlowConditionClauses() {
var wrap = document.getElementById('nf-cond-extra-clauses');
var combinatorRow = document.getElementById('nf-cond-combinator-row');
if (!wrap || !combinatorRow) return;
combinatorRow.style.display = flowConditionExtraClauses.length ? 'block' : 'none';
var defOptionsHtml = document.getElementById('nf-cond-definition') ? document.getElementById('nf-cond-definition').innerHTML : '';
wrap.innerHTML = flowConditionExtraClauses.map(function(clause, idx) {
var isVariable = clause.source === 'variable';
return '' +
'<div class="flowConditionClauseRow" style="border-top:1px dashed var(--border); margin-top:8px; padding-top:8px;">' +
'<label>Source<select id="nf-cond-extra-source-' + idx + '" onchange="onFlowClauseSourceChange(' + idx + ')">' +
'<option value="objet"' + (isVariable ? '' : ' selected') + '>Champ d\'un objet</option>' +
'<option value="variable"' + (isVariable ? ' selected' : '') + '>Variable globale</option>' +
'</select></label>' +
'<div id="nf-cond-extra-objet-fields-' + idx + '" style="display:' + (isVariable ? 'none' : 'block') + ';">' +
'<label>Objet<select id="nf-cond-extra-definition-' + idx + '" onchange="onFlowClauseDefinitionChange(' + idx + ')">' + defOptionsHtml + '</select></label>' +
'<label>Ligne concernée<select id="nf-cond-extra-row-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].row_id = this.value"></select></label>' +
'<label>Champ<select id="nf-cond-extra-field-' + idx + '" onchange="onFlowClauseFieldChange(' + idx + ')"></select></label>' +
'</div>' +
'<div id="nf-cond-extra-variable-fields-' + idx + '" style="display:' + (isVariable ? 'block' : 'none') + ';">' +
'<label>Variable<select id="nf-cond-extra-variable-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].variable = this.value">' +
'<option value="">(sélectionner une variable)</option>' + GLOBAL_VARIABLES_OPTIONS_HTML +
'</select></label>' +
'<label>Chemin (optionnel)<input type="text" id="nf-cond-extra-variable-chemin-' + idx + '" placeholder="ex. .nom ou [0].valeur" value="' + (clause.variable_chemin || '').replace(/"/g, '&quot;') + '" onchange="flowConditionExtraClauses[' + idx + '].variable_chemin = this.value"></label>' +
'</div>' +
'<label>Condition<select id="nf-cond-extra-operator-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].operator = this.value">' +
CONDITION_OPERATORS_HTML +
'</select></label>' +
'<label>Valeur de comparaison<input type="text" id="nf-cond-extra-value-' + idx + '" placeholder="ex. 1, vrai, 10..." value="' + (clause.value || '').replace(/"/g, '&quot;') + '" onchange="flowConditionExtraClauses[' + idx + '].value = this.value"></label>' +
'<button type="button" class="smallBtn button is-small" onclick="removeFlowConditionClause(' + idx + ')">Retirer cette condition</button>' +
'</div>';
}).join('');
flowConditionExtraClauses.forEach(function(clause, idx) {
var defSel = document.getElementById('nf-cond-extra-definition-' + idx);
if (defSel && clause.definition_id) defSel.value = clause.definition_id;
onFlowClauseDefinitionChange(idx);
var opSel = document.getElementById('nf-cond-extra-operator-' + idx);
if (opSel) opSel.value = clause.operator || 'egal';
var varSel = document.getElementById('nf-cond-extra-variable-' + idx);
if (varSel && clause.variable) varSel.value = clause.variable;
});
}
function onFlowClauseDefinitionChange(idx) {
var defSel = document.getElementById('nf-cond-extra-definition-' + idx);
var rowSel = document.getElementById('nf-cond-extra-row-' + idx);
var fieldSel = document.getElementById('nf-cond-extra-field-' + idx);
if (!defSel || !rowSel || !fieldSel) return;
flowConditionExtraClauses[idx].definition_id = defSel.value;
var info = DEFINITIONS_DATA[defSel.value] || { rows: [], fields: [] };
rowSel.innerHTML = _flowRowOptionsHtml(info);
fieldSel.innerHTML = info.fields.length
? info.fields.map(function(f){ return '<option value="' + f.name + '" data-type="' + f.type + '">' + f.name + ' (' + f.type + ')</option>'; }).join('')
: '<option value="">Aucun champ simple</option>';
flowConditionExtraClauses[idx].row_id = rowSel.value;
onFlowClauseFieldChange(idx);
}
function onFlowClauseFieldChange(idx) {
var fieldSel = document.getElementById('nf-cond-extra-field-' + idx);
if (!fieldSel) return;
var opt = fieldSel.options[fieldSel.selectedIndex];
flowConditionExtraClauses[idx].field = fieldSel.value;
flowConditionExtraClauses[idx].field_type = opt ? opt.dataset.type : '';
}
function toggleFlowActionFields() {
var typeSel = document.getElementById('nf-action-type');
var screenSel = document.getElementById('nf-action-target-screen');
var elementFields = document.getElementById('nf-action-element-fields');
var objectFields = document.getElementById('nf-action-object-fields');
var dataFields = document.getElementById('nf-action-data-fields');
var variableFields = document.getElementById('nf-action-variable-fields');
var scoreFields = document.getElementById('nf-action-score-fields');
var statusFields = document.getElementById('nf-action-status-fields');
var waitFields = document.getElementById('nf-action-wait-fields');
var eventFields = document.getElementById('nf-action-event-fields');
var addRowFields = document.getElementById('nf-action-add-row-fields');
var soundFields = document.getElementById('nf-action-sound-fields');
var spriteFields = document.getElementById('nf-action-sprite-fields');
var isOnglet = typeSel.value === 'activer_onglet';
// Champs propres à l'éditeur document ci-dessous (nf-action-element-*/
// -onglet-*) : absents du DOM de l'éditeur de scène (templates/
// scene_edit.html), d'où les gardes "if (x)" partout — même
// #nodeForm/flow-editor.js réutilisé pour les deux éditeurs (voir
// FLOW_TARGETS_OBJECTS en tête de ce fichier).
if (soundFields) soundFields.style.display = typeSel.value === 'jouer_son' ? 'block' : 'none';
if (spriteFields) spriteFields.style.display = typeSel.value === 'jouer_animation_sprite' ? 'block' : 'none';
if (typeSel.value === 'jouer_animation_sprite') onSpriteTargetElementChange();
if (screenSel) screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none';
if (elementFields) elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none';
if (objectFields) objectFields.style.display = typeSel.value === 'modifier_objet_scene' ? 'block' : 'none';
if (dataFields) dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none';
if (variableFields) variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none';
if (scoreFields) scoreFields.style.display = typeSel.value === 'modifier_score' ? 'block' : 'none';
if (statusFields) statusFields.style.display = typeSel.value === 'definir_statut_partie' ? 'block' : 'none';
if (waitFields) waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none';
if (eventFields) eventFields.style.display = typeSel.value === 'declencher_evenement' ? 'block' : 'none';
if (addRowFields) addRowFields.style.display = typeSel.value === 'ajouter_ligne' ? 'block' : 'none';
var ongletHint = document.getElementById('nf-action-onglet-hint');
if (ongletHint) ongletHint.style.display = isOnglet ? 'block' : 'none';
var elementPropertyWrap = document.getElementById('nf-action-element-property-wrap');
if (elementPropertyWrap) elementPropertyWrap.style.display = isOnglet ? 'none' : 'block';
if (typeSel.value === 'modifier_element') toggleFlowElementPropertyValue();
if (typeSel.value === 'modifier_objet_scene') toggleFlowObjectPropertyValue();
if (typeSel.value === 'modifier_donnee') onFlowDefinitionChange('action');
updateDataValuePlaceholder('nf-action-data-operation', 'nf-action-data-value');
updateDataValuePlaceholder('nf-action-variable-operation', 'nf-action-variable-value');
updateDataValuePlaceholder('nf-action-score-operation', 'nf-action-score-value');
}
// Fondations multi-éditeurs — équivalent de toggleFlowElementPropertyValue()
// pour l'action "Modifier un objet de scène" (palette réduite : pas de
// contenu/couleur/largeur/hauteur, juste visibilité/position en pixels/
// orientation — voir screens/scenes/flow_palette.py OBJECT_ACTION_PROPERTIES).
function toggleFlowObjectPropertyValue() {
var propSel = document.getElementById('nf-action-object-property');
if (!propSel) return;
var visSel = document.getElementById('nf-action-object-value-visibility');
var orientSel = document.getElementById('nf-action-object-value-orientation');
var numInp = document.getElementById('nf-action-object-value-number');
[visSel, orientSel, numInp].forEach(function(el) { if (el) el.style.display = 'none'; });
if (propSel.value === 'visibilite' && visSel) {
visSel.style.display = 'block';
} else if (propSel.value === 'orientation' && orientSel) {
orientSel.style.display = 'block';
} else if (numInp) {
numInp.style.display = 'block';
}
}
// "alea" (Phase 2 — hasard) attend "min,max" dans data_value (voir
// screens/data_actions/compute_operation.py) plutôt qu'un seul nombre
// comme les autres opérations — indice visuel dans le champ plutôt que
// d'ajouter un champ dédié (aucun changement de schéma nécessaire).
function updateDataValuePlaceholder(operationSelectId, valueInputId) {
var opSel = document.getElementById(operationSelectId);
var valueInp = document.getElementById(valueInputId);
if (!opSel || !valueInp) return;
valueInp.placeholder = opSel.value === 'alea' ? 'min,max (ex. 1,6)' : 'Valeur / montant';
}
// Phase 8 — sélecteur d'animation à miniatures RÉELLES, scopé au
// personnage réellement ciblé (ELEMENT_ANIMATIONS_MAP, injecté par
// screen_edit.html à partir des propriétés de CET élément précis — voir
// resolve_personnage_animations() côté serveur) — jamais un menu déroulant
// ni un catalogue global. Mirror de onFlowDefinitionChange() (précédent le
// plus proche pour "choisir A repeuple B").
function onSpriteTargetElementChange() {
var elSel = document.getElementById('nf-action-sprite-target-element');
var grid = document.getElementById('nf-action-sprite-animation-grid');
var nameField = document.getElementById('nf-action-sprite-animation-name');
grid.innerHTML = '';
nameField.value = '';
var elementId = elSel.value;
var animations = elementId ? (ELEMENT_ANIMATIONS_MAP[elementId] || {}) : {};
Object.keys(animations).forEach(function(animName) {
var frames = animations[animName];
if (!frames.length) return;
var tile = document.createElement('button');
tile.type = 'button';
tile.className = 'iconGalleryTile';
tile.title = animName;
tile.innerHTML = '<img src="' + frames[0] + '" alt="" class="personnageThumb">';
tile.onclick = function() {
grid.querySelectorAll('.iconGalleryTile').forEach(function(t) { t.classList.remove('selected'); });
tile.classList.add('selected');
nameField.value = animName;
// Vitesse par défaut proportionnelle au nombre d'images (8 pour un
// cycle Kenney à 8 images = ~1 cycle/seconde, mais un cycle CraftPix
// à 30 images (personnages animaux) au même 8 i/s prenait ~3,75 s —
// visiblement "très lent", signalé par l'utilisateur. Reste modifiable
// à la main ensuite (ce n'est qu'une valeur de départ raisonnable).
var fpsField = document.getElementById('nf-action-sprite-fps');
if (fpsField) fpsField.value = Math.max(8, Math.min(24, frames.length));
};
grid.appendChild(tile);
});
}
function toggleFlowElementPropertyValue() {
var propSel = document.getElementById('nf-action-element-property');
var visSel = document.getElementById('nf-action-element-value-visibility');
var surbrillanceSel = document.getElementById('nf-action-element-value-surbrillance');
var desactiveSel = document.getElementById('nf-action-element-value-desactive');
var orientationSel = document.getElementById('nf-action-element-value-orientation');
var colorInp = document.getElementById('nf-action-element-value-color');
var numInp = document.getElementById('nf-action-element-value-number');
var textInp = document.getElementById('nf-action-element-value-text');
var toggleRow = document.getElementById('nf-action-element-toggle-row');
[visSel, surbrillanceSel, desactiveSel, orientationSel, colorInp, numInp, textInp].forEach(function(el){ el.style.display = 'none'; });
if (propSel.value === 'visibilite') {
visSel.style.display = 'block';
toggleRow.style.display = 'none';
document.getElementById('nf-action-element-toggle').checked = false;
} else if (propSel.value === 'surbrillance') {
surbrillanceSel.style.display = 'block';
toggleRow.style.display = 'none';
document.getElementById('nf-action-element-toggle').checked = false;
} else if (propSel.value === 'desactive') {
desactiveSel.style.display = 'block';
toggleRow.style.display = 'none';
document.getElementById('nf-action-element-toggle').checked = false;
} else if (propSel.value === 'orientation') {
orientationSel.style.display = 'block';
toggleRow.style.display = 'none';
document.getElementById('nf-action-element-toggle').checked = false;
} else if (propSel.value === 'contenu') {
textInp.style.display = 'block';
toggleRow.style.display = 'none';
document.getElementById('nf-action-element-toggle').checked = false;
} else if (propSel.value === 'bg_color' || propSel.value === 'border_color') {
colorInp.style.display = 'block';
toggleRow.style.display = 'flex';
} else {
numInp.style.display = 'block';
toggleRow.style.display = 'flex';
}
toggleFlowElementToggleMode();
}
function toggleFlowElementToggleMode() {
var propSel = document.getElementById('nf-action-element-property');
var checked = document.getElementById('nf-action-element-toggle').checked;
var wrap = document.getElementById('nf-action-element-value2-wrap');
var colorInp2 = document.getElementById('nf-action-element-value2-color');
var numInp2 = document.getElementById('nf-action-element-value2-number');
var isColor = propSel.value === 'bg_color' || propSel.value === 'border_color';
wrap.style.display = checked ? 'block' : 'none';
colorInp2.style.display = (checked && isColor) ? 'block' : 'none';
numInp2.style.display = (checked && !isColor) ? 'block' : 'none';
}
function submitNodeForm() {
var payload = { node_type: currentNodeFormType, x: 20, y: 20 + currentBlockNodes().length * 10, block_id: CURRENT_BLOCK_ID };
if (currentNodeFormType === 'trigger') {
payload.trigger_event = document.getElementById('nf-trigger-event').value;
if (payload.trigger_event === 'evenement') {
payload.trigger_custom_event_id = parseInt(document.getElementById('nf-trigger-custom-event').value, 10);
} else if (payload.trigger_event === 'clavier' || payload.trigger_event === 'touche_maintenue' || payload.trigger_event === 'touche_relachee') {
payload.trigger_key = document.getElementById('nf-trigger-key').value;
} else if (payload.trigger_event === 'minuteur') {
payload.trigger_interval_ms = parseInt(document.getElementById('nf-trigger-interval').value, 10);
} else if (payload.trigger_event !== 'affichage') {
payload[FLOW_TRIGGER_ID_FIELD] = parseInt(document.getElementById('nf-trigger-element').value, 10);
}
} else if (currentNodeFormType === 'condition') {
var condSource = document.getElementById('nf-cond-source').value;
payload.cond_source = condSource;
if (condSource === 'collision') {
// Phase 5 — ni opérateur ni valeur de comparaison pour une
// collision (voir onFlowConditionSourceChange()) : juste les deux
// éléments dont on compare les rectangles. Pas de clauses combinées
// (ET/OU) pour une collision — même limitation assumée que pour les
// autres sources tant qu'aucune clause supplémentaire n'a été ajoutée.
payload.cond_element_a = parseInt(document.getElementById('nf-cond-element-a').value, 10);
payload.cond_element_b = parseInt(document.getElementById('nf-cond-element-b').value, 10);
} else {
payload.cond_operator = document.getElementById('nf-cond-operator').value;
payload.cond_value = document.getElementById('nf-cond-value').value;
if (condSource === 'variable') {
payload.cond_variable = document.getElementById('nf-cond-variable').value;
payload.cond_variable_chemin = document.getElementById('nf-cond-variable-chemin').value;
} else {
var fieldSel = document.getElementById('nf-cond-field');
var fieldOpt = fieldSel.options[fieldSel.selectedIndex];
payload.cond_definition_id = parseInt(document.getElementById('nf-cond-definition').value, 10);
payload.cond_row_id = parseInt(document.getElementById('nf-cond-row').value, 10) || null;
payload.cond_field = fieldSel.value;
payload.cond_field_type = fieldOpt ? fieldOpt.dataset.type : '';
}
}
if (condSource !== 'collision' && flowConditionExtraClauses.length) {
// 2.4 — clauses combinées : la clause "historique" ci-dessus devient
// la première clause de la liste, suivie des clauses supplémentaires.
var mainClause = condSource === 'variable'
? { source: 'variable', variable: payload.cond_variable, variable_chemin: payload.cond_variable_chemin,
operator: payload.cond_operator, value: payload.cond_value }
: { source: 'objet', definition_id: payload.cond_definition_id, row_id: payload.cond_row_id,
field: payload.cond_field, field_type: payload.cond_field_type,
operator: payload.cond_operator, value: payload.cond_value };
var allClauses = [mainClause].concat(flowConditionExtraClauses.map(function(c) {
return c.source === 'variable'
? { source: 'variable', variable: c.variable, variable_chemin: c.variable_chemin, operator: c.operator, value: c.value }
: { source: 'objet', definition_id: parseInt(c.definition_id, 10), row_id: parseInt(c.row_id, 10) || null,
field: c.field, field_type: c.field_type, operator: c.operator, value: c.value };
}));
payload.cond_clauses = JSON.stringify(allClauses);
payload.cond_combinator = document.getElementById('nf-cond-combinator').value;
}
} else if (currentNodeFormType === 'action') {
var actionType = document.getElementById('nf-action-type').value;
payload.action_type = actionType;
if (actionType === 'aller_a' || actionType === 'ouvrir_ligne') {
payload.target_screen_id = parseInt(document.getElementById('nf-action-target-screen').value, 10);
} else if (actionType === 'activer_onglet') {
payload.target_element_id = parseInt(document.getElementById('nf-action-target-element').value, 10);
} else if (actionType === 'modifier_element') {
payload.target_element_id = parseInt(document.getElementById('nf-action-target-element').value, 10);
var prop = document.getElementById('nf-action-element-property').value;
payload.element_property = prop;
var isColorProp = prop === 'bg_color' || prop === 'border_color';
var toggleOn = prop !== 'visibilite' && prop !== 'surbrillance' && prop !== 'desactive' && prop !== 'orientation' && document.getElementById('nf-action-element-toggle').checked;
if (prop === 'visibilite') {
payload.element_value = document.getElementById('nf-action-element-value-visibility').value;
} else if (prop === 'surbrillance') {
payload.element_value = document.getElementById('nf-action-element-value-surbrillance').value;
} else if (prop === 'desactive') {
payload.element_value = document.getElementById('nf-action-element-value-desactive').value;
} else if (prop === 'orientation') {
payload.element_value = document.getElementById('nf-action-element-value-orientation').value;
} else if (prop === 'contenu') {
payload.element_value = document.getElementById('nf-action-element-value-text').value;
} else {
var v1 = isColorProp
? document.getElementById('nf-action-element-value-color').value
: document.getElementById('nf-action-element-value-number').value;
if (toggleOn) {
var v2 = isColorProp
? document.getElementById('nf-action-element-value2-color').value
: document.getElementById('nf-action-element-value2-number').value;
payload.element_value = v1 + '||' + v2;
} else {
payload.element_value = v1;
}
}
} else if (actionType === 'modifier_objet_scene') {
// Fondations multi-éditeurs — équivalent scène de "modifier_element"
// ci-dessus, palette réduite (voir OBJECT_ACTION_PROPERTIES) : pas
// de va-et-vient/couleur, juste visibilité/position en pixels/
// orientation. FLOW_TARGET_ID_FIELD vaut "target_object_id" ici
// (voir tête de fichier).
payload[FLOW_TARGET_ID_FIELD] = parseInt(document.getElementById('nf-action-target-object').value, 10);
var objProp = document.getElementById('nf-action-object-property').value;
payload.element_property = objProp;
if (objProp === 'visibilite') {
payload.element_value = document.getElementById('nf-action-object-value-visibility').value;
} else if (objProp === 'orientation') {
payload.element_value = document.getElementById('nf-action-object-value-orientation').value;
} else {
payload.element_value = document.getElementById('nf-action-object-value-number').value;
}
} else if (actionType === 'modifier_donnee') {
payload.target_definition_id = parseInt(document.getElementById('nf-action-data-definition').value, 10);
payload.target_row_id = parseInt(document.getElementById('nf-action-data-row').value, 10) || null;
payload.target_field = document.getElementById('nf-action-data-field').value;
payload.data_operation = document.getElementById('nf-action-data-operation').value;
payload.data_value = document.getElementById('nf-action-data-value').value;
} else if (actionType === 'modifier_variable') {
payload.target_variable = document.getElementById('nf-action-variable-name').value;
payload.data_operation = document.getElementById('nf-action-variable-operation').value;
payload.data_value = document.getElementById('nf-action-variable-value').value;
} else if (actionType === 'modifier_score') {
// Score/progression (voir db/scoring/) : un seul score par joueur,
// pas de nom à transmettre (contrairement à "modifier_variable").
payload.data_operation = document.getElementById('nf-action-score-operation').value;
payload.data_value = document.getElementById('nf-action-score-value').value;
} else if (actionType === 'definir_statut_partie') {
payload.data_value = document.getElementById('nf-action-status-value').value;
} else if (actionType === 'attendre') {
payload.data_value = document.getElementById('nf-action-wait-seconds').value;
} else if (actionType === 'declencher_evenement') {
// Notification pure : aucun élément/ligne à transmettre (voir
// screens/custom_events/) — c'est à l'écouteur de décider quoi
// faire, avec ses propres réglages habituels.
payload.target_custom_event_id = parseInt(document.getElementById('nf-action-custom-event').value, 10);
} else if (actionType === 'ajouter_ligne') {
// Phase 4 — crée une ligne VIDE ; ses champs se renseignent via un ou
// plusieurs nœuds "Modifier une donnée" enchaînés, ciblant
// FLOW_LAST_INSERTED_ROW_ID ("➕ Dernière ligne ajoutée").
payload.target_definition_id = parseInt(document.getElementById('nf-action-add-row-definition').value, 10);
} else if (actionType === 'jouer_son') {
// Phase 6 — réutilise data_value (déjà un simple champ texte générique
// sur le nœud action, comme pour "attendre") plutôt qu'une nouvelle
// colonne dédiée.
payload.data_value = document.getElementById('nf-action-sound-url').value;
} else if (actionType === 'jouer_animation_sprite') {
// Phase 8 — stocke le NOM de l'animation choisie (miniature cliquée,
// voir onSpriteTargetElementChange()), PAS ses frames : résolues à
// l'exécution depuis le personnage ACTUELLEMENT assigné à la cible
// (voir resolveSpriteFrames() dans static/js/play/actions.js) —
// sinon changer le personnage Forge de l'élément dans l'éditeur
// n'avait aucun effet sur les animations déjà posées dans la
// logique, qui continuaient à jouer les frames de l'ancien
// personnage indéfiniment (bug signalé par l'utilisateur).
var elementId = document.getElementById('nf-action-sprite-target-element').value;
payload[FLOW_TARGET_ID_FIELD] = parseInt(elementId, 10);
payload.data_value = JSON.stringify({
animation: document.getElementById('nf-action-sprite-animation-name').value,
fps: parseInt(document.getElementById('nf-action-sprite-fps').value, 10) || 8,
loop: document.getElementById('nf-action-sprite-loop').checked,
});
}
}
fetch(flowUrl('/screens/' + FLOW_SCREEN_ID + '/flow/nodes/add'), {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(node){
ALL_FLOW.nodes.push(node);
closeNodeForm();
renderFlow();
});
}
+6 -6
View File
@@ -36,10 +36,10 @@ function initBuilderPanel() {
.then(function (json) {
if (json.url) {
targetField.value = json.url;
status.textContent = 'Fichier envoyé ✓ Enregistrement...';
status.textContent = 'Fichier envoyé — Enregistrement...';
// Enregistre tout de suite, comme n'importe quel autre réglage —
// un fichier envoyé ne doit jamais rester "en attente".
submitPropsForm().then(function () { status.textContent = 'Fichier envoyé ✓ Enregistré.'; });
submitPropsForm().then(function () { status.textContent = 'Fichier envoyé — Enregistré.'; });
}
else { status.textContent = "Échec de l'envoi."; }
})
@@ -63,7 +63,7 @@ function initBuilderPanel() {
fetch("/game/" + GAME_SLUG + "/upload", { method: 'POST', body: data })
.then(function (r) { return r.json(); })
.then(function (json) {
if (json.url) { targetField.value = json.url; status.textContent = 'Fichier envoyé ✓'; }
if (json.url) { targetField.value = json.url; status.textContent = 'Fichier envoyé.'; }
else { status.textContent = "Échec de l'envoi."; }
})
.catch(function () { status.textContent = "Échec de l'envoi."; });
@@ -442,8 +442,8 @@ function bindScreenBackgroundMusicField() {
.then(function (json) {
if (json.url) {
targetField.value = json.url;
status.textContent = 'Fichier envoyé ✓ Enregistrement...';
saveScreenBackgroundMusic(json.url).then(function () { status.textContent = 'Fichier envoyé ✓ Enregistré.'; });
status.textContent = 'Fichier envoyé — Enregistrement...';
saveScreenBackgroundMusic(json.url).then(function () { status.textContent = 'Fichier envoyé — Enregistré.'; });
}
else { status.textContent = "Échec de l'envoi."; }
})
@@ -483,7 +483,7 @@ document.querySelectorAll('.uploadInput').forEach(function(input){
.then(function(json){
if (json.url) {
targetField.value = json.url;
status.textContent = 'Fichier envoyé ✓ Enregistrement...';
status.textContent = 'Fichier envoyé — Enregistrement...';
// Enregistre tout de suite (comme un clic sur "Enregistrer") pour
// qu'un fichier envoyé ne reste jamais "en attente" si on oublie
// de cliquer sur le bouton ensuite — l'image doit apparaître dès
-119
View File
@@ -1,119 +0,0 @@
// switchBuilderTab()/toggleDashCreate() ont déménagé dans builder-tabs.js
// (générique, chargé par les DEUX éditeurs — voir ce fichier) : tout ce
// qui reste ici est propre aux blocs de logique/panneaux flottants,
// jamais chargé par l'éditeur de scène 2D (scene_edit.html).
function toggleBlockEditRow(blockId) {
document.getElementById('blockEditRow' + blockId).classList.toggle('hidden');
}
// ---------- Panneaux flottants : glisser-déposer + redimensionnement +
// plein écran ---------- Générique, porté tel quel depuis game_dashboard.html
// (mêmes gestes partout dans l'outil plutôt qu'une variante par panneau).
function makeFloatPanelDraggable(panel, header) {
var dragging = false, offsetX = 0, offsetY = 0;
header.addEventListener('mousedown', function (e) {
if (e.target.closest('button')) return;
dragging = true;
if (panel.classList.contains('floatPanel--fullscreen')) panel._fsToggle.exit();
var rect = panel.getBoundingClientRect();
panel.style.left = rect.left + 'px';
panel.style.top = rect.top + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
panel.style.transform = 'none';
offsetX = e.clientX - rect.left;
offsetY = e.clientY - rect.top;
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', function (e) {
if (!dragging) return;
panel.style.left = (e.clientX - offsetX) + 'px';
panel.style.top = (e.clientY - offsetY) + 'px';
});
document.addEventListener('mouseup', function () {
dragging = false;
document.body.style.userSelect = '';
});
}
function makeFloatPanelResizable(panel, handle) {
var resizing = false, startX = 0, startY = 0, startW = 0, startH = 0;
handle.addEventListener('mousedown', function (e) {
resizing = true;
if (panel.classList.contains('floatPanel--fullscreen')) panel._fsToggle.exit();
var rect = panel.getBoundingClientRect();
startX = e.clientX; startY = e.clientY;
startW = rect.width; startH = rect.height;
panel.style.width = startW + 'px';
panel.style.maxWidth = 'none';
document.body.style.userSelect = 'none';
e.preventDefault();
});
document.addEventListener('mousemove', function (e) {
if (!resizing) return;
panel.style.width = Math.max(320, startW + (e.clientX - startX)) + 'px';
panel.style.height = Math.max(200, startH + (e.clientY - startY)) + 'px';
});
document.addEventListener('mouseup', function () {
resizing = false;
document.body.style.userSelect = '';
});
}
function makeFloatPanelFullscreenable(panel, toggleBtn) {
var saved = null;
function enter() {
saved = {
left: panel.style.left, top: panel.style.top, right: panel.style.right,
width: panel.style.width, height: panel.style.height,
maxWidth: panel.style.maxWidth, transform: panel.style.transform,
};
panel.classList.add('floatPanel--fullscreen');
panel.style.left = '12px'; panel.style.top = '12px'; panel.style.right = '12px'; panel.style.bottom = '12px';
panel.style.width = 'auto'; panel.style.height = 'auto'; panel.style.maxWidth = 'none'; panel.style.transform = 'none';
toggleBtn.title = 'Quitter le plein écran';
}
function exit() {
panel.classList.remove('floatPanel--fullscreen');
panel.style.bottom = '';
if (saved) {
panel.style.left = saved.left; panel.style.top = saved.top; panel.style.right = saved.right;
panel.style.width = saved.width; panel.style.height = saved.height;
panel.style.maxWidth = saved.maxWidth; panel.style.transform = saved.transform;
}
toggleBtn.title = 'Plein écran';
}
toggleBtn.addEventListener('click', function () {
if (panel.classList.contains('floatPanel--fullscreen')) exit(); else enter();
});
panel._fsToggle = { enter: enter, exit: exit };
return panel._fsToggle;
}
// ---------- Modale d'un bloc de logique (voir screens/flow/blocks/) : un
// seul bloc ouvert à la fois, réutilise le même #nodeForm/#flowCanvas pour
// n'importe quel bloc — voir currentBlockNodes()/currentBlockEdges(). ----------
(function () {
var panel = document.getElementById('logicBlockPanel');
makeFloatPanelDraggable(panel, document.getElementById('logicBlockPanelHeader'));
makeFloatPanelResizable(panel, document.getElementById('logicBlockPanelResizeHandle'));
makeFloatPanelFullscreenable(panel, panel.querySelector('.floatPanelFullscreenBtn'));
window.openLogicBlockPanel = function (blockId) {
CURRENT_BLOCK_ID = blockId;
closeNodeForm();
var block = FLOW_BLOCKS.find(function (b) { return b.id === blockId; });
document.getElementById('logicBlockPanelTitle').textContent = (block && block.name) || '?';
panel.classList.remove('hidden');
panel._fsToggle.enter();
renderFlow();
requestAnimationFrame(drawFlowEdges);
};
window.closeLogicBlockPanel = function () {
panel.classList.add('hidden');
CURRENT_BLOCK_ID = null;
};
})();
// L'ouverture initiale ?tab=/?block=... (données Jinja : request.args)
// reste dans le petit <script> inline en fin de screen_edit.html, après
// ce fichier — voir le commentaire là-bas.
+14 -4
View File
@@ -249,7 +249,7 @@ function handleDeleteElementSubmit(event, form) {
if (!data.flow_node_count) return;
var n = data.flow_node_count;
var plural = n > 1 ? 's' : '';
warnEl.textContent = '⚠️ ' + n + ' connexion' + plural + ' de la Logique de la scène (déclencheur/action) utilise' +
warnEl.textContent = n + ' connexion' + plural + ' de la Logique de la scène (déclencheur/action) utilise' +
(n > 1 ? 'nt' : '') + ' cet élément et sera' + (n > 1 ? 'ont' : '') + ' aussi supprimée' + plural + '.';
warnEl.style.display = 'block';
})
@@ -533,7 +533,11 @@ function refreshAfterReorder() {
// le bouton "précédent" du navigateur continue à fonctionner normalement.
function refreshBuilderPanel(selectedId) {
var url = SCREEN_EDIT_URL + "?selected=" + selectedId;
// selectedId falsy (0/null/undefined) : referme le panneau de propriétés
// plutôt que de garder une sélection (ex. après suppression de l'objet
// sélectionné, voir deleteSceneObject(), static/js/scenes/scene-editor.js)
// — jamais "?selected=null/undefined" dans l'URL.
var url = SCREEN_EDIT_URL + (selectedId ? ("?selected=" + selectedId) : "");
return fetch(url, { headers: { "X-Requested-With": "pjax" } })
.then(function (r) { return r.text().then(function (html) { return { html: html, url: r.url || url }; }); })
.then(function (result) {
@@ -543,9 +547,15 @@ function refreshBuilderPanel(selectedId) {
if (newBuilder && curBuilder) {
curBuilder.innerHTML = newBuilder.innerHTML;
}
CURRENT_SELECTED_ID = selectedId;
CURRENT_SELECTED_ID = selectedId || null;
history.pushState({ pjax: true }, "", result.url);
initBuilderPanel();
// Éditeur de scène 2D (voir static/js/scenes/scene-editor.js) : ce
// fichier générique (partagé avec l'éditeur document) ne connaît pas
// ses réglages propres (position en pixels, panneau "Commandes",
// aperçu animé des personnages) — rien à faire ici pour l'éditeur
// document, où cette fonction n'existe simplement pas.
if (typeof initSceneBuilderPanel === "function") initSceneBuilderPanel();
})
.catch(function () {
// Échec réseau : on retombe sur une vraie navigation plutôt que de
@@ -683,7 +693,7 @@ function flashAutosave(state) {
autosaveIndicator.textContent = 'Enregistrement...';
autosaveIndicator.classList.add('saving');
} else {
autosaveIndicator.textContent = 'Enregistré automatiquement ✓';
autosaveIndicator.textContent = 'Enregistré automatiquement';
autosaveIndicator.classList.add('saved');
}
}
+119 -13
View File
@@ -219,10 +219,21 @@ input[type=checkbox]{ width:auto; margin-top:6px; }
les différents éditeurs d'un même jeu, en ligne (contrairement à
.navList/.navRow ci-dessous, empilés verticalement dans le panneau
"Créer" du tableau de bord). */
.gameNavBar{ display:flex; flex-wrap:wrap; justify-content:center; gap:6px; padding:8px 20px; background:transparent; border-bottom:1px solid var(--border); }
/* space-between (jamais center, voir le commentaire d'en-tête plus haut) :
"Mes écrans" (page de gestion) reste à gauche, "Jouer"/"Exporter"
(.gameNavRight, des ACTIONS) groupés à droite — demande explicite. */
.gameNavBar{ display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; gap:6px; padding:8px 20px; background:transparent; border-bottom:1px solid var(--border); }
.gameNavRight{ display:flex; flex-wrap:wrap; gap:6px; margin-left:auto; }
.gameNavLink{ display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border:1px solid var(--border); border-radius:6px; background:var(--panel); text-decoration:none; color:var(--text); font-size:12.5px; }
.gameNavLink:hover{ background:var(--panel2); border-color:var(--accent); }
.gameNavLink.active{ background:var(--panel2); border-color:var(--accent); color:var(--accent); }
/* "Jouer" (orange, --accent) / "Exporter" (vert, --status-success) —
couleur pleine plutôt qu'un simple contour, pour ressortir comme des
actions parmi les liens de gestion neutres. */
.gameNavLink--play{ background:var(--accent); border-color:var(--accent); color:#fff; font-weight:600; }
.gameNavLink--play:hover{ background:var(--accent); filter:brightness(1.1); border-color:var(--accent); }
.gameNavLink--export{ background:var(--status-success); border-color:var(--status-success); color:#fff; font-weight:600; }
.gameNavLink--export:hover{ background:var(--status-success); filter:brightness(1.1); border-color:var(--status-success); }
.content{ max-width:760px; margin:0 auto; padding:28px 20px; }
.card{ border:1px solid var(--border); border-radius:10px; padding:16px; background:var(--panel); margin:12px 0; }
@@ -246,14 +257,26 @@ input[type=checkbox]{ width:auto; margin-top:6px; }
.onboardingStep{ flex:0 0 50%; width:50%; box-sizing:border-box; padding:12px 24px; }
/* ---------- Étape 1 : cartes de type, grandes, retournables ---------- */
.onboardingGrid{ display:flex; gap:18px; height:66vh; min-height:420px; max-height:640px; }
/* Un seul choix aujourd'hui (RPG, voir db/games/game_type_catalog.py) :
pleine hauteur (comme avant), mais LARGEUR d'1/4 (comme s'il y avait
toujours 4 cartes côte à côte) et centrée — demande explicite, plutôt
que de repenser toute la mise en page pour un seul choix. */
.onboardingGrid{ display:flex; gap:18px; height:66vh; min-height:420px; max-height:640px; max-width:25%; margin:0 auto; transition:max-width .35s ease; }
/* Élargit la carte (jamais un scale() : demande explicite, "s'élargir sur
sa width", pas grossir dans les deux sens) au survol/à la sélection —
pour lire confortablement le dos retourné (titre + liste + badge, voir
.onboardingPitch*) sans reprendre toute la mise en page à 4 cartes. Le
sélecteur :has() cible le PARENT depuis l'état d'un enfant (support
navigateurs modernes déjà requis ailleurs, voir container queries
"cqw" plus bas). */
.onboardingGrid:has(.onboardingCard:hover), .onboardingGrid:has(.onboardingCard.is-selected){ max-width:42%; }
.onboardingCard{
flex:1; position:relative; cursor:pointer; padding:0; margin:0; border:none; background:none;
border-radius:20px; perspective:1400px;
opacity:0; animation:forgeOnboardCardIn .5s ease both;
transition:flex-grow .3s ease;
}
.onboardingCard:hover{ flex-grow:1.25; }
.onboardingCard:hover{ flex-grow:1.25; z-index:1; }
.onboardingCardInner{
position:relative; display:block; width:100%; height:100%;
transform-style:preserve-3d; transition:transform .6s cubic-bezier(.4,.2,.2,1);
@@ -269,9 +292,30 @@ input[type=checkbox]{ width:auto; margin-top:6px; }
.onboardingCardIcon{ font-size:3.4em; line-height:1; filter:drop-shadow(0 4px 10px rgba(0,0,0,.35)); }
.onboardingCardLabel{ font-weight:800; font-size:1.3em; letter-spacing:.01em; }
.onboardingCardBack{
transform:rotateY(180deg); background:var(--panel); border:2px solid var(--accent);
transform:rotateY(180deg); background:linear-gradient(165deg, var(--panel), var(--panel2));
border:2px solid var(--accent); justify-content:flex-start; padding:26px 22px;
}
/* Argumentaire de vente (voir db/games/game_type_catalog.py::ONBOARDING_TYPES,
"description" contient du HTML de confiance — jamais de saisie
utilisateur — d'où |safe dans le template) : un titre accrocheur, une
liste à coche plutôt qu'un bloc de texte brut, un badge LMS mis en
avant en bas — pensé pour convaincre un public RH non technique en un
coup d'œil, pas juste informer. */
.onboardingCardDesc{ display:flex; flex-direction:column; gap:16px; width:100%; text-align:left; }
.onboardingPitchTitle{ font-size:1.15em; font-weight:800; line-height:1.3; color:var(--text); }
.onboardingPitchList{ list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:12px; }
.onboardingPitchList li{ display:flex; align-items:flex-start; gap:10px; font-size:.93em; line-height:1.45; color:var(--text-dim); }
.onboardingPitchCheck{
flex:0 0 auto; width:20px; height:20px; border-radius:50%; background:var(--gradient);
color:#fff; display:flex; align-items:center; justify-content:center; font-size:.7em; font-weight:900;
box-shadow:0 2px 8px var(--glow); margin-top:1px;
}
.onboardingPitchList strong{ color:var(--text); }
.onboardingPitchBadge{
display:flex; align-items:center; gap:8px; font-size:.85em; font-weight:700;
color:var(--accent); background:rgba(255,95,46,.12); border:1px solid rgba(255,95,46,.35);
border-radius:999px; padding:8px 14px; align-self:flex-start;
}
.onboardingCardDesc{ color:var(--text); font-size:1em; line-height:1.5; }
/* Une couleur de dégradé par type — voir data-type dans le template,
généré depuis db.ONBOARDING_TYPES (mêmes clés que ci-dessous). */
@@ -513,10 +557,14 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
}
.floatPanelHeader .floatPanelTitle{ font-size:11px; color:var(--text-dim); text-transform:uppercase; letter-spacing:.05em; }
.floatPanelBody{ padding:12px; overflow-y:auto; min-height:0; flex:1 1 auto; }
.floatPanelReopenBtn{ position:fixed; top:90px; z-index:60; display:none; }
/* Posés dans la barre d'outils de la scène (templates/scene_edit.html),
plus en position:fixed flottant au-dessus de toute la page — ils
chevauchaient "Mes écrans"/"Jouer"/"Exporter" (voir base.html) une fois
les panneaux fermés. display:none/.visible (JS : openFloatPanel/
closeFloatPanel, static/js/screen_edit/tree-panels.js) reste la même
logique : visible seulement quand le panneau correspondant est fermé. */
.floatPanelReopenBtn{ display:none; }
.floatPanelReopenBtn.visible{ display:inline-flex; }
#panelLeftReopenBtn{ left:24px; }
#propsPanelReopenBtn{ right:24px; }
.widgetGrid{ display:grid; grid-template-columns:1fr 1fr; gap:8px; }
@@ -615,6 +663,29 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
.canvasElement:hover{ border-color:rgba(91,140,255,.5); }
.canvasElement.selected{ border:2px solid var(--accent); }
.canvasElementInner{ width:100%; height:100%; pointer-events:none; }
/* render_scene_object.py positionne son <img> en absolu (left/top/width/
height en px) pour s'afficher correctement en enfant DIRECT de
.playScreen.playScene (voir templates/play.html) — dans le canevas de
l'éditeur de scène (templates/scene_edit.html), cette même balise est
nichée dans .canvasElementInner, lui-même déjà positionné/dimensionné
par .canvasElement ci-dessus : sans ce correctif, l'image se
positionnait EN PLUS depuis #canvas (son ancêtre positionné le plus
proche), en double du décalage déjà appliqué par .canvasElement.
Posé ici (feuille de style globale, chargée par TOUTES les pages) et
non plus dans un <style> inline propre à scene_edit.html : la
navigation "Mes écrans" -> scène se fait en pjax (voir static/pjax.js),
qui ne fusionne dans <head> que les <link rel="stylesheet"> manquants,
JAMAIS un <style> inline — arriver sur une scène par un clic (plutôt
qu'un vrai chargement de page) laissait donc cette règle absente, et
les personnages/décors se retrouvaient mal positionnés (invisibles)
tant qu'aucun rechargement complet n'avait eu lieu. */
.canvasElementInner > img{ position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important; }
/* Même correctif que ci-dessus, pour les widgets d'interface ("💬 Boîte
de dialogue"/"❓ Boîte à quiz"/"🏆 Score", screens/scenes/
render_scene_object.py) — des <div>, pas des <img>. */
.canvasElementInner > .dialogueBoxWidget, .canvasElementInner > .quizBoxWidget, .canvasElementInner > .scoreWidget{
position:static !important; left:auto !important; top:auto !important; width:100% !important; height:100% !important; z-index:auto !important;
}
/* Objet de scène (screens/scenes/render_scene_object.py, éditeur ET jeu
jouable) : object-fit:contain — les frames CraftPix (screens/labels/
animal_sprite_library.py) sont de grands canevas très paddés, très
@@ -808,19 +879,31 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
border:1px solid var(--border); border-radius:12px; padding:8px 10px;
}
.collisionRuleChainLinks{ display:flex; align-items:center; flex-wrap:wrap; row-gap:6px; }
/* Pointe/encoche en PIXELS FIXES (calc(100% - 14px), jamais un pourcentage
de la largeur du maillon) — un maillon plus court ("PNJ") ou plus long
("Déclencher une quête « ... »") a alors TOUJOURS la même profondeur de
pointe, qui s'emboîte exactement dans l'encoche du suivant (même 14px
que la marge négative ci-dessous) : avec un pourcentage, la profondeur
de la pointe variait avec la largeur du texte, sans jamais coïncider
avec l'encoche du voisin — les pièces flottaient les unes par rapport
aux autres au lieu de s'emboîter. */
.collisionRuleLink{
--link-color:var(--accent);
position:relative; display:inline-flex; align-items:center; gap:4px;
background:linear-gradient(135deg, var(--link-color), color-mix(in srgb, var(--link-color) 70%, #000 30%));
color:#fff; padding:7px 16px 7px 20px; font-size:12.5px; font-weight:600; white-space:nowrap;
clip-path:polygon(0% 0%, 90% 0%, 100% 50%, 90% 100%, 0% 100%, 10% 50%);
margin-left:-12px; z-index:1; animation:collisionLinkIn .3s ease backwards;
clip-path:polygon(0 0, calc(100% - 14px) 0, 100% 50%, calc(100% - 14px) 100%, 0 100%, 14px 50%);
margin-left:-14px; z-index:1; animation:collisionLinkIn .3s ease backwards;
transition:transform .15s ease, filter .15s ease;
}
.collisionRuleLink:first-child{ margin-left:0; clip-path:polygon(0% 0%, 90% 0%, 100% 50%, 90% 100%, 0% 100%); padding-left:14px; border-radius:8px 0 0 8px; }
.collisionRuleLink:first-child{ margin-left:0; clip-path:polygon(0 0, calc(100% - 14px) 0, 100% 50%, calc(100% - 14px) 100%, 0 100%); padding-left:14px; border-radius:8px 0 0 8px; }
.collisionRuleLink:hover{ transform:translateY(-2px) scale(1.04); filter:brightness(1.12); z-index:2; }
.collisionRuleLink[data-kind="trigger"]{ --link-color:#f2994a; }
.collisionRuleLink[data-kind="quete"]{ --link-color:#9b59b6; }
/* "quete" est le seul type de maillon cliquable (voir collision-rules-
editor.js — un onclick posé directement sur le <span>, jamais un
<button> : son style natif grignotait la pointe en clip-path même
après reset), d'où le curseur en plus ici. */
.collisionRuleLink[data-kind="quete"]{ --link-color:#9b59b6; cursor:pointer; }
.collisionRuleLink[data-kind="attaque"]{ --link-color:#e0524d; }
.collisionRuleLink[data-kind="evenement"]{ --link-color:#17a2b8; }
.collisionRuleLink[data-kind="interagir"]{ --link-color:#d4a72c; }
@@ -884,6 +967,13 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
(distincte de la modale de fiche ci-dessus) : couvre tout le viewport
plutôt qu'une boîte centrée, pour laisser toute la place aux 3
colonnes de répliques. */
/* z-index:61 (au-dessus de .floatPanel, à 60, voir plus haut) : sans ça,
les panneaux flottants "Objets"/"Propriétés" restaient peints par-
dessus cette modale PLEIN ÉCRAN. Les boutons "Objets"/"Propriétés"
eux-mêmes (.floatPanelReopenBtn) vivent maintenant dans la barre
d'outils de la scène, plus en position:fixed — ce n'est donc plus qu'un
souci entre CETTE modale et les panneaux flottants encore ouverts. */
.questDialogueModal{ z-index:61; }
.questDialogueModal .modal-content{ width:100vw; height:100vh; max-width:100vw; max-height:100vh; margin:0; }
/* La modale entière est une colonne flex (jamais un scroll global, voir
overflow:hidden ici) : le header/la fiche restent fixes en haut, les 3
@@ -1097,7 +1187,12 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
.builderTabBtn:hover{ color:var(--text); }
.builderTabBtn.active{ color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
.builderTabPanel{ flex:1 1 auto; min-height:0; display:none; flex-direction:column; }
/* overflow-y:auto : contrairement à "screen" (voir .canvasFrame plus haut,
qui défile LUI-MÊME), "collision-rules"/"quests"/"events" n'ont pas de
sous-conteneur dédié au défilement — sans ça, une liste plus haute que
l'espace disponible restait simplement coupée par .builderCanvasArea
(overflow:hidden), au lieu de défiler. */
.builderTabPanel{ flex:1 1 auto; min-height:0; display:none; flex-direction:column; overflow-y:auto; }
.builderTabPanel.active{ display:flex; }
/* Sous-onglets Champs/Données À L'INTÉRIEUR d'un panneau "Modifier un
@@ -1223,6 +1318,17 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
position:absolute; top:0; left:0; transform-origin:top left;
transform:scale(calc(100cqw / var(--scene-w)));
}
/* obj.rendered_html (screens/scenes/render_scene_object.py) porte un
z-index PENSÉ POUR L'ÉDITEUR DE SCÈNE (souvent très négatif — un "fond"
à -1000, un personnage juste au-dessus — pour rester sous les cadres/
poignées de sélection de CET éditeur, voir templates/scene_edit.html) :
réutilisé tel quel ici, ce z-index très négatif faisait passer l'objet
SOUS le fond de la carte (.screenPreviewBox, background:#0b0d12),
invisible. L'ordre d'empilement voulu ici (fond, puis personnages/décor
par-dessus) est déjà garanti par l'ORDRE dans lequel screen_preview()
(game_dashboard_simple.html) les liste — z-index:auto (l'ordre du DOM)
suffit donc, sans dépendre de leurs valeurs numériques d'origine. */
.screenPreviewScene > *{ z-index:auto !important; }
.screenPreviewEmpty{
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
color:var(--forge-text-muted, #8b95a7); font-size:.85em;