Files
Forge-Engine/static/js/triggers/trigger-editor.js
T
williamandClaude Sonnet 5 559331f9cf
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
Enrichit les declencheurs/actions de scene (clic/survol/affichage, surbrillance/video/son/visibilite/indication/attendre) et fiabilise la pose d'un fond/decor importe
- 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>
2026-09-12 00:28:46 +02:00

1015 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------- Onglet "Déclencheurs" ----------
// templates/scene_edit.html : un GRAPHE DE LOGIQUE lu par déclencheur
// (demande explicite : "un RH doit le comprendre, un visuel global") —
// TOUS les déclencheurs du JEU ENTIER vivent côte à côte dans la même
// colonne (plus de "quête" pour les regrouper, voir screens/rendering/
// trigger_graph.py::list_all_triggers), chacun avec SON PROPRE dialogue
// embarqué ({id, lines}, voir screens/rendering/collision_rules.py::
// "dialogue".dialogue). Les boutons Réplique/Question sont posés
// DIRECTEMENT sur le rectangle de dialogue de chaque déclencheur — le
// seul bouton au bas de la colonne est "Ajouter un déclencheur". Le
// créateur décide lui-même, via ses variables globales et conditions
// (voir screens/rendering/collision_rules.py::"condition"), QUAND
// déclencher quel dialogue et QUAND marquer un dialogue comme terminé
// (action.mark_completed, voir static/js/play/dialogue-box-controller.js).
// Noms d'objets de scène nommés (voir "ℹ️ Informations", templates/
// scene_edit.html) — N'IMPORTE QUEL objet (personnage, décor, fond), pas
// seulement un personnage — proposés comme "qui parle" pour une réplique,
// en plus de "Joueur" (toujours disponible, jamais un objet de scène).
var SCENE_OBJECT_NAMES = (typeof SCENE_OBJECT_NAMES_JSON !== 'undefined') ? SCENE_OBJECT_NAMES_JSON : [];
// Une réplique n'a que deux COULEURS possibles (voir .questBubble-joueur/
// .questBubble-pnj, static/style.css) même si le nom affiché est
// n'importe quel objet nommé — "Joueur" en vert, n'importe quel autre nom
// en bleu.
function triggerBubbleKind(speaker) {
return speaker === 'Joueur' ? 'joueur' : 'pnj';
}
// Options du <select> "qui parle" — "Joueur" toujours en premier, puis
// chaque personnage nommé (voir "ℹ️ Informations"), plus le speaker
// ACTUEL de la réplique s'il n'est plus dans cette liste (personnage
// renommé/supprimé depuis) pour ne jamais perdre silencieusement la
// valeur déjà enregistrée.
function triggerSpeakerOptions(currentSpeaker) {
var options = ['Joueur'].concat(SCENE_OBJECT_NAMES);
if (currentSpeaker && options.indexOf(currentSpeaker) === -1) options.push(currentSpeaker);
return options;
}
function triggerEscAttr(str) {
return (str || '').replace(/"/g, '&quot;');
}
// ---------- Colonne = un graphe de logique lu par déclencheur ----------
function triggerColumnHtml() {
return (
'<div class="questDialogueColumn">' +
'<div class="questDialogueColumnBody" id="triggerColumnBody">' +
'<div class="questTriggersSlot" id="triggersSlot"></div>' +
'</div>' +
'<div class="questDialogueColumnButtons">' +
'<button type="button" class="collisionRuleAddBtn questDialogueAddBtn" onclick="openAddTriggerModal()">Ajouter un déclencheur</button>' +
'</div>' +
'</div>'
);
}
// ---------- Dialogue INLINE (voir screens/rendering/collision_rules.py
// ::"dialogue".dialogue) ----------
// Un dialogue = {id, lines} porté PAR L'ACTION elle-même — rendu comme un
// rectangle à bords arrondis, MÊME largeur que le reste de la chaîne
// (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 + 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) {
return '<option value="' + name.replace(/"/g, '&quot;') + '"' + (name === line.speaker ? ' selected' : '') + '>' + (name === 'Joueur' ? 'Joueur' : name) + '</option>';
}).join('');
return (
'<div class="questBubbleRow">' +
'<div class="questBubble questBubble-' + kind + '">' +
'<div class="questBubbleHeader">' +
'<select class="questBubbleSpeaker" title="Qui parle" onchange="triggerInlineSetBubbleSpeaker(\'' + dialogueId + '\', ' + index + ', this.value)">' + speakerSelect + '</select>' +
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="triggerInlineDeleteBubble(\'' + dialogueId + '\', ' + index + ')">✕</button>' +
'</div>' +
'<div class="questBubbleBody">' +
'<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>'
);
}
// "❓ Question" — une bulle JAUNE dans la même chaîne qu'une réplique de
// dialogue (voir .questBubbleRow/.questBubble et static/style.css) mais
// un type de ligne différent (voir db/dialogue_lines.py) : header =
// nombre de choix + récompense (type + montant), body = la question et
// ses choix, avec la bonne réponse cochée.
function triggerQuestionChoiceRowHtml(dialogueId, choice, choiceIndex, correctIndex, index) {
var radioName = 'triggerCorrect-' + dialogueId + '-' + index;
return (
'<div class="questChoiceRow">' +
'<input type="radio" name="' + radioName + '" class="questChoiceRadio" title="Bonne réponse" ' +
(choiceIndex === correctIndex ? 'checked' : '') +
' onchange="triggerInlineSetQuestionCorrectIndex(\'' + dialogueId + '\', ' + index + ', ' + choiceIndex + ')">' +
'<input type="text" class="questChoiceInput" placeholder="Choix ' + (choiceIndex + 1) + '" value="' + triggerEscAttr(choice) + '" ' +
'onchange="triggerInlineSetQuestionChoiceText(\'' + dialogueId + '\', ' + index + ', ' + choiceIndex + ', this.value)">' +
'</div>'
);
}
function triggerQuestionBubbleHtml(dialogueId, line, index) {
var choicesHtml = line.choices.map(function (c, i) { return triggerQuestionChoiceRowHtml(dialogueId, c, i, line.correct_index, index); }).join('');
return (
'<div class="questBubbleRow">' +
'<div class="questBubble questBubble-question">' +
'<div class="questBubbleHeader">' +
'<label class="questQuestionHeaderField"><span class="questQuestionHeaderFieldLabel">Nombre de choix</span>' +
'<input type="number" min="2" max="4" value="' + line.choices.length + '" ' +
'onchange="triggerInlineSetQuestionChoiceCount(\'' + dialogueId + '\', ' + index + ', this.value)"></label>' +
'<label class="questQuestionHeaderField"><span class="questQuestionHeaderFieldLabel">Points gagnés</span>' +
'<input type="number" min="0" value="' + line.reward_amount + '" ' +
'onchange="triggerInlineSetQuestionRewardAmount(\'' + dialogueId + '\', ' + index + ', this.value)"> pts</label>' +
'<button type="button" class="questBubbleDelete" title="Supprimer" onclick="triggerInlineDeleteBubble(\'' + dialogueId + '\', ' + index + ')">✕</button>' +
'</div>' +
'<div class="questBubbleBody">' +
'<input type="text" class="questQuestionText" placeholder="Question..." value="' + triggerEscAttr(line.text) + '" ' +
'onchange="triggerInlineUpdateQuestionText(\'' + dialogueId + '\', ' + index + ', this.value)">' +
'<div class="questChoicesList">' + choicesHtml + '</div>' +
'</div>' +
'</div>' +
'</div>'
);
}
// Pastille "Déclencher ce dialogue" — visuelle uniquement, la même pour
// TOUTES les occurrences : posée juste avant le rectangle de dialogue
// lui-même (voir triggerInlineDialogueHtml).
function triggerDeclencherDialoguePillHtml() {
return '<div class="questTriggerPill quete">Déclencher ce dialogue</div>';
}
// Libellé lisible d'une action "variable" (voir screens/rendering/
// collision_rules.py::"variable" et screens/labels/data_operations.py) —
// DATA_OPERATION_LABELS_MAP est déjà posé par templates/scene_edit.html.
function triggerVariableActionLabel(item) {
var opLabel = (typeof DATA_OPERATION_LABELS_MAP !== 'undefined' && DATA_OPERATION_LABELS_MAP[item.data_operation]) || item.data_operation;
var valuePart = (item.data_value !== undefined && item.data_value !== null && item.data_value !== '') ? ' ' + item.data_value : '';
return (item.target_variable || '').replace(/</g, '&lt;') + ' — ' + opLabel.replace(/</g, '&lt;') + valuePart;
}
// Feuille "Modifier une variable" — un encart compact (même largeur que
// le reste de la chaîne), pas de bulles dedans contrairement au
// rectangle de dialogue.
function triggerVariableActionHtml(item) {
return (
'<div class="dialogueBox questVariableAction">' +
'<div class="dialogueBoxHeader">' +
'<span class="dialogueBoxTitle">Variable</span>' +
'</div>' +
'<p class="questVariableActionLabel">' + triggerVariableActionLabel(item) + '</p>' +
'</div>'
);
}
// ---------- 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) : 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 '';
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('');
}
// Le rectangle lui-même — voir screens/rendering/collision_rules.py::
// "dialogue".dialogue pour la forme {id, lines}. `dialogue` peut être
// absent (action posée sans passer par l'assistant) : rien n'est rendu
// plutôt que de planter, même tolérance que le reste de ce moteur.
function triggerInlineDialogueHtml(dialogue) {
if (!dialogue) return '';
var lines = dialogue.lines || [];
var bubblesHtml = lines.map(function (line, i) {
return line.type === 'question' ? triggerQuestionBubbleHtml(dialogue.id, line, i) : triggerDialogueBubbleHtml(dialogue.id, line, i);
}).join('');
return (
'<div class="dialogueBox">' +
'<div class="dialogueBoxHeader">' +
'<span class="dialogueBoxTitle">Dialogue</span>' +
'<div class="dialogueBoxActions">' +
'<button type="button" class="addReplyBtn" onclick="triggerInlineAddBubble(\'' + dialogue.id + '\')">Réplique</button>' +
'<button type="button" class="addReplyBtn addQuestionBtn" onclick="triggerInlineAddQuestion(\'' + dialogue.id + '\')">Question</button>' +
'</div>' +
'</div>' +
bubblesHtml +
'</div>'
);
}
// ---------- Déclencheurs (voir screens/rendering/trigger_graph.py::
// list_all_triggers) ----------
// Maquette validée : carte objet + pastilles empilées VERTICALEMENT
// reliées par des flèches. Le "Déclencher ce dialogue" se termine par le
// rectangle de dialogue lui-même — la flèche pointe DIRECTEMENT dessus.
// Quand l'action se termine par une "condition", la chaîne se sépare en
// un losange à embranchement Vrai/Faux (voir triggerConditionColumnHtml) —
// chaque branche affiche DIRECTEMENT son propre contenu (plus de "branche
// qui mène à telle quête" à choisir, chaque branche est indépendante).
// collisionRuleThumbHtml (static/js/scenes/collision-rules-editor.js,
// chargé sur la même page) reste réutilisé pour la vignette elle-même.
// Accordéon : object_id -> true si REPLIÉ — par défaut tout est déplié,
// absent de cette map = déplié.
var _triggerCollapsed = {};
// Le déclencheur actif est celui où Réplique/Question ajoutent leur bulle
// (surligné, voir .questTriggerCol.active en CSS) — par défaut le
// DERNIER dialogue trouvé, mais cliquer sur la carte d'un autre
// déclencheur en fait le nouvel actif.
var _triggerActiveDialogueId = null;
function _chainLeafIds(chain) {
return (chain || []).map(function (item) { return item.type === 'dialogue' ? (item.dialogue && item.dialogue.id) : item.id; }).filter(Boolean);
}
function _triggerLeafIds(trigger) {
if (trigger.condition) {
var vrai = trigger.condition.si_vrai, faux = trigger.condition.si_faux;
return _chainLeafIds(vrai && vrai.chain).concat(_chainLeafIds(faux && faux.chain));
}
return _chainLeafIds(trigger.chain);
}
function triggerChainHtml(trigger) {
var isCollapsed = !!_triggerCollapsed[trigger.object_id];
var leafIds = _triggerLeafIds(trigger);
var isActive = _triggerActiveDialogueId && leafIds.indexOf(_triggerActiveDialogueId) !== -1;
var cardOnclick = 'triggerClickObjectCard(' + trigger.object_id + ', ' + (leafIds[0] ? '\'' + leafIds[0] + '\'' : 'null') + ')';
var card =
'<div class="questTriggerObjectCard" title="Cliquer pour replier/déplier — replie aussi la logique associée" onclick="' + cardOnclick + '">' + collisionRuleThumbHtml(trigger) +
'<span class="questTriggerObjectName">' + (trigger.object_name || '').replace(/</g, '&lt;') + '</span>' +
'<span class="questTriggerCollapseChevron">▾</span>' +
'</div>';
if (isCollapsed) {
return (
'<div class="questTriggerCol collapsed">' +
'<button type="button" class="questTriggerDelete" title="Retirer ce déclencheur" ' +
'onclick="event.stopPropagation(); triggerDeleteTrigger(' + trigger.object_id + ')">✕</button>' +
card +
'</div>'
);
}
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, '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" ' +
'onclick="event.stopPropagation(); triggerDeleteTrigger(' + trigger.object_id + ')">✕</button>' +
card +
'<div class="questTriggerConnector"></div>' +
pillsHtml +
tailHtml +
'</div>'
);
}
// Clic sur la carte objet : replie/déplie TOUTE la logique associée
// (accordéon, demande explicite) — et rend ce déclencheur actif au
// passage puisqu'on vient d'y porter son attention.
function triggerClickObjectCard(objectId, leafId) {
_triggerCollapsed[objectId] = !_triggerCollapsed[objectId];
if (leafId) _triggerActiveDialogueId = leafId;
triggerRenderSlot();
}
// Losange "condition" — lecture ENTIÈREMENT VERTICALE (demande explicite :
// "il faut tout mettre verticalement", sans toucher au losange lui-même) :
// la branche Vrai descend tout droit vers son "Déclencher ce dialogue"
// (ou "Ne rien faire"), PUIS la branche Faux descend à son tour — jamais
// deux branches côte à côte. Chaque branche affiche SON PROPRE dialogue
// (voir screens/rendering/trigger_graph.py::_resolve_action_trigger),
// indépendamment de l'autre.
function triggerCondArrowHtml(kind, label) {
var color = kind === 'yes' ? '#2ecc71' : '#e0524d';
var markerId = 'triggerCondArrow_' + kind + '_' + Math.random().toString(36).slice(2, 8);
return (
'<div class="questCondArrowWrap">' +
'<div class="questCondArrowLabel ' + kind + '">' + label + '</div>' +
'<svg viewBox="0 0 34 68" preserveAspectRatio="none" aria-hidden="true">' +
'<defs><marker id="' + markerId + '" markerWidth="10" markerHeight="10" refX="7" refY="5" orient="auto">' +
'<path d="M0,0 L10,5 L0,10 Z" fill="' + color + '"></path></marker></defs>' +
'<line x1="17" y1="0" x2="17" y2="56" stroke="' + color + '" stroke-width="4" marker-end="url(#' + markerId + ')"></line>' +
'</svg>' +
'</div>'
);
}
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) {
var kind = branchKey === 'si_vrai' ? 'yes' : 'no';
var arrowLabel = branchKey === 'si_vrai' ? '✓ Vrai' : '✕ Faux';
var branch = condition[branchKey];
var content = branch
? triggerActionChainHtml(branch.chain, target, targetId)
: '<div class="questTriggerPill none">Ne rien faire</div>';
return triggerCondArrowHtml(kind, arrowLabel) + content;
}).join('');
return (
'<div class="questCondColumn">' +
'<div class="questCondDiamond"><span>' + label + '</span></div>' +
branchesHtml +
'</div>'
);
}
// Cache global (voir triggerLoadAll) — muté directement par les fonctions
// triggerInline* ci-dessous, puis ré-affiché + sauvegardé, sans jamais
// adopter la réponse du serveur comme nouvel état local (sanitize_dialogue_lines
// rejette toute réplique au texte encore vide — juste ajoutée, pas encore
// tapée — l'adopter effacerait silencieusement la bulle en cours
// d'écriture).
var _triggersCache = [];
// 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 = {};
// `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] = Object.assign({ dialogueRef: item.dialogue }, owner);
dialogueIds.push(item.dialogue.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) {
_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, { objectId: t.object_id }, dialogueIds);
});
} else {
_registerChainDialogues(t.chain, { objectId: t.object_id }, dialogueIds);
}
});
return dialogueIds;
}
function triggerLoadAll() {
fetch('/game/' + GAME_SLUG + '/triggers')
.then(function (r) { return r.json(); })
.then(function (list) {
_triggersCache = list;
var dialogueIds = _registerTriggerDialogues(list);
// Le déclencheur actif par défaut est le DERNIER dialogue trouvé —
// sauf s'il y en a déjà un actif encore présent, auquel cas on le
// garde (cliquer sur une autre carte reste possible, voir
// triggerClickObjectCard).
if (dialogueIds.indexOf(_triggerActiveDialogueId) === -1) {
_triggerActiveDialogueId = dialogueIds[dialogueIds.length - 1] || null;
}
triggerRenderSlot();
});
}
function triggerRenderSlot() {
var slot = document.getElementById('triggersSlot');
if (!slot) return;
slot.innerHTML = _triggersCache.map(function (t) { return triggerChainHtml(t); }).join('');
}
function triggerDeleteTrigger(objectId) {
fetch('/game/' + GAME_SLUG + '/triggers/' + objectId, { method: 'DELETE' })
.then(function () { triggerLoadAll(); });
}
// ---------- Mutateurs du dialogue INLINE ----------
// Chaque fonction retrouve son dialogue via le registre (dialogue_id,
// unique par occurrence de "Déclencher ce dialogue") — pas besoin de
// connaître l'objet, déjà mémorisé à triggerLoadAll.
function triggerInlineSaveDialogue(dialogueId) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
// "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) {
if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible d'enregistrer."); });
});
}
// Alterne "Joueur" <-> le premier personnage nommé (ou "PNJ" faute de
// mieux, si aucun personnage n'a encore de nom — voir "ℹ️ Informations")
// — juste un défaut de départ, l'auteur choisit ensuite librement dans
// le menu déroulant de la réplique.
function triggerNextDefaultSpeaker(lastSpeaker) {
if (lastSpeaker === 'Joueur') return SCENE_OBJECT_NAMES[0] || 'PNJ';
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: '' });
_triggerRenderSlotForEntry(entry);
triggerInlineSaveDialogue(dialogueId);
}
// "❓ Question" — voir triggerQuestionBubbleHtml ci-dessus. Défauts :
// 2 choix vides, la 1ère cochée bonne réponse, récompense "score" à 0 pt
// (l'auteur règle le montant dans le header de la bulle).
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 });
_triggerRenderSlotForEntry(entry);
triggerInlineSaveDialogue(dialogueId);
}
function triggerInlineUpdateQuestionText(dialogueId, index, text) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines[index].text = text;
triggerInlineSaveDialogue(dialogueId);
}
// Ajuste le NOMBRE de choix (2 à 4, voir db/dialogue_lines.py::
// _MIN_CHOICES/_MAX_CHOICES) : ajoute des choix vides ou retire les
// derniers, sans jamais toucher aux choix déjà remplis qui restent dans
// la nouvelle taille. Recadre la bonne réponse sur le premier choix si
// elle pointait vers un choix retiré.
function triggerInlineSetQuestionChoiceCount(dialogueId, index, value) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
var line = entry.dialogueRef.lines[index];
var count = Math.max(2, Math.min(4, parseInt(value, 10) || 2));
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;
_triggerRenderSlotForEntry(entry);
triggerInlineSaveDialogue(dialogueId);
}
function triggerInlineSetQuestionChoiceText(dialogueId, index, choiceIndex, text) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines[index].choices[choiceIndex] = text;
triggerInlineSaveDialogue(dialogueId);
}
function triggerInlineSetQuestionCorrectIndex(dialogueId, index, choiceIndex) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines[index].correct_index = choiceIndex;
triggerInlineSaveDialogue(dialogueId);
}
function triggerInlineSetQuestionRewardAmount(dialogueId, index, value) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines[index].reward_amount = Math.max(0, parseInt(value, 10) || 0);
triggerInlineSaveDialogue(dialogueId);
}
function triggerInlineSetBubbleSpeaker(dialogueId, index, speaker) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines[index].speaker = speaker;
_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);
}
function triggerInlineUpdateBubbleText(dialogueId, index, text) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines[index].text = text;
triggerInlineSaveDialogue(dialogueId);
}
function triggerInlineDeleteBubble(dialogueId, index) {
var entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines.splice(index, 1);
_triggerRenderSlotForEntry(entry);
triggerInlineSaveDialogue(dialogueId);
}
// ---------- Modale d'ajout de déclencheur ----------
// Étape 1 : choisir l'objet de scène (toutes scènes du jeu confondues,
// voir GET .../triggers/objects-catalog — contrairement à l'éditeur de
// collision, limité à la scène actuellement ouverte). Étape 2 : le MÊME
// assistant "déclencheur -> action" que l'éditeur de collision (voir
// openTriggerWizard, static/js/scenes/collision-rules-editor.js).
function ensureTriggerModal() {
var modal = document.getElementById('questTriggerModal');
if (modal) return modal;
modal = document.createElement('div');
modal.className = 'modal questTriggerModal';
modal.id = 'questTriggerModal';
modal.innerHTML =
'<div class="modal-background" onclick="closeTriggerModal()"></div>' +
'<div class="modal-content questFieldsModalContent"><div class="box">' +
'<div class="fieldRow" style="align-items:center; justify-content:space-between;">' +
'<h2 class="title is-5" style="margin:0;" id="questTriggerModalTitle">Ajouter un déclencheur</h2>' +
'<button type="button" class="button is-small" onclick="closeTriggerModal()">✕</button>' +
'</div>' +
'<div id="questTriggerModalBody"></div>' +
'</div></div>';
document.body.appendChild(modal);
return modal;
}
function openAddTriggerModal() {
var modal = ensureTriggerModal();
modal.classList.add('is-active');
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter un déclencheur';
triggerRenderObjectStep();
}
function triggerRenderObjectStep() {
var body = document.getElementById('questTriggerModalBody');
body.innerHTML =
'<p class="hint mt-2 mb-2">Choisis l\'objet de scène qui déclenchera ce dialogue :</p>' +
'<div id="questTriggerObjectGrid" class="collisionWizardChoices">Chargement…</div>';
fetch('/game/' + GAME_SLUG + '/triggers/objects-catalog')
.then(function (r) { return r.json(); })
.then(function (objects) {
var grid = document.getElementById('questTriggerObjectGrid');
if (!grid) return;
if (!objects.length) { grid.innerHTML = '<p class="hint">Aucun objet éligible sur ce jeu.</p>'; return; }
grid.innerHTML = objects.map(function (o, i) {
return '<button type="button" class="collisionWizardChoice questTriggerObjectChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="triggerSelectObject(' + o.id + ')">' +
'<div class="questTriggerObjectThumb">' + collisionRuleThumbHtml(o) + '</div>' +
'<span>' + (o.name || '').replace(/</g, '&lt;') + '</span>' +
'</button>';
}).join('');
});
}
// Objet choisi -> enchaîne directement sur l'assistant déclencheur/action
// de l'éditeur de collision (même écran, même modale, juste la suite du
// parcours) — voir openTriggerWizard.
function triggerSelectObject(objectId) {
var body = document.getElementById('questTriggerModalBody');
openTriggerWizard(objectId, body);
}
// ---------- "+ Ajouter une action" (bout de chaîne d'un déclencheur
// DÉJÀ posé) ----------
// Demande explicite : "pouvoir modifier un déclencheur pour lui ajouter
// 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) :
// 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');
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter une action';
var body = document.getElementById('questTriggerModalBody');
// État minimal partagé avec collision-rules-editor.js (mêmes étapes
// réutilisées telles quelles, voir renderCollisionWizardChainStep/
// collisionWizardBuildLeafAction — elles ne lisent que .bodyEl).
_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="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) {
collisionWizardBuildLeafAction(type, function (leaf) {
renderCollisionWizardChainStep(leaf, function (finalLeaf) { triggerSaveAppendedAction(objectId, afterId, finalLeaf); });
});
}
function triggerSaveAppendedAction(objectId, afterId, action) {
closeTriggerModal();
fetch('/game/' + GAME_SLUG + '/triggers/' + objectId + '/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 () { triggerLoadAll(); });
}
function closeTriggerModal() {
var modal = document.getElementById('questTriggerModal');
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
// 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 "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="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();