Files
Forge-Engine/static/js/triggers/trigger-editor.js
T
williamandClaude Sonnet 5 d664ed5637
Build and deploy / test-python (push) Successful in 8m45s
Build and deploy / test-js (push) Successful in 49s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Remplace le système de quêtes par des déclencheurs, ajoute l'action variable et le chaînage
Supprime le concept de "quête" au profit d'un onglet unique "Déclencheurs"
portant toute la logique (dialogue, condition, marquage terminé) directement
sur l'objet de scène. Ajoute une nouvelle action "Modifier une variable"
(réutilisant le vocabulaire du graphe de flow) utilisable après une
collision, une interaction ou une branche de condition, ainsi qu'un
chaînage d'actions ("then") permettant d'enchaîner plusieurs actions à la
suite et d'étendre un déclencheur déjà posé sans le recréer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 22:05:06 +02:00

649 lines
32 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.
// Une bulle = header (qui parle, menu déroulant des objets de scène
// nommés — voir "ℹ️ Informations") + body (le texte) — deux zones
// distinctes, pas un seul bloc.
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>' +
'</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>'
);
}
// 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() : '<div class="questTriggerPill quete">Modifier une variable</div>';
var box = item.type === 'dialogue' ? triggerInlineDialogueHtml(item.dialogue) : triggerVariableActionHtml(item);
return '<div class="questTriggerConnector"></div>' + pill + '<div class="questTriggerConnector"></div>' + box;
}).join('');
var lastItem = chain[chain.length - 1];
var lastLeafId = lastItem.type === 'dialogue' ? lastItem.dialogue && lastItem.dialogue.id : lastItem.id;
var appendBtn = lastLeafId
? '<div class="questTriggerConnector"></div>' +
'<button type="button" class="collisionRuleAddBtn questTriggerAddChainedActionBtn" onclick="triggerOpenAppendActionModal(' + objectId + ', \'' + lastLeafId + '\')">+ Ajouter une action</button>'
: '';
return itemsHtml + appendBtn;
}
// Le rectangle lui-même — voir screens/rendering/collision_rules.py::
// "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.collision }];
if (trigger.has_interagir) pills.push({ kind: 'interagir', text: 'Interagir avec une touche' });
var pillsHtml = pills.map(function (pill, i) {
return (i > 0 ? '<div class="questTriggerConnector"></div>' : '') +
'<div class="questTriggerPill ' + pill.kind + '">' + pill.text + '</div>';
}).join('');
var tailHtml = trigger.condition
? '<div class="questTriggerConnector tall"></div>' + triggerConditionColumnHtml(trigger.condition, trigger.object_id)
: triggerActionChainHtml(trigger.chain, trigger.object_id);
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, objectId) {
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, objectId)
: '<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, 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 =
'<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) :
// 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 =
'<p class="hint mb-2">Ajouter...</p>' +
'<div class="collisionWizardChoices">' +
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="triggerAppendLeaf(' + objectId + ', \'' + afterId + '\', \'dialogue\')">' +
'<span>Déclencher un dialogue</span>' +
'</button>' +
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="triggerAppendLeaf(' + objectId + ', \'' + afterId + '\', \'variable\')">' +
'<span>Modifier une variable</span>' +
'</button>' +
'</div>';
}
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 <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.
function initTriggersTab() {
var body = document.getElementById('triggersTabPanelBody');
if (!body) return;
body.innerHTML = '<div class="questDialogueColumns">' + triggerColumnHtml() + '</div>';
triggerLoadAll();
}
initTriggersTab();