Enrichit les declencheurs/actions de scene (clic/survol/affichage, surbrillance/video/son/visibilite/indication/attendre) et fiabilise la pose d'un fond/decor importe
Build and deploy / test-python (push) Successful in 10m59s
Build and deploy / test-js (push) Successful in 1m27s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

- Ajoute clic/survol/affichage-ecran comme declencheurs, et surbrillance,
  video, son, visibilite, indication, attendre comme actions, utilisables
  aussi bien par l'editeur manuel (menu lateral Objets/Ecran) que par
  Ruby (IA), avec blocs deplacables/supprimables dans une chaine.
- Corrige plusieurs variantes du bug "impossible de poser un objet hors
  du champ de la camera" (troncature du chainage d'actions a 4 maillons,
  fond importe pose a 128x128 au lieu de sa taille reelle, decalage du
  fond au vrai glisser-depose, redimensionnement manuel jamais propage
  au monde).
- Ajoute un vrai glisser-depose depuis la galerie vers la scene, la
  gestion complete de "Mes assets" (sous-sections Fonds/Decors/Sons/
  Videos, suppression, reclassement fond<->decor sans re-upload).
- Ajoute l'upload de son (limite 3 min) et de video (MP4 uniquement,
  limite 5 min), avec validation de la duree reelle du fichier, et une
  replique audio optionnelle dans une bulle de dialogue.
- Fixe la taille de pose d'un objet/decor importe a 200x200 avec une
  boite de collision de 150x150.
- Filtre le selecteur de fichier des actions son/video par type reel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-12 00:28:46 +02:00
co-authored by Claude Sonnet 5
parent b07b231a61
commit 559331f9cf
67 changed files with 4739 additions and 306 deletions
+428 -62
View File
@@ -63,9 +63,35 @@ function triggerColumnHtml() {
// (pastilles/carte objet), avec un bouton "Réplique"/"Question" qui
// ajoute une bulle directement dedans.
// Voix off d'une réplique (demande explicite : "ajouter une réplique
// audio") — un fichier déjà présent dans "Mes assets" (voir
// USER_ASSETS_OPTIONS, posé par templates/scene_edit.html), jamais une
// URL tapée à la main : filtré aux sons (kind="audio", voir
// auth/user_asset_kind.py) — un choix de son ou de vidéo n'a pas de sens
// ici.
function triggerAudioAssetOptions() {
return (typeof USER_ASSETS_OPTIONS !== 'undefined' ? USER_ASSETS_OPTIONS : []).filter(function (a) { return a.kind === 'audio'; });
}
function triggerBubbleAudioControlHtml(dialogueId, index, audioUrl) {
var assetOptions = triggerAudioAssetOptions();
var selectOptions = '<option value="">Aucune voix off</option>' + assetOptions.map(function (a) {
return '<option value="' + a.url.replace(/"/g, '&quot;') + '"' + (a.url === audioUrl ? ' selected' : '') + '>' + a.name.replace(/</g, '&lt;') + '</option>';
}).join('');
var preview = audioUrl
? '<audio controls preload="none" src="' + audioUrl.replace(/"/g, '&quot;') + '" class="questBubbleAudioPreview"></audio>'
: '';
return (
'<div class="questBubbleAudioRow">' +
'<select class="questBubbleAudioSelect" title="Voix off (optionnel)" onchange="triggerInlineSetBubbleAudio(\'' + dialogueId + '\', ' + index + ', this.value)">' + selectOptions + '</select>' +
preview +
'</div>'
);
}
// Une bulle = header (qui parle, menu déroulant des objets de scène
// nommés — voir "ℹ️ Informations") + body (le texte) — deux zones
// distinctes, pas un seul bloc.
// nommés — voir "ℹ️ Informations") + body (le texte + la voix off
// optionnelle) — trois zones distinctes, pas un seul bloc.
function triggerDialogueBubbleHtml(dialogueId, line, index) {
var kind = triggerBubbleKind(line.speaker);
var speakerSelect = triggerSpeakerOptions(line.speaker).map(function (name) {
@@ -82,6 +108,7 @@ function triggerDialogueBubbleHtml(dialogueId, line, index) {
'<textarea class="questBubbleText" rows="1" placeholder="Réplique..." ' +
'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" ' +
'onchange="triggerInlineUpdateBubbleText(\'' + dialogueId + '\', ' + index + ', this.value)">' + line.text.replace(/</g, '&lt;') + '</textarea>' +
triggerBubbleAudioControlHtml(dialogueId, index, line.audio_url) +
'</div>' +
'</div>' +
'</div>'
@@ -160,27 +187,154 @@ function triggerVariableActionHtml(item) {
);
}
// ---------- Feuilles "surbrillance"/"visibilite"/"son"/"video"/
// "indication" (voir screens/rendering/collision_rules.py) — même
// encart compact que "Modifier une variable" ci-dessus, un simple texte
// descriptif (rien à éditer en ligne, contrairement au dialogue).
var COLLISION_LEAF_PILL_LABELS = {
dialogue: 'Déclencher ce dialogue', variable: 'Modifier une variable',
surbrillance: 'Mettre en surbrillance', visibilite: 'Rendre visible/invisible',
son: 'Jouer un son', video: 'Jouer une vidéo', indication: 'Afficher une indication',
attendre: 'Attendre quelques secondes',
};
function triggerItemLeafId(item) {
return item.type === 'dialogue' ? (item.dialogue && item.dialogue.id) : item.id;
}
// URL commune retrait/déplacement — objet (routes/triggers/triggers.py)
// ou écran (routes/screens/screen_triggers.py, toujours CURRENT_SCREEN_ID,
// aucun `targetId` à connaître).
function triggerChainActionUrl(target, targetId, leafId, suffix) {
return target === 'screen'
? '/game/' + GAME_SLUG + '/screens/' + CURRENT_SCREEN_ID + '/triggers/action/' + encodeURIComponent(leafId) + suffix
: '/game/' + GAME_SLUG + '/triggers/' + targetId + '/action/' + encodeURIComponent(leafId) + suffix;
}
function triggerReloadFor(target) {
if (target === 'screen') screenTriggerLoadAll();
else triggerLoadAll();
}
// Demande explicite : "que chaque bloc étiquette + action soit
// déplaçable ou supprimable par l'utilisateur et par l'IA aussi" — voir
// screens/rendering/collision_rules.py::remove_action_from_rules/
// move_action_in_rules, déjà utilisables par Ruby via remove_trigger_action/
// move_trigger_action (voir ai/tools.py).
function triggerRemoveChainAction(target, targetId, leafId) {
if (!confirm('Retirer cette action ?')) return;
fetch(triggerChainActionUrl(target, targetId, leafId, ''), { method: 'DELETE' })
.then(function (r) {
if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible de retirer cette action."); });
})
.then(function () { triggerReloadFor(target); });
}
function triggerMoveChainAction(target, targetId, leafId, direction) {
fetch(triggerChainActionUrl(target, targetId, leafId, '/move'), {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction: direction }),
}).then(function (r) {
if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible de déplacer cette action."); });
}).then(function () { triggerReloadFor(target); });
}
// `target`/`targetId` absents (undefined) : pastille sans contrôles —
// utilisé pour "interagir" lui-même (pas une feuille adressable, voir
// _leaf_id côté serveur) dans triggerChainHtml ci-dessous.
function triggerLeafPillHtml(item, target, targetId, index, total) {
var label = COLLISION_LEAF_PILL_LABELS[item.type] || item.type;
var leafId = target && triggerItemLeafId(item);
var controls = '';
if (leafId) {
var targetIdArg = targetId == null ? 'null' : targetId;
controls =
'<span class="questTriggerPillControls">' +
'<button type="button" title="Monter" class="questTriggerPillCtrlBtn"' + (index === 0 ? ' disabled' : '') +
' onclick="event.stopPropagation(); triggerMoveChainAction(\'' + target + '\', ' + targetIdArg + ', \'' + leafId + '\', \'up\')">↑</button>' +
'<button type="button" title="Descendre" class="questTriggerPillCtrlBtn"' + (index === total - 1 ? ' disabled' : '') +
' onclick="event.stopPropagation(); triggerMoveChainAction(\'' + target + '\', ' + targetIdArg + ', \'' + leafId + '\', \'down\')">↓</button>' +
'<button type="button" title="Retirer cette action" class="questTriggerPillCtrlBtn questTriggerPillCtrlBtn-delete" ' +
'onclick="event.stopPropagation(); triggerRemoveChainAction(\'' + target + '\', ' + targetIdArg + ', \'' + leafId + '\')">✕</button>' +
'</span>';
}
return '<div class="questTriggerPill quete">' + label + controls + '</div>';
}
function triggerAssetFilename(url) {
return ((url || '').split('/').pop() || url || '').replace(/</g, '&lt;');
}
function triggerGenericLeafBoxHtml(title, text) {
return (
'<div class="dialogueBox questVariableAction">' +
'<div class="dialogueBoxHeader"><span class="dialogueBoxTitle">' + title + '</span></div>' +
'<p class="questVariableActionLabel">' + text + '</p>' +
'</div>'
);
}
function triggerLeafBoxHtml(item) {
if (item.type === 'dialogue') return triggerInlineDialogueHtml(item.dialogue);
if (item.type === 'variable') return triggerVariableActionHtml(item);
if (item.type === 'surbrillance') {
var sLabel = (typeof SURBRILLANCE_LABELS_MAP !== 'undefined' && SURBRILLANCE_LABELS_MAP[item.valeur]) || item.valeur;
return triggerGenericLeafBoxHtml('Surbrillance', 'Objet #' + item.object_id + ' — ' + sLabel);
}
if (item.type === 'visibilite') {
var vLabel = (typeof ELEMENT_VISIBILITY_LABELS_MAP !== 'undefined' && ELEMENT_VISIBILITY_LABELS_MAP[item.valeur]) || item.valeur;
return triggerGenericLeafBoxHtml('Visibilité', 'Objet #' + item.object_id + ' — ' + vLabel);
}
if (item.type === 'son') {
return triggerGenericLeafBoxHtml('Son', triggerAssetFilename(item.asset_url));
}
if (item.type === 'video') {
var mLabel = (typeof VIDEO_MODE_LABELS_MAP !== 'undefined' && VIDEO_MODE_LABELS_MAP[item.mode]) || item.mode;
return triggerGenericLeafBoxHtml('Vidéo', triggerAssetFilename(item.asset_url) + ' — ' + mLabel);
}
if (item.type === 'indication') {
return triggerGenericLeafBoxHtml('Indication', 'Objet #' + item.object_id + ' — ' + (item.texte || '').replace(/</g, '&lt;'));
}
if (item.type === 'attendre') {
return triggerGenericLeafBoxHtml('Attendre', (item.data_value || '0') + ' seconde(s)');
}
return '';
}
// Une chaîne d'actions (voir screens/rendering/trigger_graph.py::
// _describe_chain) : "Déclencher ce dialogue" + son rectangle, ou une
// feuille "variable", à la suite les unes des autres — PUIS, en bout de
// chaîne, "+ Ajouter une action" (demande explicite : "pouvoir modifier
// un déclencheur pour lui ajouter des actions", jamais bloqué à une
// seule action). `objectId` : nécessaire pour poster l'ajout sur le bon
// objet (voir triggerOpenAppendActionModal).
function triggerActionChainHtml(chain, objectId) {
// _describe_chain) : chaque feuille (dialogue, variable, ou l'une des
// nouvelles actions ci-dessus) à la suite les unes des autres — PUIS, en
// bout de chaîne, "+ Ajouter une action" (demande explicite : "pouvoir
// modifier un déclencheur pour lui ajouter des actions", jamais bloqué à
// une seule action). `target`/`targetId` : "object"/l'id de l'objet
// (routes/triggers/triggers.py) ou "screen"/null (routes/screens/
// screen_triggers.py, voir screenTriggerOpenAppendActionModal) — décide
// où poster l'ajout.
// "+ Ajouter une action" APRÈS `afterId` — insère (voir
// screens/rendering/collision_rules.py::set_then_action_in_rules,
// demande explicite : "pouvoir ajouter n'importe quelle action à
// n'importe quel niveau du schéma") plutôt que d'exiger d'être en bout
// de chaîne réel : posé après CHAQUE bloc (voir triggerActionChainHtml),
// pas seulement le dernier.
function triggerAppendButtonHtml(target, targetId, afterId) {
if (!afterId) return '';
var appendOnclick = target === 'screen'
? 'screenTriggerOpenAppendActionModal(\'' + afterId + '\')'
: 'triggerOpenAppendActionModal(' + targetId + ', \'' + afterId + '\')';
return '<div class="questTriggerConnector"></div>' +
'<button type="button" class="collisionRuleAddBtn questTriggerAddChainedActionBtn" onclick="' + appendOnclick + '">+ Ajouter une action</button>';
}
function triggerActionChainHtml(chain, target, targetId) {
if (!chain || !chain.length) return '';
var itemsHtml = chain.map(function (item) {
var pill = item.type === 'dialogue' ? triggerDeclencherDialoguePillHtml() : '<div class="questTriggerPill quete">Modifier une variable</div>';
var box = item.type === 'dialogue' ? triggerInlineDialogueHtml(item.dialogue) : triggerVariableActionHtml(item);
return '<div class="questTriggerConnector"></div>' + pill + '<div class="questTriggerConnector"></div>' + box;
return chain.map(function (item, i) {
var leafId = triggerItemLeafId(item);
return (
'<div class="questTriggerConnector"></div>' + triggerLeafPillHtml(item, target, targetId, i, chain.length) +
'<div class="questTriggerConnector"></div>' + triggerLeafBoxHtml(item) +
triggerAppendButtonHtml(target, targetId, leafId)
);
}).join('');
var lastItem = chain[chain.length - 1];
var lastLeafId = lastItem.type === 'dialogue' ? lastItem.dialogue && lastItem.dialogue.id : lastItem.id;
var appendBtn = lastLeafId
? '<div class="questTriggerConnector"></div>' +
'<button type="button" class="collisionRuleAddBtn questTriggerAddChainedActionBtn" onclick="triggerOpenAppendActionModal(' + objectId + ', \'' + lastLeafId + '\')">+ Ajouter une action</button>'
: '';
return itemsHtml + appendBtn;
}
// Le rectangle lui-même — voir screens/rendering/collision_rules.py::
@@ -259,15 +413,15 @@ function triggerChainHtml(trigger) {
'</div>'
);
}
var pills = [{ kind: 'trigger', text: COLLISION_TRIGGER_LABELS.collision }];
var pills = [{ kind: 'trigger', text: COLLISION_TRIGGER_LABELS[trigger.trigger] || trigger.trigger }];
if (trigger.has_interagir) pills.push({ kind: 'interagir', text: 'Interagir avec une touche' });
var pillsHtml = pills.map(function (pill, i) {
return (i > 0 ? '<div class="questTriggerConnector"></div>' : '') +
'<div class="questTriggerPill ' + pill.kind + '">' + pill.text + '</div>';
}).join('');
var tailHtml = trigger.condition
? '<div class="questTriggerConnector tall"></div>' + triggerConditionColumnHtml(trigger.condition, trigger.object_id)
: triggerActionChainHtml(trigger.chain, trigger.object_id);
? '<div class="questTriggerConnector tall"></div>' + triggerConditionColumnHtml(trigger.condition, 'object', trigger.object_id)
: triggerActionChainHtml(trigger.chain, 'object', trigger.object_id);
return (
'<div class="questTriggerCol' + (isActive ? ' active' : '') + '">' +
'<button type="button" class="questTriggerDelete" title="Retirer ce déclencheur" ' +
@@ -311,7 +465,7 @@ function triggerCondArrowHtml(kind, label) {
);
}
function triggerConditionColumnHtml(condition, objectId) {
function triggerConditionColumnHtml(condition, target, targetId) {
var opLabel = CONDITION_OPERATOR_LABELS_MAP[condition.operateur] || condition.operateur;
var label = (condition.variable || '').replace(/</g, '&lt;') + ' ' + (opLabel || '').replace(/</g, '&lt;') + ' ' + (condition.valeur || '').replace(/</g, '&lt;');
var branchesHtml = ['si_vrai', 'si_faux'].map(function (branchKey) {
@@ -319,7 +473,7 @@ function triggerConditionColumnHtml(condition, objectId) {
var arrowLabel = branchKey === 'si_vrai' ? '✓ Vrai' : '✕ Faux';
var branch = condition[branchKey];
var content = branch
? triggerActionChainHtml(branch.chain, objectId)
? triggerActionChainHtml(branch.chain, target, targetId)
: '<div class="questTriggerPill none">Ne rien faire</div>';
return triggerCondArrowHtml(kind, arrowLabel) + content;
}).join('');
@@ -338,35 +492,51 @@ function triggerConditionColumnHtml(condition, objectId) {
// tapée — l'adopter effacerait silencieusement la bulle en cours
// d'écriture).
var _triggersCache = [];
// dialogue_id -> {objectId, dialogueRef} — `dialogueRef` est LA MÊME
// référence que dans _triggersCache, muter dialogueRef.lines ici mute
// donc aussi le cache (une seule source de vérité). Couvre aussi bien le
// dialogue direct d'un déclencheur que ceux embarqués dans ses branches
// de condition.
// dialogue_id -> {objectId}|{screenId} + dialogueRef — `dialogueRef` est
// LA MÊME référence que dans _triggersCache/_screenTriggersCache, muter
// dialogueRef.lines ici mute donc aussi le cache (une seule source de
// vérité). Couvre aussi bien le dialogue direct d'un déclencheur que
// ceux embarqués dans ses branches de condition — PARTAGÉ entre les
// déclencheurs d'OBJET et D'ÉCRAN (voir _registerScreenTriggerDialogues),
// une seule table pour les deux.
var _triggerDialogueRegistry = {};
function _registerChainDialogues(chain, objectId, dialogueIds) {
// `owner` : {objectId: N} ou {screenId: N} — L'UN OU L'AUTRE, jamais les
// deux (voir triggerInlineSaveDialogue, qui route sur cette distinction).
function _registerChainDialogues(chain, owner, dialogueIds) {
(chain || []).forEach(function (item) {
if (item.type === 'dialogue' && item.dialogue) {
_triggerDialogueRegistry[item.dialogue.id] = { objectId: objectId, dialogueRef: item.dialogue };
_triggerDialogueRegistry[item.dialogue.id] = Object.assign({ dialogueRef: item.dialogue }, owner);
dialogueIds.push(item.dialogue.id);
} else if (item.type === 'variable' && item.id) {
} else if (item.id) {
dialogueIds.push(item.id); // pas de "dialogueRef" (rien à éditer en ligne), juste pour l'id "actif"
}
});
}
// Efface uniquement les entrées d'UN propriétaire (objet OU écran, voir
// `key` = 'objectId'/'screenId') avant de les reconstruire — jamais tout
// le registre, sans quoi recharger les déclencheurs d'objet effacerait
// au passage les entrées des déclencheurs d'écran déjà enregistrées (et
// inversement), les deux rechargements étant indépendants l'un de
// l'autre.
function _clearRegistryEntriesWithKey(key) {
Object.keys(_triggerDialogueRegistry).forEach(function (id) {
if (Object.prototype.hasOwnProperty.call(_triggerDialogueRegistry[id], key)) delete _triggerDialogueRegistry[id];
});
}
function _registerTriggerDialogues(list) {
_triggerDialogueRegistry = {};
_clearRegistryEntriesWithKey('objectId');
var dialogueIds = [];
list.forEach(function (t) {
if (t.condition) {
['si_vrai', 'si_faux'].forEach(function (branchKey) {
var branch = t.condition[branchKey];
if (branch) _registerChainDialogues(branch.chain, t.object_id, dialogueIds);
if (branch) _registerChainDialogues(branch.chain, { objectId: t.object_id }, dialogueIds);
});
} else {
_registerChainDialogues(t.chain, t.object_id, dialogueIds);
_registerChainDialogues(t.chain, { objectId: t.object_id }, dialogueIds);
}
});
return dialogueIds;
@@ -408,7 +578,13 @@ function triggerDeleteTrigger(objectId) {
function triggerInlineSaveDialogue(dialogueId) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
fetch('/game/' + GAME_SLUG + '/triggers/' + entry.objectId + '/dialogue', {
// "screenId" (déclencheur d'écran, routes/screens/screen_triggers.py)
// ou "objectId" (déclencheur d'objet, routes/triggers/triggers.py) —
// l'UN OU L'AUTRE, jamais les deux (voir _registerChainDialogues).
var url = entry.screenId != null
? '/game/' + GAME_SLUG + '/screens/' + entry.screenId + '/triggers/dialogue'
: '/game/' + GAME_SLUG + '/triggers/' + entry.objectId + '/dialogue';
fetch(url, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dialogue_id: dialogueId, lines: entry.dialogueRef.lines }),
}).then(function (r) {
@@ -425,13 +601,25 @@ function triggerNextDefaultSpeaker(lastSpeaker) {
return 'Joueur';
}
// Réaffiche le BON panneau après une mutation en ligne — un dialogue
// d'écran (entry.screenId) vit dans #screenTriggersSlot
// (screenTriggerRenderSlot), un dialogue d'objet (entry.objectId) dans
// #triggersSlot (triggerRenderSlot) : rafraîchir systématiquement
// triggerRenderSlot() laissait le panneau "Écran" affiché figé après
// Réplique/Question (bug corrigé — la sauvegarde serveur fonctionnait
// bien, seul le rendu restait périmé).
function _triggerRenderSlotForEntry(entry) {
if (entry.screenId != null) screenTriggerRenderSlot();
else triggerRenderSlot();
}
function triggerInlineAddBubble(dialogueId) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
var lines = entry.dialogueRef.lines;
var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'PNJ';
lines.push({ type: 'dialogue', speaker: triggerNextDefaultSpeaker(lastSpeaker), text: '' });
triggerRenderSlot();
_triggerRenderSlotForEntry(entry);
triggerInlineSaveDialogue(dialogueId);
}
@@ -442,7 +630,7 @@ function triggerInlineAddQuestion(dialogueId) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines.push({ type: 'question', text: '', choices: ['', ''], correct_index: 0, reward_type: 'score', reward_amount: 0 });
triggerRenderSlot();
_triggerRenderSlotForEntry(entry);
triggerInlineSaveDialogue(dialogueId);
}
@@ -466,7 +654,7 @@ function triggerInlineSetQuestionChoiceCount(dialogueId, index, value) {
while (line.choices.length < count) line.choices.push('');
while (line.choices.length > count) line.choices.pop();
if (line.correct_index >= line.choices.length) line.correct_index = 0;
triggerRenderSlot();
_triggerRenderSlotForEntry(entry);
triggerInlineSaveDialogue(dialogueId);
}
@@ -495,7 +683,16 @@ function triggerInlineSetBubbleSpeaker(dialogueId, index, speaker) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines[index].speaker = speaker;
triggerRenderSlot();
_triggerRenderSlotForEntry(entry);
triggerInlineSaveDialogue(dialogueId);
}
function triggerInlineSetBubbleAudio(dialogueId, index, audioUrl) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
if (audioUrl) entry.dialogueRef.lines[index].audio_url = audioUrl;
else delete entry.dialogueRef.lines[index].audio_url;
_triggerRenderSlotForEntry(entry); // ré-affiche l'aperçu <audio> (apparaît/disparaît selon le choix)
triggerInlineSaveDialogue(dialogueId);
}
@@ -510,7 +707,7 @@ function triggerInlineDeleteBubble(dialogueId, index) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines.splice(index, 1);
triggerRenderSlot();
_triggerRenderSlotForEntry(entry);
triggerInlineSaveDialogue(dialogueId);
}
@@ -580,8 +777,8 @@ function triggerSelectObject(objectId) {
// des actions" — pas besoin de repasser par la modale de création, on
// est déjà DANS ce déclencheur (voir triggerActionChainHtml). Réutilise
// les étapes de l'assistant (static/js/scenes/collision-rules-editor.js) :
// choix dialogue/variable, puis "Ensuite ?" (Marquer terminé + chaîner
// encore), avant d'envoyer le tout via /append-action.
// même choix de feuille (COLLISION_CHAIN_CHOICES) que l'étape "Ensuite ?"
// de l'assistant, avant d'envoyer le tout via /append-action.
function triggerOpenAppendActionModal(objectId, afterId) {
var modal = ensureTriggerModal();
modal.classList.add('is-active');
@@ -589,28 +786,17 @@ function triggerOpenAppendActionModal(objectId, afterId) {
var body = document.getElementById('questTriggerModalBody');
// État minimal partagé avec collision-rules-editor.js (mêmes étapes
// réutilisées telles quelles, voir renderCollisionWizardChainStep/
// renderCollisionWizardVariableStep — elles ne lisent que .bodyEl).
// collisionWizardBuildLeafAction — elles ne lisent que .bodyEl).
_collisionWizard = { bodyEl: body };
body.innerHTML =
'<p class="hint mb-2">Ajouter...</p>' +
'<div class="collisionWizardChoices">' +
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="triggerAppendLeaf(' + objectId + ', \'' + afterId + '\', \'dialogue\')">' +
'<span>Déclencher un dialogue</span>' +
'</button>' +
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="triggerAppendLeaf(' + objectId + ', \'' + afterId + '\', \'variable\')">' +
'<span>Modifier une variable</span>' +
'</button>' +
'</div>';
var buttons = COLLISION_CHAIN_CHOICES.map(function (choice, i) {
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="triggerAppendLeaf(' + objectId + ', \'' + afterId + '\', \'' + choice + '\')"><span>' + COLLISION_ACTION_LABELS[choice] + '</span></button>';
}).join('');
body.innerHTML = '<p class="hint mb-2">Ajouter...</p><div class="collisionWizardChoices">' + buttons + '</div>';
}
function triggerAppendLeaf(objectId, afterId, type) {
if (type === 'dialogue') {
var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
collisionWizardBuildLeafAction(type, function (leaf) {
renderCollisionWizardChainStep(leaf, function (finalLeaf) { triggerSaveAppendedAction(objectId, afterId, finalLeaf); });
return;
}
renderCollisionWizardVariableStep(function (action) {
renderCollisionWizardChainStep(action, function (finalLeaf) { triggerSaveAppendedAction(objectId, afterId, finalLeaf); });
});
}
@@ -629,6 +815,150 @@ function closeTriggerModal() {
if (modal) modal.classList.remove('is-active');
}
// ---------- Section "Déclencheurs de l'écran" (SANS objet, voir
// screens/rendering/screen_triggers.py) ----------
// Même moteur d'affichage que les déclencheurs d'objet ci-dessus
// (triggerActionChainHtml/triggerConditionColumnHtml, target='screen'),
// mais une liste PLATE de règles (pas de carte objet, pas d'accordéon —
// il n'y a qu'un seul "propriétaire" possible : l'écran lui-même).
// screenActionToChain/screenTriggerNormalize reproduisent CÔTÉ CLIENT le
// même aplatissement que screens/rendering/trigger_graph.py::
// _resolve_action_trigger fait côté serveur pour les déclencheurs
// d'objet (jamais de condition imbriquée dans un "then", voir
// _sanitize_leaf_action — la boucle s'arrête donc forcément sur le
// premier "condition" rencontré, toujours en position de tête).
function collisionLeafChain(leafAction) {
var chain = [];
var node = leafAction;
while (node) { chain.push(node); node = node.then; }
return chain;
}
function screenTriggerNormalize(rule) {
if (rule.action.type === 'condition') {
var cond = rule.action;
return {
chain: null,
condition: {
variable: cond.variable, operateur: cond.operateur, valeur: cond.valeur,
si_vrai: cond.si_vrai ? { chain: collisionLeafChain(cond.si_vrai) } : null,
si_faux: cond.si_faux ? { chain: collisionLeafChain(cond.si_faux) } : null,
},
};
}
return { chain: collisionLeafChain(rule.action), condition: null };
}
var _screenTriggersCache = [];
function screenTriggerColumnHtml() {
return (
'<div class="questDialogueColumn">' +
'<div class="questDialogueColumnBody" id="screenTriggerColumnBody">' +
'<div class="questTriggersSlot" id="screenTriggersSlot"></div>' +
'</div>' +
'<div class="questDialogueColumnButtons">' +
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn" onclick="openScreenAddTriggerModal()">Ajouter un déclencheur d\'écran</button>' +
'</div>' +
'</div>'
);
}
function screenTriggerRuleHtml(rule, index) {
var normalized = screenTriggerNormalize(rule);
var tailHtml = normalized.condition
? triggerConditionColumnHtml(normalized.condition, 'screen', null)
: triggerActionChainHtml(normalized.chain, 'screen', null);
return (
'<div class="questTriggerCol">' +
'<button type="button" class="questTriggerDelete" title="Retirer ce déclencheur" onclick="screenTriggerDeleteAt(' + index + ')">✕</button>' +
'<div class="questTriggerPill trigger">' + (COLLISION_TRIGGER_LABELS[rule.trigger] || rule.trigger) + '</div>' +
'<div class="questTriggerConnector"></div>' +
tailHtml +
'</div>'
);
}
function screenTriggerRenderSlot() {
var slot = document.getElementById('screenTriggersSlot');
if (!slot) return;
slot.innerHTML = _screenTriggersCache.length
? _screenTriggersCache.map(function (rule, i) { return screenTriggerRuleHtml(rule, i); }).join('')
: '<p class="hint">Aucun déclencheur d\'écran pour l\'instant.</p>';
}
function _registerScreenTriggerDialogues(list) {
_clearRegistryEntriesWithKey('screenId');
var dialogueIds = [];
list.forEach(function (rule) {
var normalized = screenTriggerNormalize(rule);
if (normalized.condition) {
['si_vrai', 'si_faux'].forEach(function (branchKey) {
var branch = normalized.condition[branchKey];
if (branch) _registerChainDialogues(branch.chain, { screenId: CURRENT_SCREEN_ID }, dialogueIds);
});
} else {
_registerChainDialogues(normalized.chain, { screenId: CURRENT_SCREEN_ID }, dialogueIds);
}
});
return dialogueIds;
}
function screenTriggerLoadAll() {
fetch('/game/' + GAME_SLUG + '/screens/' + CURRENT_SCREEN_ID + '/triggers')
.then(function (r) { return r.json(); })
.then(function (list) {
_screenTriggersCache = list;
_registerScreenTriggerDialogues(list);
screenTriggerRenderSlot();
});
}
function screenTriggerDeleteAt(index) {
fetch('/game/' + GAME_SLUG + '/screens/' + CURRENT_SCREEN_ID + '/triggers/' + index, { method: 'DELETE' })
.then(function () { screenTriggerLoadAll(); });
}
function openScreenAddTriggerModal() {
var modal = ensureTriggerModal();
modal.classList.add('is-active');
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter un déclencheur d\'écran';
var body = document.getElementById('questTriggerModalBody');
openScreenTriggerWizard(CURRENT_SCREEN_ID, body);
}
// "+ Ajouter une action" en bout de chaîne d'un déclencheur d'écran déjà
// posé — même parcours que triggerOpenAppendActionModal ci-dessus, juste
// adressé vers /screens/<id>/triggers/append-action (aucun objet à
// connaître).
function screenTriggerOpenAppendActionModal(afterId) {
var modal = ensureTriggerModal();
modal.classList.add('is-active');
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter une action';
var body = document.getElementById('questTriggerModalBody');
_collisionWizard = { bodyEl: body };
var buttons = COLLISION_CHAIN_CHOICES.map(function (choice, i) {
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="screenTriggerAppendLeaf(\'' + afterId + '\', \'' + choice + '\')"><span>' + COLLISION_ACTION_LABELS[choice] + '</span></button>';
}).join('');
body.innerHTML = '<p class="hint mb-2">Ajouter...</p><div class="collisionWizardChoices">' + buttons + '</div>';
}
function screenTriggerAppendLeaf(afterId, type) {
collisionWizardBuildLeafAction(type, function (leaf) {
renderCollisionWizardChainStep(leaf, function (finalLeaf) { screenTriggerSaveAppendedAction(afterId, finalLeaf); });
});
}
function screenTriggerSaveAppendedAction(afterId, action) {
closeTriggerModal();
fetch('/game/' + GAME_SLUG + '/screens/' + CURRENT_SCREEN_ID + '/triggers/append-action', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ after_id: afterId, action: action }),
}).then(function (r) {
if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible d'ajouter cette action."); });
}).then(function () { screenTriggerLoadAll(); });
}
// ---------- Point d'entrée ----------
// 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
@@ -639,10 +969,46 @@ function closeTriggerModal() {
// l'onglet "Déclencheurs" 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.
// Menu latéral (demande explicite) : "🧩 Objets" (déclencheurs posés sur
// un objet de scène) / "🖥️ Écran" (voir screens/rendering/
// screen_triggers.py, SANS objet requis) — un seul panneau visible à la
// fois, plutôt que les deux empilés l'un sous l'autre.
var _triggersTabMode = 'object';
function triggersTabSwitchMode(mode) {
_triggersTabMode = mode;
var objectItem = document.getElementById('triggersTabSidebarObject');
var screenItem = document.getElementById('triggersTabSidebarScreen');
var objectPane = document.getElementById('triggersObjectPane');
var screenPane = document.getElementById('triggersScreenPane');
if (!objectItem || !screenItem || !objectPane || !screenPane) return;
objectItem.classList.toggle('active', mode === 'object');
screenItem.classList.toggle('active', mode === 'screen');
objectPane.style.display = mode === 'object' ? 'flex' : 'none';
screenPane.style.display = mode === 'screen' ? 'flex' : 'none';
}
function initTriggersTab() {
var body = document.getElementById('triggersTabPanelBody');
if (!body) return;
body.innerHTML = '<div class="questDialogueColumns">' + triggerColumnHtml() + '</div>';
body.innerHTML =
'<div class="triggersTabLayout">' +
'<div class="triggersTabSidebar">' +
'<button type="button" id="triggersTabSidebarObject" class="triggersTabSidebarItem active" onclick="triggersTabSwitchMode(\'object\')">Objets</button>' +
'<button type="button" id="triggersTabSidebarScreen" class="triggersTabSidebarItem" onclick="triggersTabSwitchMode(\'screen\')">Écran</button>' +
'</div>' +
'<div class="triggersTabContent">' +
'<div id="triggersObjectPane" class="triggersTabPane">' +
'<div class="questDialogueColumns">' + triggerColumnHtml() + '</div>' +
'</div>' +
'<div id="triggersScreenPane" class="triggersTabPane" style="display:none;">' +
'<p class="hint triggersTabPaneIntro">Se déclenchent SANS objet — narration/cinématique dès l\'affichage de cet écran.</p>' +
'<div class="questDialogueColumns">' + screenTriggerColumnHtml() + '</div>' +
'</div>' +
'</div>' +
'</div>';
triggersTabSwitchMode(_triggersTabMode); // conserve l'onglet actif d'un ré-affichage à l'autre (pjax, sélection d'objet...)
triggerLoadAll();
screenTriggerLoadAll();
}
initTriggersTab();