Files
Forge-Engine/static/js/triggers/trigger-editor.js
T
williamandClaude Sonnet 5 66d8eaeae8 Lots 1-3 modernisation JS (S8786/S2703/S2486) + retrait Sonar CI/prod
- Lot 1 (S8786, ReDoS) : 5 sites documentes NOSONAR apres preuve empirique
  (script reproductible docs/redos_probe_s8786.js), aucune reecriture
  defensive necessaire.
- Lot 2 (S2703, variable globale implicite) : bug reel trouve et corrige
  (SCENE_OBJECT_NAMES en const au lieu de let, cassait la reassignation
  cross-script depuis scene-editor.js) + test de non-regression ; 4 autres
  sites confirmes surs et documentes.
- Lot 3 (S2486, exceptions avalees) : 6 sites confirmes surs et
  documentes ; 2 sites (config sprite JSON invalide) corriges avec un
  console.warn devtools, comportement joueur inchange, couverts par un
  nouveau test.
- Retrait du job CI sonarqube (.gitea/workflows/deploy.yml) et du service
  prod sonarqube/sonar-postgres (docker-compose.prod.yml) : acces dashboard
  bloque par des soucis d'infrastructure reseau (WSL2/pare-feu Hyper-V en
  local, reseau Docker partage avec Caddy pas en place en prod), sans lien
  avec le code du moteur - mis de cote plutot que de continuer a bloquer
  sur de l'infra. Les lots 4+ de modernisation JS dependent de scores
  Sonar exacts et sont donc egalement en pause (voir CODE_QUALITY.md).

SKIP=djlint : H021 (styles inline, 49 occurrences) est un backlog deja
documente et assume (CODE_QUALITY.md section 6), sur des templates non
touches par ce commit - deja exclu de la CI pour la meme raison.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 08:46:24 +02:00

1012 lines
49 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------- Onglet "Déclencheurs" ----------
// templates/scene_edit.html : un GRAPHE DE LOGIQUE lu par déclencheur
// (demande explicite : "un RH doit le comprendre, un visuel global") —
// TOUS les déclencheurs du JEU ENTIER vivent côte à côte dans la même
// colonne (plus de "quête" pour les regrouper, voir screens/rendering/
// trigger_graph.py::list_all_triggers), chacun avec SON PROPRE dialogue
// embarqué ({id, lines}, voir screens/rendering/collision_rules.py::
// "dialogue".dialogue). Les boutons Réplique/Question sont posés
// DIRECTEMENT sur le rectangle de dialogue de chaque déclencheur — le
// seul bouton au bas de la colonne est "Ajouter un déclencheur". Le
// créateur décide lui-même, via ses variables globales et conditions
// (voir screens/rendering/collision_rules.py::"condition"), QUAND
// déclencher quel dialogue et QUAND marquer un dialogue comme terminé
// (action.mark_completed, voir static/js/play/dialogue-box-controller.js).
// Noms d'objets de scène nommés (voir "ℹ️ Informations", templates/
// scene_edit.html) — N'IMPORTE QUEL objet (personnage, décor, fond), pas
// seulement un personnage — proposés comme "qui parle" pour une réplique,
// en plus de "Joueur" (toujours disponible, jamais un objet de scène).
// `let`, pas `const` : réassignée depuis un AUTRE <script> (voir
// refreshSceneObjectNames(), static/js/scenes/scene-editor.js) — deux
// balises <script> classiques sur la même page partagent un même
// environnement lexical global, mais une liaison `const` posée dans l'une
// ne peut PAS être réassignée depuis l'autre (TypeError: Assignment to
// constant variable., vérifié empiriquement). Bug réel trouvé lors de
// l'audit S2703 : renommer un personnage puis ouvrir un dialogue de
// déclencheur SANS recharger la page ne voyait jamais le nouveau nom.
// eslint-disable-next-line prefer-const -- reassignee depuis scene-editor.js, voir commentaire ci-dessus
let 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) {
const options = ['Joueur'].concat(SCENE_OBJECT_NAMES);
if (currentSpeaker && options.indexOf(currentSpeaker) === -1) options.push(currentSpeaker);
return options;
}
function triggerEscAttr(str) {
return (str || '').replace(/"/g, '&quot;');
}
// ---------- Colonne = un graphe de logique lu par déclencheur ----------
function triggerColumnHtml() {
return (
'<div class="questDialogueColumn">'
+ '<div class="questDialogueColumnBody" id="triggerColumnBody">'
+ '<div class="questTriggersSlot" id="triggersSlot"></div>'
+ '</div>'
+ '<div class="questDialogueColumnButtons">'
+ '<button type="button" class="collisionRuleAddBtn questDialogueAddBtn" onclick="openAddTriggerModal()">Ajouter un déclencheur</button>'
+ '</div>'
+ '</div>'
);
}
// ---------- Dialogue INLINE (voir screens/rendering/collision_rules.py
// ::"dialogue".dialogue) ----------
// Un dialogue = {id, lines} porté PAR L'ACTION elle-même — rendu comme un
// rectangle à bords arrondis, MÊME largeur que le reste de la chaîne
// (pastilles/carte objet), avec un bouton "Réplique"/"Question" qui
// ajoute une bulle directement dedans.
// Voix off d'une réplique (demande explicite : "ajouter une réplique
// audio") — un fichier déjà présent dans "Mes assets" (voir
// USER_ASSETS_OPTIONS, posé par templates/scene_edit.html), jamais une
// URL tapée à la main : filtré aux sons (kind="audio", voir
// auth/user_asset_kind.py) — un choix de son ou de vidéo n'a pas de sens
// ici.
function triggerAudioAssetOptions() {
return (typeof USER_ASSETS_OPTIONS !== 'undefined' ? USER_ASSETS_OPTIONS : []).filter((a) => a.kind === 'audio');
}
function triggerBubbleAudioControlHtml(dialogueId, index, audioUrl) {
const assetOptions = triggerAudioAssetOptions();
const selectOptions = `<option value="">Aucune voix off</option>${assetOptions.map((a) => `<option value="${a.url.replace(/"/g, '&quot;')}"${a.url === audioUrl ? ' selected' : ''}>${a.name.replace(/</g, '&lt;')}</option>`).join('')}`;
const preview = audioUrl
? `<audio controls preload="none" src="${audioUrl.replace(/"/g, '&quot;')}" class="questBubbleAudioPreview"></audio>`
: '';
return (
'<div class="questBubbleAudioRow">'
+ `<select class="questBubbleAudioSelect" title="Voix off (optionnel)" onchange="triggerInlineSetBubbleAudio('${dialogueId}', ${index}, this.value)">${selectOptions}</select>${
preview
}</div>`
);
}
// Une bulle = header (qui parle, menu déroulant des objets de scène
// nommés — voir "ℹ️ Informations") + body (le texte + la voix off
// optionnelle) — trois zones distinctes, pas un seul bloc.
function triggerDialogueBubbleHtml(dialogueId, line, index) {
const kind = triggerBubbleKind(line.speaker);
const speakerSelect = triggerSpeakerOptions(line.speaker).map((name) => `<option value="${name.replace(/"/g, '&quot;')}"${name === line.speaker ? ' selected' : ''}>${name === 'Joueur' ? 'Joueur' : name}</option>`).join('');
return (
'<div class="questBubbleRow">'
+ `<div class="questBubble questBubble-${kind}">`
+ '<div class="questBubbleHeader">'
+ `<select class="questBubbleSpeaker" title="Qui parle" onchange="triggerInlineSetBubbleSpeaker('${dialogueId}', ${index}, this.value)">${speakerSelect}</select>`
+ `<button type="button" class="questBubbleDelete" title="Supprimer" onclick="triggerInlineDeleteBubble('${dialogueId}', ${index})">✕</button>`
+ '</div>'
+ '<div class="questBubbleBody">'
+ '<textarea class="questBubbleText" rows="1" placeholder="Réplique..." '
+ 'oninput="this.style.height=\'auto\'; this.style.height=this.scrollHeight+\'px\';" '
+ `onchange="triggerInlineUpdateBubbleText('${dialogueId}', ${index}, this.value)">${line.text.replace(/</g, '&lt;')}</textarea>${
triggerBubbleAudioControlHtml(dialogueId, index, line.audio_url)
}</div>`
+ '</div>'
+ '</div>'
);
}
// "❓ Question" — une bulle JAUNE dans la même chaîne qu'une réplique de
// dialogue (voir .questBubbleRow/.questBubble et static/style.css) mais
// un type de ligne différent (voir db/dialogue_lines.py) : header =
// nombre de choix + récompense (type + montant), body = la question et
// ses choix, avec la bonne réponse cochée.
function triggerQuestionChoiceRowHtml(dialogueId, choice, choiceIndex, correctIndex, index) {
const 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) {
const choicesHtml = line.choices.map((c, i) => 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>'
);
}
// 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(/</g, '&lt;')} — ${opLabel.replace(/</g, '&lt;')}${valuePart}`;
}
// Feuille "Modifier une variable" — un encart compact (même largeur que
// le reste de la chaîne), pas de bulles dedans contrairement au
// rectangle de dialogue.
function triggerVariableActionHtml(item) {
return (
'<div class="dialogueBox questVariableAction">'
+ '<div class="dialogueBoxHeader">'
+ '<span class="dialogueBoxTitle">Variable</span>'
+ '</div>'
+ `<p class="questVariableActionLabel">${triggerVariableActionLabel(item)}</p>`
+ '</div>'
);
}
// ---------- Feuilles "surbrillance"/"visibilite"/"son"/"video"/
// "indication" (voir screens/rendering/collision_rules.py) — même
// encart compact que "Modifier une variable" ci-dessus, un simple texte
// descriptif (rien à éditer en ligne, contrairement au dialogue).
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 = '<span class="questTriggerPillControls">'
+ `<button type="button" title="Monter" class="questTriggerPillCtrlBtn"${index === 0 ? ' disabled' : ''
} onclick="event.stopPropagation(); triggerMoveChainAction('${target}', ${targetIdArg}, '${leafId}', 'up')">↑</button>`
+ `<button type="button" title="Descendre" class="questTriggerPillCtrlBtn"${index === total - 1 ? ' disabled' : ''
} onclick="event.stopPropagation(); triggerMoveChainAction('${target}', ${targetIdArg}, '${leafId}', 'down')">↓</button>`
+ '<button type="button" title="Retirer cette action" class="questTriggerPillCtrlBtn questTriggerPillCtrlBtn-delete" '
+ `onclick="event.stopPropagation(); triggerRemoveChainAction('${target}', ${targetIdArg}, '${leafId}')">✕</button>`
+ '</span>';
}
return `<div class="questTriggerPill quete">${label}${controls}</div>`;
}
function triggerAssetFilename(url) {
return ((url || '').split('/').pop() || url || '').replace(/</g, '&lt;');
}
function triggerGenericLeafBoxHtml(title, text) {
return (
'<div class="dialogueBox questVariableAction">'
+ `<div class="dialogueBoxHeader"><span class="dialogueBoxTitle">${title}</span></div>`
+ `<p class="questVariableActionLabel">${text}</p>`
+ '</div>'
);
}
function triggerLeafBoxHtml(item) {
if (item.type === 'dialogue') return triggerInlineDialogueHtml(item.dialogue);
if (item.type === 'variable') return triggerVariableActionHtml(item);
if (item.type === 'surbrillance') {
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(/</g, '&lt;')}`);
}
if (item.type === 'attendre') {
return triggerGenericLeafBoxHtml('Attendre', `${item.data_value || '0'} seconde(s)`);
}
return '';
}
// Une chaîne d'actions (voir screens/rendering/trigger_graph.py::
// _describe_chain) : chaque feuille (dialogue, variable, ou l'une des
// nouvelles actions ci-dessus) à la suite les unes des autres — PUIS, en
// bout de chaîne, "+ Ajouter une action" (demande explicite : "pouvoir
// modifier un déclencheur pour lui ajouter des actions", jamais bloqué à
// une seule action). `target`/`targetId` : "object"/l'id de l'objet
// (routes/triggers/triggers.py) ou "screen"/null (routes/screens/
// screen_triggers.py, voir screenTriggerOpenAppendActionModal) — décide
// où poster l'ajout.
// "+ Ajouter une action" APRÈS `afterId` — insère (voir
// screens/rendering/collision_rules.py::set_then_action_in_rules,
// demande explicite : "pouvoir ajouter n'importe quelle action à
// n'importe quel niveau du schéma") plutôt que d'exiger d'être en bout
// de chaîne réel : posé après CHAQUE bloc (voir triggerActionChainHtml),
// pas seulement le dernier.
function triggerAppendButtonHtml(target, targetId, afterId) {
if (!afterId) return '';
const appendOnclick = target === 'screen'
? `screenTriggerOpenAppendActionModal('${afterId}')`
: `triggerOpenAppendActionModal(${targetId}, '${afterId}')`;
return '<div class="questTriggerConnector"></div>'
+ `<button type="button" class="collisionRuleAddBtn questTriggerAddChainedActionBtn" onclick="${appendOnclick}">+ Ajouter une action</button>`;
}
function triggerActionChainHtml(chain, target, targetId) {
if (!chain || !chain.length) return '';
return chain.map((item, i) => {
const leafId = triggerItemLeafId(item);
return (
`<div class="questTriggerConnector"></div>${triggerLeafPillHtml(item, target, targetId, i, chain.length)
}<div class="questTriggerConnector"></div>${triggerLeafBoxHtml(item)
}${triggerAppendButtonHtml(target, targetId, leafId)}`
);
}).join('');
}
// Le rectangle lui-même — voir screens/rendering/collision_rules.py::
// "dialogue".dialogue pour la forme {id, lines}. `dialogue` peut être
// absent (action posée sans passer par l'assistant) : rien n'est rendu
// plutôt que de planter, même tolérance que le reste de ce moteur.
function triggerInlineDialogueHtml(dialogue) {
if (!dialogue) return '';
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 (
'<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é.
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 = `<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>`
);
}
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 ? '<div class="questTriggerConnector"></div>' : ''
}<div class="questTriggerPill ${pill.kind}">${pill.text}</div>`).join('');
const tailHtml = trigger.condition
? `<div class="questTriggerConnector tall"></div>${triggerConditionColumnHtml(trigger.condition, 'object', trigger.object_id)}`
: triggerActionChainHtml(trigger.chain, 'object', trigger.object_id);
return (
`<div class="questTriggerCol${isActive ? ' active' : ''}">`
+ '<button type="button" class="questTriggerDelete" title="Retirer ce déclencheur" '
+ `onclick="event.stopPropagation(); triggerDeleteTrigger(${trigger.object_id})">✕</button>${
card
}<div class="questTriggerConnector"></div>${
pillsHtml
}${tailHtml
}</div>`
);
}
// Clic sur la carte objet : replie/déplie TOUTE la logique associée
// (accordéon, demande explicite) — et rend ce déclencheur actif au
// passage puisqu'on vient d'y porter son attention.
function triggerClickObjectCard(objectId, leafId) {
_triggerCollapsed[objectId] = !_triggerCollapsed[objectId];
if (leafId) _triggerActiveDialogueId = leafId;
triggerRenderSlot();
}
// Losange "condition" — lecture ENTIÈREMENT VERTICALE (demande explicite :
// "il faut tout mettre verticalement", sans toucher au losange lui-même) :
// la branche Vrai descend tout droit vers son "Déclencher ce dialogue"
// (ou "Ne rien faire"), PUIS la branche Faux descend à son tour — jamais
// deux branches côte à côte. Chaque branche affiche SON PROPRE dialogue
// (voir screens/rendering/trigger_graph.py::_resolve_action_trigger),
// indépendamment de l'autre.
function triggerCondArrowHtml(kind, label) {
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 (
'<div class="questCondArrowWrap">'
+ `<div class="questCondArrowLabel ${kind}">${label}</div>`
+ '<svg viewBox="0 0 34 68" preserveAspectRatio="none" aria-hidden="true">'
+ `<defs><marker id="${markerId}" markerWidth="10" markerHeight="10" refX="7" refY="5" orient="auto">`
+ `<path d="M0,0 L10,5 L0,10 Z" fill="${color}"></path></marker></defs>`
+ `<line x1="17" y1="0" x2="17" y2="56" stroke="${color}" stroke-width="4" marker-end="url(#${markerId})"></line>`
+ '</svg>'
+ '</div>'
);
}
function triggerConditionColumnHtml(condition, target, targetId) {
const opLabel = CONDITION_OPERATOR_LABELS_MAP[condition.operateur] || condition.operateur;
const label = `${(condition.variable || '').replace(/</g, '&lt;')} ${(opLabel || '').replace(/</g, '&lt;')} ${(condition.valeur || '').replace(/</g, '&lt;')}`;
const branchesHtml = ['si_vrai', 'si_faux'].map((branchKey) => {
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)
: '<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).
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 <audio> (apparaît/disparaît selon le choix)
triggerInlineSaveDialogue(dialogueId);
}
function triggerInlineUpdateBubbleText(dialogueId, index, text) {
const entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines[index].text = text;
triggerInlineSaveDialogue(dialogueId);
}
function triggerInlineDeleteBubble(dialogueId, index) {
const entry = _triggerDialogueRegistry[dialogueId];
if (!entry) return;
entry.dialogueRef.lines.splice(index, 1);
_triggerRenderSlotForEntry(entry);
triggerInlineSaveDialogue(dialogueId);
}
// ---------- Modale d'ajout de déclencheur ----------
// Étape 1 : choisir l'objet de scène (toutes scènes du jeu confondues,
// voir GET .../triggers/objects-catalog — contrairement à l'éditeur de
// collision, limité à la scène actuellement ouverte). Étape 2 : le MÊME
// assistant "déclencheur -> action" que l'éditeur de collision (voir
// openTriggerWizard, static/js/scenes/collision-rules-editor.js).
function ensureTriggerModal() {
let 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() {
const modal = ensureTriggerModal();
modal.classList.add('is-active');
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter un déclencheur';
triggerRenderObjectStep();
}
function triggerRenderObjectStep() {
const 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((r) => r.json())
.then((objects) => {
const 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((o, i) => `<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) {
const body = document.getElementById('questTriggerModalBody');
openTriggerWizard(objectId, body);
}
// ---------- "+ Ajouter une action" (bout de chaîne d'un déclencheur
// DÉJÀ posé) ----------
// Demande explicite : "pouvoir modifier un déclencheur pour lui ajouter
// des actions" — pas besoin de repasser par la modale de création, on
// est déjà DANS ce déclencheur (voir triggerActionChainHtml). Réutilise
// les étapes de l'assistant (static/js/scenes/collision-rules-editor.js) :
// même choix de feuille (COLLISION_CHAIN_CHOICES) que l'étape "Ensuite ?"
// de l'assistant, avant d'envoyer le tout via /append-action.
function triggerOpenAppendActionModal(objectId, afterId) {
const modal = ensureTriggerModal();
modal.classList.add('is-active');
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter une action';
const body = document.getElementById('questTriggerModalBody');
// État minimal partagé avec collision-rules-editor.js (mêmes étapes
// réutilisées telles quelles, voir renderCollisionWizardChainStep/
// collisionWizardBuildLeafAction — elles ne lisent que .bodyEl).
_collisionWizard = { bodyEl: body };
const buttons = COLLISION_CHAIN_CHOICES.map((choice, i) => `<button type="button" class="collisionWizardChoice" style="animation-delay:${i * 40}ms" onclick="triggerAppendLeaf(${objectId}, '${afterId}', '${choice}')"><span>${COLLISION_ACTION_LABELS[choice]}</span></button>`).join('');
body.innerHTML = `<p class="hint mb-2">Ajouter...</p><div class="collisionWizardChoices">${buttons}</div>`;
}
function triggerAppendLeaf(objectId, afterId, type) {
collisionWizardBuildLeafAction(type, (leaf) => {
renderCollisionWizardChainStep(leaf, (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 }),
}).then((r) => {
if (!r.ok) return r.json().then((json) => { alert(json.error || "Impossible d'ajouter cette action."); });
}).then(() => { triggerLoadAll(); });
}
function closeTriggerModal() {
const modal = document.getElementById('questTriggerModal');
if (modal) modal.classList.remove('is-active');
}
// ---------- Section "Déclencheurs de l'écran" (SANS objet, voir
// screens/rendering/screen_triggers.py) ----------
// Même moteur d'affichage que les déclencheurs d'objet ci-dessus
// (triggerActionChainHtml/triggerConditionColumnHtml, target='screen'),
// mais une liste PLATE de règles (pas de carte objet, pas d'accordéon —
// il n'y a qu'un seul "propriétaire" possible : l'écran lui-même).
// screenActionToChain/screenTriggerNormalize reproduisent CÔTÉ CLIENT le
// même aplatissement que screens/rendering/trigger_graph.py::
// _resolve_action_trigger fait côté serveur pour les déclencheurs
// d'objet (jamais de condition imbriquée dans un "then", voir
// _sanitize_leaf_action — la boucle s'arrête donc forcément sur le
// premier "condition" rencontré, toujours en position de tête).
function collisionLeafChain(leafAction) {
const chain = [];
let node = leafAction;
while (node) { chain.push(node); node = node.then; }
return chain;
}
function screenTriggerNormalize(rule) {
if (rule.action.type === 'condition') {
const cond = rule.action;
return {
chain: null,
condition: {
variable: cond.variable,
operateur: cond.operateur,
valeur: cond.valeur,
si_vrai: cond.si_vrai ? { chain: collisionLeafChain(cond.si_vrai) } : null,
si_faux: cond.si_faux ? { chain: collisionLeafChain(cond.si_faux) } : null,
},
};
}
return { chain: collisionLeafChain(rule.action), condition: null };
}
let _screenTriggersCache = [];
function screenTriggerColumnHtml() {
return (
'<div class="questDialogueColumn">'
+ '<div class="questDialogueColumnBody" id="screenTriggerColumnBody">'
+ '<div class="questTriggersSlot" id="screenTriggersSlot"></div>'
+ '</div>'
+ '<div class="questDialogueColumnButtons">'
+ '<button type="button" class="collisionRuleAddBtn questDialogueAddBtn" onclick="openScreenAddTriggerModal()">Ajouter un déclencheur d\'écran</button>'
+ '</div>'
+ '</div>'
);
}
function screenTriggerRuleHtml(rule, index) {
const normalized = screenTriggerNormalize(rule);
const tailHtml = normalized.condition
? triggerConditionColumnHtml(normalized.condition, 'screen', null)
: triggerActionChainHtml(normalized.chain, 'screen', null);
return (
'<div class="questTriggerCol">'
+ `<button type="button" class="questTriggerDelete" title="Retirer ce déclencheur" onclick="screenTriggerDeleteAt(${index})">✕</button>`
+ `<div class="questTriggerPill trigger">${COLLISION_TRIGGER_LABELS[rule.trigger] || rule.trigger}</div>`
+ `<div class="questTriggerConnector"></div>${
tailHtml
}</div>`
);
}
function screenTriggerRenderSlot() {
const slot = document.getElementById('screenTriggersSlot');
if (!slot) return;
slot.innerHTML = _screenTriggersCache.length
? _screenTriggersCache.map((rule, i) => screenTriggerRuleHtml(rule, i)).join('')
: '<p class="hint">Aucun déclencheur d\'écran pour l\'instant.</p>';
}
function _registerScreenTriggerDialogues(list) {
_clearRegistryEntriesWithKey('screenId');
const dialogueIds = [];
list.forEach((rule) => {
const normalized = screenTriggerNormalize(rule);
if (normalized.condition) {
['si_vrai', 'si_faux'].forEach((branchKey) => {
const branch = normalized.condition[branchKey];
if (branch) _registerChainDialogues(branch.chain, { screenId: CURRENT_SCREEN_ID }, dialogueIds);
});
} else {
_registerChainDialogues(normalized.chain, { screenId: CURRENT_SCREEN_ID }, dialogueIds);
}
});
return dialogueIds;
}
function screenTriggerLoadAll() {
fetch(`/game/${GAME_SLUG}/screens/${CURRENT_SCREEN_ID}/triggers`)
.then((r) => r.json())
.then((list) => {
_screenTriggersCache = list;
_registerScreenTriggerDialogues(list);
screenTriggerRenderSlot();
});
}
function screenTriggerDeleteAt(index) {
fetch(`/game/${GAME_SLUG}/screens/${CURRENT_SCREEN_ID}/triggers/${index}`, { method: 'DELETE' })
.then(() => { screenTriggerLoadAll(); });
}
function openScreenAddTriggerModal() {
const modal = ensureTriggerModal();
modal.classList.add('is-active');
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter un déclencheur d\'écran';
const body = document.getElementById('questTriggerModalBody');
openScreenTriggerWizard(CURRENT_SCREEN_ID, body);
}
// "+ Ajouter une action" en bout de chaîne d'un déclencheur d'écran déjà
// posé — même parcours que triggerOpenAppendActionModal ci-dessus, juste
// adressé vers /screens/<id>/triggers/append-action (aucun objet à
// connaître).
function screenTriggerOpenAppendActionModal(afterId) {
const modal = ensureTriggerModal();
modal.classList.add('is-active');
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter une action';
const body = document.getElementById('questTriggerModalBody');
_collisionWizard = { bodyEl: body };
const buttons = COLLISION_CHAIN_CHOICES.map((choice, i) => `<button type="button" class="collisionWizardChoice" style="animation-delay:${i * 40}ms" onclick="screenTriggerAppendLeaf('${afterId}', '${choice}')"><span>${COLLISION_ACTION_LABELS[choice]}</span></button>`).join('');
body.innerHTML = `<p class="hint mb-2">Ajouter...</p><div class="collisionWizardChoices">${buttons}</div>`;
}
function screenTriggerAppendLeaf(afterId, type) {
collisionWizardBuildLeafAction(type, (leaf) => {
renderCollisionWizardChainStep(leaf, (finalLeaf) => { screenTriggerSaveAppendedAction(afterId, finalLeaf); });
});
}
function screenTriggerSaveAppendedAction(afterId, action) {
closeTriggerModal();
fetch(`/game/${GAME_SLUG}/screens/${CURRENT_SCREEN_ID}/triggers/append-action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ after_id: afterId, action }),
}).then((r) => {
if (!r.ok) return r.json().then((json) => { alert(json.error || "Impossible d'ajouter cette action."); });
}).then(() => { screenTriggerLoadAll(); });
}
// ---------- Point d'entrée ----------
// Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé par
// un <script src> posé à la fin du contenu de la page — les éléments dont
// on a besoin existent déjà à ce stade. Nécessaire aussi pour la
// navigation pjax (voir static/pjax.js — runScriptsIn ré-exécute ce
// script à chaque page swappée, mais DOMContentLoaded, lui, ne se
// redéclenche jamais après le tout premier chargement réel) — sans ça,
// l'onglet "Déclencheurs" restait vide après un remplacement de <main>
// (ex. suppression d'un objet de scène, voir scene_object_delete.py) qui
// ne redéclenche jamais cet événement.
// Menu latéral (demande explicite) : "🧩 Objets" (déclencheurs posés sur
// un objet de scène) / "🖥️ Écran" (voir screens/rendering/
// screen_triggers.py, SANS objet requis) — un seul panneau visible à la
// fois, plutôt que les deux empilés l'un sous l'autre.
let _triggersTabMode = 'object';
function triggersTabSwitchMode(mode) {
_triggersTabMode = mode;
const objectItem = document.getElementById('triggersTabSidebarObject');
const screenItem = document.getElementById('triggersTabSidebarScreen');
const objectPane = document.getElementById('triggersObjectPane');
const screenPane = document.getElementById('triggersScreenPane');
if (!objectItem || !screenItem || !objectPane || !screenPane) return;
objectItem.classList.toggle('active', mode === 'object');
screenItem.classList.toggle('active', mode === 'screen');
objectPane.style.display = mode === 'object' ? 'flex' : 'none';
screenPane.style.display = mode === 'screen' ? 'flex' : 'none';
}
function initTriggersTab() {
const body = document.getElementById('triggersTabPanelBody');
if (!body) return;
body.innerHTML = '<div class="triggersTabLayout">'
+ '<div class="triggersTabSidebar">'
+ '<button type="button" id="triggersTabSidebarObject" class="triggersTabSidebarItem active" onclick="triggersTabSwitchMode(\'object\')">Objets</button>'
+ '<button type="button" id="triggersTabSidebarScreen" class="triggersTabSidebarItem" onclick="triggersTabSwitchMode(\'screen\')">Écran</button>'
+ '</div>'
+ '<div class="triggersTabContent">'
+ '<div id="triggersObjectPane" class="triggersTabPane">'
+ `<div class="questDialogueColumns">${triggerColumnHtml()}</div>`
+ '</div>'
+ '<div id="triggersScreenPane" class="triggersTabPane" style="display:none;">'
+ '<p class="hint triggersTabPaneIntro">Se déclenchent SANS objet — narration/cinématique dès l\'affichage de cet écran.</p>'
+ `<div class="questDialogueColumns">${screenTriggerColumnHtml()}</div>`
+ '</div>'
+ '</div>'
+ '</div>';
// conserve l'onglet actif d'un ré-affichage à l'autre (pjax, sélection d'objet...)
triggersTabSwitchMode(_triggersTabMode);
triggerLoadAll();
screenTriggerLoadAll();
}
initTriggersTab();