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');
}
}