// ---------- 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 ' + speakerSelect + '' + '' + '' + '
' + '' + '
' + '' + '' ); } // "❓ 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 ( '
' + '' + '' + '
' ); } function triggerQuestionBubbleHtml(dialogueId, line, index) { var choicesHtml = line.choices.map(function (c, i) { return triggerQuestionChoiceRowHtml(dialogueId, c, i, line.correct_index, index); }).join(''); return ( '
' + '
' + '
' + '' + '' + '' + '
' + '
' + '' + '
' + choicesHtml + '
' + '
' + '
' + '
' ); } // 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 '
Déclencher ce dialogue
'; } // 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(/' + '
' + 'Variable' + '
' + '

' + triggerVariableActionLabel(item) + '

' + '' ); } // 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) { if (!chain || !chain.length) return ''; var itemsHtml = chain.map(function (item) { var pill = item.type === 'dialogue' ? triggerDeclencherDialoguePillHtml() : '
Modifier une variable
'; var box = item.type === 'dialogue' ? triggerInlineDialogueHtml(item.dialogue) : triggerVariableActionHtml(item); return '
' + pill + '
' + box; }).join(''); var lastItem = chain[chain.length - 1]; var lastLeafId = lastItem.type === 'dialogue' ? lastItem.dialogue && lastItem.dialogue.id : lastItem.id; var appendBtn = lastLeafId ? '
' + '' : ''; return itemsHtml + appendBtn; } // 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 ( '
' + '
' + 'Dialogue' + '
' + '' + '' + '
' + '
' + bubblesHtml + '
' ); } // ---------- 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 = '
' + collisionRuleThumbHtml(trigger) + '' + (trigger.object_name || '').replace(/' + '▾' + '
'; if (isCollapsed) { return ( '' ); } var pills = [{ kind: 'trigger', text: COLLISION_TRIGGER_LABELS.collision }]; if (trigger.has_interagir) pills.push({ kind: 'interagir', text: 'Interagir avec une touche' }); var pillsHtml = pills.map(function (pill, i) { return (i > 0 ? '
' : '') + '
' + pill.text + '
'; }).join(''); var tailHtml = trigger.condition ? '
' + triggerConditionColumnHtml(trigger.condition, trigger.object_id) : triggerActionChainHtml(trigger.chain, trigger.object_id); return ( '
' + '' + card + '
' + pillsHtml + tailHtml + '
' ); } // 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 ( '
' + '
' + label + '
' + '' + '
' ); } function triggerConditionColumnHtml(condition, objectId) { var opLabel = CONDITION_OPERATOR_LABELS_MAP[condition.operateur] || condition.operateur; var label = (condition.variable || '').replace(/Ne rien faire'; return triggerCondArrowHtml(kind, arrowLabel) + content; }).join(''); return ( '
' + '
' + label + '
' + branchesHtml + '
' ); } // 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, 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. var _triggerDialogueRegistry = {}; function _registerChainDialogues(chain, objectId, dialogueIds) { (chain || []).forEach(function (item) { if (item.type === 'dialogue' && item.dialogue) { _triggerDialogueRegistry[item.dialogue.id] = { objectId: objectId, dialogueRef: item.dialogue }; dialogueIds.push(item.dialogue.id); } else if (item.type === 'variable' && item.id) { dialogueIds.push(item.id); // pas de "dialogueRef" (rien à éditer en ligne), juste pour l'id "actif" } }); } function _registerTriggerDialogues(list) { _triggerDialogueRegistry = {}; 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); }); } else { _registerChainDialogues(t.chain, 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; fetch('/game/' + GAME_SLUG + '/triggers/' + entry.objectId + '/dialogue', { 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'; } 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(); 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 }); triggerRenderSlot(); 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; triggerRenderSlot(); 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; triggerRenderSlot(); 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); triggerRenderSlot(); 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 = '' + ''; 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 = '

Choisis l\'objet de scène qui déclenchera ce dialogue :

' + '
Chargement…
'; 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 = '

Aucun objet éligible sur ce jeu.

'; return; } grid.innerHTML = objects.map(function (o, i) { return ''; }).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) : // choix dialogue/variable, puis "Ensuite ?" (Marquer terminé + chaîner // encore), 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/ // renderCollisionWizardVariableStep — elles ne lisent que .bodyEl). _collisionWizard = { bodyEl: body }; body.innerHTML = '

Ajouter...

' + '
' + '' + '' + '
'; } function triggerAppendLeaf(objectId, afterId, type) { if (type === 'dialogue') { var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } }; renderCollisionWizardChainStep(leaf, function (finalLeaf) { triggerSaveAppendedAction(objectId, afterId, finalLeaf); }); return; } renderCollisionWizardVariableStep(function (action) { renderCollisionWizardChainStep(action, 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'); } // ---------- Point d'entrée ---------- // Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé par // un