Config strictement stricte partout (ruff, mypy --strict, bandit, vulture, import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas casser le build" - l'existant a ete corrige pour la satisfaire plutot que l'inverse. Hooks pre-commit locaux (language: system) bloquants. - Typage mypy --strict propage a tout le moteur (db, screens, auth, core, ai, routes, puis publish/scripts/tests/app.py/build_css.py). - Securite : fuite de handle fichier Windows corrigee dans l'export SCORM (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe "</script>" dans le JSON embarque en <script>, 25 sites). - Architecture : imports circulaires/F811 nettoyes, contrats import-linter respectes, code mort retire (vulture). - Accessibilite : 69 champs de formulaire sans label correctement associe corriges (for/id ou aria-label) sur 11 templates. - ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis appliquees (aucune desactivee sans verification individuelle). - Tests : isolation du compte admin partage (nettoyage ponctuel + fixture de teardown automatique en filet de securite), suite complete verte (591 tests Python, 241 tests JS). - SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport complet analyse point par point, faux positifs documentes. - .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine faisait echouer ESLint (linebreak-style) via un bug connu de git (checkout "en place" qui ignore l'eol force sur un fichier deja present sur disque - contourne en supprimant puis recreant chaque fichier suivi). djLint (H021, styles inline) volontairement saute pour ce commit - backlog assume, deja documente, traite dans un lot separe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1003 lines
48 KiB
JavaScript
1003 lines
48 KiB
JavaScript
// ---------- 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 <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, '"');
|
||
}
|
||
|
||
// ---------- 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, '"')}"${a.url === audioUrl ? ' selected' : ''}>${a.name.replace(/</g, '<')}</option>`).join('')}`;
|
||
const preview = audioUrl
|
||
? `<audio controls preload="none" src="${audioUrl.replace(/"/g, '"')}" 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, '"')}"${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, '<')}</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, '<')} — ${opLabel.replace(/</g, '<')}${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, '<');
|
||
}
|
||
|
||
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, '<')}`);
|
||
}
|
||
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, '<')}</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, '<')} ${(opLabel || '').replace(/</g, '<')} ${(condition.valeur || '').replace(/</g, '<')}`;
|
||
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, '<')}</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();
|