// ---------- 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 ' + selectOptions + '' + preview + '' ); } // 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 ''; }).join(''); return ( '
' + '
' + '
' + '' + '' + '
' + '
' + '' + triggerBubbleAudioControlHtml(dialogueId, index, line.audio_url) + '
' + '
' + '
' ); } // "❓ 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) + '

' + '' ); } // ---------- 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 = '' + '' + '' + '' + ''; } return '
' + label + controls + '
'; } function triggerAssetFilename(url) { return ((url || '').split('/').pop() || url || '').replace(/' + '
' + title + '
' + '

' + text + '

' + '' ); } 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(/' + ''; } function triggerActionChainHtml(chain, target, targetId) { if (!chain || !chain.length) return ''; return chain.map(function (item, i) { var leafId = triggerItemLeafId(item); return ( '
' + triggerLeafPillHtml(item, target, targetId, i, chain.length) + '
' + 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 ( '
' + '
' + '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[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 ? '
' : '') + '
' + pill.text + '
'; }).join(''); var tailHtml = trigger.condition ? '
' + triggerConditionColumnHtml(trigger.condition, 'object', trigger.object_id) : triggerActionChainHtml(trigger.chain, 'object', 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, target, targetId) { 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}|{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