// ---------- 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). const 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) { const kind = triggerBubbleKind(line.speaker); const speakerSelect = triggerSpeakerOptions(line.speaker).map((name) => ``).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) { const radioName = `triggerCorrect-${dialogueId}-${index}`; return ( '
' + `` + `` + '
' ); } function triggerQuestionBubbleHtml(dialogueId, line, index) { const choicesHtml = line.choices.map((c, i) => triggerQuestionChoiceRowHtml(dialogueId, c, i, line.correct_index, index)).join(''); return ( '
' + '
' + '
' + '` + '` + `` + '
' + '
' + `` + `
${choicesHtml}
` + '
' + '
' + '
' ); } // 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) { const opLabel = (typeof DATA_OPERATION_LABELS_MAP !== 'undefined' && DATA_OPERATION_LABELS_MAP[item.data_operation]) || item.data_operation; const 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). const 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 (!window.confirm('Retirer cette action ?')) return; fetch(triggerChainActionUrl(target, targetId, leafId, ''), { method: 'DELETE' }) .then((r) => { if (!r.ok) return r.json().then((json) => { alert(json.error || 'Impossible de retirer cette action.'); }); }) .then(() => { 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 }), }).then((r) => { if (!r.ok) return r.json().then((json) => { alert(json.error || 'Impossible de déplacer cette action.'); }); }).then(() => { 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) { const label = COLLISION_LEAF_PILL_LABELS[item.type] || item.type; const leafId = target && triggerItemLeafId(item); let controls = ''; if (leafId) { const 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') { const 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') { const 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') { const 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((item, i) => { const 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 ''; const lines = dialogue.lines || []; const bubblesHtml = lines.map((line, i) => (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é. const _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. let _triggerActiveDialogueId = null; function _chainLeafIds(chain) { return (chain || []).map((item) => (item.type === 'dialogue' ? (item.dialogue && item.dialogue.id) : item.id)).filter(Boolean); } function _triggerLeafIds(trigger) { if (trigger.condition) { const vrai = trigger.condition.si_vrai; const faux = trigger.condition.si_faux; return _chainLeafIds(vrai && vrai.chain).concat(_chainLeafIds(faux && faux.chain)); } return _chainLeafIds(trigger.chain); } function triggerChainHtml(trigger) { const isCollapsed = !!_triggerCollapsed[trigger.object_id]; const leafIds = _triggerLeafIds(trigger); const isActive = _triggerActiveDialogueId && leafIds.indexOf(_triggerActiveDialogueId) !== -1; const cardOnclick = `triggerClickObjectCard(${trigger.object_id}, ${leafIds[0] ? `'${leafIds[0]}'` : 'null'})`; const card = `
${collisionRuleThumbHtml(trigger) }${(trigger.object_name || '').replace(/` + '▾' + '
'; if (isCollapsed) { return ( '` ); } const pills = [{ kind: 'trigger', text: COLLISION_TRIGGER_LABELS[trigger.trigger] || trigger.trigger }]; if (trigger.has_interagir) pills.push({ kind: 'interagir', text: 'Interagir avec une touche' }); const pillsHtml = pills.map((pill, i) => `${i > 0 ? '
' : '' }
${pill.text}
`).join(''); const 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) { const color = kind === 'yes' ? '#2ecc71' : '#e0524d'; const markerId = `triggerCondArrow_${kind}_${Math.random().toString(36).slice(2, 8)}`; // NOSONAR S2245 - id local UI, jamais crypto return ( '
' + `
${label}
` + '' + '
' ); } function triggerConditionColumnHtml(condition, target, targetId) { const opLabel = CONDITION_OPERATOR_LABELS_MAP[condition.operateur] || condition.operateur; const label = `${(condition.variable || '').replace(/ { const kind = branchKey === 'si_vrai' ? 'yes' : 'no'; const arrowLabel = branchKey === 'si_vrai' ? '✓ Vrai' : '✕ Faux'; const branch = condition[branchKey]; const content = branch ? triggerActionChainHtml(branch.chain, target, targetId) : '
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). let _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. const _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((item) => { if (item.type === 'dialogue' && item.dialogue) { _triggerDialogueRegistry[item.dialogue.id] = { 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((id) => { if (Object.prototype.hasOwnProperty.call(_triggerDialogueRegistry[id], key)) delete _triggerDialogueRegistry[id]; }); } function _registerTriggerDialogues(list) { _clearRegistryEntriesWithKey('objectId'); const dialogueIds = []; list.forEach((t) => { if (t.condition) { ['si_vrai', 'si_faux'].forEach((branchKey) => { const 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((r) => r.json()) .then((list) => { _triggersCache = list; const 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() { const slot = document.getElementById('triggersSlot'); if (!slot) return; slot.innerHTML = _triggersCache.map((t) => triggerChainHtml(t)).join(''); } function triggerDeleteTrigger(objectId) { fetch(`/game/${GAME_SLUG}/triggers/${objectId}`, { method: 'DELETE' }) .then(() => { 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) { const 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). const 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((r) => { if (!r.ok) return r.json().then((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) { const entry = _triggerDialogueRegistry[dialogueId]; if (!entry) return; const { lines } = entry.dialogueRef; const 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) { const 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) { const 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) { const entry = _triggerDialogueRegistry[dialogueId]; if (!entry) return; const line = entry.dialogueRef.lines[index]; const 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) { const entry = _triggerDialogueRegistry[dialogueId]; if (!entry) return; entry.dialogueRef.lines[index].choices[choiceIndex] = text; triggerInlineSaveDialogue(dialogueId); } function triggerInlineSetQuestionCorrectIndex(dialogueId, index, choiceIndex) { const entry = _triggerDialogueRegistry[dialogueId]; if (!entry) return; entry.dialogueRef.lines[index].correct_index = choiceIndex; triggerInlineSaveDialogue(dialogueId); } function triggerInlineSetQuestionRewardAmount(dialogueId, index, value) { const 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) { const entry = _triggerDialogueRegistry[dialogueId]; if (!entry) return; entry.dialogueRef.lines[index].speaker = speaker; _triggerRenderSlotForEntry(entry); triggerInlineSaveDialogue(dialogueId); } function triggerInlineSetBubbleAudio(dialogueId, index, audioUrl) { const 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