Enrichit les declencheurs/actions de scene (clic/survol/affichage, surbrillance/video/son/visibilite/indication/attendre) et fiabilise la pose d'un fond/decor importe
- Ajoute clic/survol/affichage-ecran comme declencheurs, et surbrillance, video, son, visibilite, indication, attendre comme actions, utilisables aussi bien par l'editeur manuel (menu lateral Objets/Ecran) que par Ruby (IA), avec blocs deplacables/supprimables dans une chaine. - Corrige plusieurs variantes du bug "impossible de poser un objet hors du champ de la camera" (troncature du chainage d'actions a 4 maillons, fond importe pose a 128x128 au lieu de sa taille reelle, decalage du fond au vrai glisser-depose, redimensionnement manuel jamais propage au monde). - Ajoute un vrai glisser-depose depuis la galerie vers la scene, la gestion complete de "Mes assets" (sous-sections Fonds/Decors/Sons/ Videos, suppression, reclassement fond<->decor sans re-upload). - Ajoute l'upload de son (limite 3 min) et de video (MP4 uniquement, limite 5 min), avec validation de la duree reelle du fichier, et une replique audio optionnelle dans une bulle de dialogue. - Fixe la taille de pose d'un objet/decor importe a 200x200 avec une boite de collision de 150x150. - Filtre le selecteur de fichier des actions son/video par type reel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b07b231a61
commit
559331f9cf
@@ -63,9 +63,35 @@ function triggerColumnHtml() {
|
||||
// (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(function (a) { return a.kind === 'audio'; });
|
||||
}
|
||||
|
||||
function triggerBubbleAudioControlHtml(dialogueId, index, audioUrl) {
|
||||
var assetOptions = triggerAudioAssetOptions();
|
||||
var selectOptions = '<option value="">Aucune voix off</option>' + assetOptions.map(function (a) {
|
||||
return '<option value="' + a.url.replace(/"/g, '"') + '"' + (a.url === audioUrl ? ' selected' : '') + '>' + a.name.replace(/</g, '<') + '</option>';
|
||||
}).join('');
|
||||
var 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) — deux zones
|
||||
// distinctes, pas un seul bloc.
|
||||
// nommés — voir "ℹ️ Informations") + body (le texte + la voix off
|
||||
// optionnelle) — trois zones distinctes, pas un seul bloc.
|
||||
function triggerDialogueBubbleHtml(dialogueId, line, index) {
|
||||
var kind = triggerBubbleKind(line.speaker);
|
||||
var speakerSelect = triggerSpeakerOptions(line.speaker).map(function (name) {
|
||||
@@ -82,6 +108,7 @@ function triggerDialogueBubbleHtml(dialogueId, line, index) {
|
||||
'<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>'
|
||||
@@ -160,27 +187,154 @@ function triggerVariableActionHtml(item) {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Feuilles "surbrillance"/"visibilite"/"son"/"video"/
|
||||
// "indication" (voir screens/rendering/collision_rules.py) — même
|
||||
// encart compact que "Modifier une variable" ci-dessus, un simple texte
|
||||
// descriptif (rien à éditer en ligne, contrairement au dialogue).
|
||||
var COLLISION_LEAF_PILL_LABELS = {
|
||||
dialogue: 'Déclencher ce dialogue', variable: 'Modifier une variable',
|
||||
surbrillance: 'Mettre en surbrillance', visibilite: 'Rendre visible/invisible',
|
||||
son: 'Jouer un son', video: 'Jouer une vidéo', indication: 'Afficher une indication',
|
||||
attendre: 'Attendre quelques secondes',
|
||||
};
|
||||
|
||||
function triggerItemLeafId(item) {
|
||||
return item.type === 'dialogue' ? (item.dialogue && item.dialogue.id) : item.id;
|
||||
}
|
||||
|
||||
// URL commune retrait/déplacement — objet (routes/triggers/triggers.py)
|
||||
// ou écran (routes/screens/screen_triggers.py, toujours CURRENT_SCREEN_ID,
|
||||
// aucun `targetId` à connaître).
|
||||
function triggerChainActionUrl(target, targetId, leafId, suffix) {
|
||||
return target === 'screen'
|
||||
? '/game/' + GAME_SLUG + '/screens/' + CURRENT_SCREEN_ID + '/triggers/action/' + encodeURIComponent(leafId) + suffix
|
||||
: '/game/' + GAME_SLUG + '/triggers/' + targetId + '/action/' + encodeURIComponent(leafId) + suffix;
|
||||
}
|
||||
|
||||
function triggerReloadFor(target) {
|
||||
if (target === 'screen') screenTriggerLoadAll();
|
||||
else triggerLoadAll();
|
||||
}
|
||||
|
||||
// Demande explicite : "que chaque bloc étiquette + action soit
|
||||
// déplaçable ou supprimable par l'utilisateur et par l'IA aussi" — voir
|
||||
// screens/rendering/collision_rules.py::remove_action_from_rules/
|
||||
// move_action_in_rules, déjà utilisables par Ruby via remove_trigger_action/
|
||||
// move_trigger_action (voir ai/tools.py).
|
||||
function triggerRemoveChainAction(target, targetId, leafId) {
|
||||
if (!confirm('Retirer cette action ?')) return;
|
||||
fetch(triggerChainActionUrl(target, targetId, leafId, ''), { method: 'DELETE' })
|
||||
.then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible de retirer cette action."); });
|
||||
})
|
||||
.then(function () { triggerReloadFor(target); });
|
||||
}
|
||||
|
||||
function triggerMoveChainAction(target, targetId, leafId, direction) {
|
||||
fetch(triggerChainActionUrl(target, targetId, leafId, '/move'), {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ direction: direction }),
|
||||
}).then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible de déplacer cette action."); });
|
||||
}).then(function () { triggerReloadFor(target); });
|
||||
}
|
||||
|
||||
// `target`/`targetId` absents (undefined) : pastille sans contrôles —
|
||||
// utilisé pour "interagir" lui-même (pas une feuille adressable, voir
|
||||
// _leaf_id côté serveur) dans triggerChainHtml ci-dessous.
|
||||
function triggerLeafPillHtml(item, target, targetId, index, total) {
|
||||
var label = COLLISION_LEAF_PILL_LABELS[item.type] || item.type;
|
||||
var leafId = target && triggerItemLeafId(item);
|
||||
var controls = '';
|
||||
if (leafId) {
|
||||
var targetIdArg = targetId == null ? 'null' : targetId;
|
||||
controls =
|
||||
'<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') {
|
||||
var sLabel = (typeof SURBRILLANCE_LABELS_MAP !== 'undefined' && SURBRILLANCE_LABELS_MAP[item.valeur]) || item.valeur;
|
||||
return triggerGenericLeafBoxHtml('Surbrillance', 'Objet #' + item.object_id + ' — ' + sLabel);
|
||||
}
|
||||
if (item.type === 'visibilite') {
|
||||
var vLabel = (typeof ELEMENT_VISIBILITY_LABELS_MAP !== 'undefined' && ELEMENT_VISIBILITY_LABELS_MAP[item.valeur]) || item.valeur;
|
||||
return triggerGenericLeafBoxHtml('Visibilité', 'Objet #' + item.object_id + ' — ' + vLabel);
|
||||
}
|
||||
if (item.type === 'son') {
|
||||
return triggerGenericLeafBoxHtml('Son', triggerAssetFilename(item.asset_url));
|
||||
}
|
||||
if (item.type === 'video') {
|
||||
var mLabel = (typeof VIDEO_MODE_LABELS_MAP !== 'undefined' && VIDEO_MODE_LABELS_MAP[item.mode]) || item.mode;
|
||||
return triggerGenericLeafBoxHtml('Vidéo', triggerAssetFilename(item.asset_url) + ' — ' + mLabel);
|
||||
}
|
||||
if (item.type === 'indication') {
|
||||
return triggerGenericLeafBoxHtml('Indication', 'Objet #' + item.object_id + ' — ' + (item.texte || '').replace(/</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) : "Déclencher ce dialogue" + son rectangle, ou une
|
||||
// feuille "variable", à la suite les unes des autres — PUIS, en bout de
|
||||
// chaîne, "+ Ajouter une action" (demande explicite : "pouvoir modifier
|
||||
// un déclencheur pour lui ajouter des actions", jamais bloqué à une
|
||||
// seule action). `objectId` : nécessaire pour poster l'ajout sur le bon
|
||||
// objet (voir triggerOpenAppendActionModal).
|
||||
function triggerActionChainHtml(chain, objectId) {
|
||||
// _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 '';
|
||||
var 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 '';
|
||||
var itemsHtml = chain.map(function (item) {
|
||||
var pill = item.type === 'dialogue' ? triggerDeclencherDialoguePillHtml() : '<div class="questTriggerPill quete">Modifier une variable</div>';
|
||||
var box = item.type === 'dialogue' ? triggerInlineDialogueHtml(item.dialogue) : triggerVariableActionHtml(item);
|
||||
return '<div class="questTriggerConnector"></div>' + pill + '<div class="questTriggerConnector"></div>' + box;
|
||||
return chain.map(function (item, i) {
|
||||
var 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('');
|
||||
var lastItem = chain[chain.length - 1];
|
||||
var lastLeafId = lastItem.type === 'dialogue' ? lastItem.dialogue && lastItem.dialogue.id : lastItem.id;
|
||||
var appendBtn = lastLeafId
|
||||
? '<div class="questTriggerConnector"></div>' +
|
||||
'<button type="button" class="collisionRuleAddBtn questTriggerAddChainedActionBtn" onclick="triggerOpenAppendActionModal(' + objectId + ', \'' + lastLeafId + '\')">+ Ajouter une action</button>'
|
||||
: '';
|
||||
return itemsHtml + appendBtn;
|
||||
}
|
||||
|
||||
// Le rectangle lui-même — voir screens/rendering/collision_rules.py::
|
||||
@@ -259,15 +413,15 @@ function triggerChainHtml(trigger) {
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
var pills = [{ kind: 'trigger', text: COLLISION_TRIGGER_LABELS.collision }];
|
||||
var pills = [{ kind: 'trigger', text: COLLISION_TRIGGER_LABELS[trigger.trigger] || trigger.trigger }];
|
||||
if (trigger.has_interagir) pills.push({ kind: 'interagir', text: 'Interagir avec une touche' });
|
||||
var pillsHtml = pills.map(function (pill, i) {
|
||||
return (i > 0 ? '<div class="questTriggerConnector"></div>' : '') +
|
||||
'<div class="questTriggerPill ' + pill.kind + '">' + pill.text + '</div>';
|
||||
}).join('');
|
||||
var tailHtml = trigger.condition
|
||||
? '<div class="questTriggerConnector tall"></div>' + triggerConditionColumnHtml(trigger.condition, trigger.object_id)
|
||||
: triggerActionChainHtml(trigger.chain, trigger.object_id);
|
||||
? '<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" ' +
|
||||
@@ -311,7 +465,7 @@ function triggerCondArrowHtml(kind, label) {
|
||||
);
|
||||
}
|
||||
|
||||
function triggerConditionColumnHtml(condition, objectId) {
|
||||
function triggerConditionColumnHtml(condition, target, targetId) {
|
||||
var opLabel = CONDITION_OPERATOR_LABELS_MAP[condition.operateur] || condition.operateur;
|
||||
var label = (condition.variable || '').replace(/</g, '<') + ' ' + (opLabel || '').replace(/</g, '<') + ' ' + (condition.valeur || '').replace(/</g, '<');
|
||||
var branchesHtml = ['si_vrai', 'si_faux'].map(function (branchKey) {
|
||||
@@ -319,7 +473,7 @@ function triggerConditionColumnHtml(condition, objectId) {
|
||||
var arrowLabel = branchKey === 'si_vrai' ? '✓ Vrai' : '✕ Faux';
|
||||
var branch = condition[branchKey];
|
||||
var content = branch
|
||||
? triggerActionChainHtml(branch.chain, objectId)
|
||||
? triggerActionChainHtml(branch.chain, target, targetId)
|
||||
: '<div class="questTriggerPill none">Ne rien faire</div>';
|
||||
return triggerCondArrowHtml(kind, arrowLabel) + content;
|
||||
}).join('');
|
||||
@@ -338,35 +492,51 @@ function triggerConditionColumnHtml(condition, objectId) {
|
||||
// tapée — l'adopter effacerait silencieusement la bulle en cours
|
||||
// d'écriture).
|
||||
var _triggersCache = [];
|
||||
// dialogue_id -> {objectId, dialogueRef} — `dialogueRef` est LA MÊME
|
||||
// référence que dans _triggersCache, muter dialogueRef.lines ici mute
|
||||
// donc aussi le cache (une seule source de vérité). Couvre aussi bien le
|
||||
// dialogue direct d'un déclencheur que ceux embarqués dans ses branches
|
||||
// de condition.
|
||||
// dialogue_id -> {objectId}|{screenId} + dialogueRef — `dialogueRef` est
|
||||
// LA MÊME référence que dans _triggersCache/_screenTriggersCache, muter
|
||||
// dialogueRef.lines ici mute donc aussi le cache (une seule source de
|
||||
// vérité). Couvre aussi bien le dialogue direct d'un déclencheur que
|
||||
// ceux embarqués dans ses branches de condition — PARTAGÉ entre les
|
||||
// déclencheurs d'OBJET et D'ÉCRAN (voir _registerScreenTriggerDialogues),
|
||||
// une seule table pour les deux.
|
||||
var _triggerDialogueRegistry = {};
|
||||
|
||||
function _registerChainDialogues(chain, objectId, dialogueIds) {
|
||||
// `owner` : {objectId: N} ou {screenId: N} — L'UN OU L'AUTRE, jamais les
|
||||
// deux (voir triggerInlineSaveDialogue, qui route sur cette distinction).
|
||||
function _registerChainDialogues(chain, owner, dialogueIds) {
|
||||
(chain || []).forEach(function (item) {
|
||||
if (item.type === 'dialogue' && item.dialogue) {
|
||||
_triggerDialogueRegistry[item.dialogue.id] = { objectId: objectId, dialogueRef: item.dialogue };
|
||||
_triggerDialogueRegistry[item.dialogue.id] = Object.assign({ dialogueRef: item.dialogue }, owner);
|
||||
dialogueIds.push(item.dialogue.id);
|
||||
} else if (item.type === 'variable' && item.id) {
|
||||
} else if (item.id) {
|
||||
dialogueIds.push(item.id); // pas de "dialogueRef" (rien à éditer en ligne), juste pour l'id "actif"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Efface uniquement les entrées d'UN propriétaire (objet OU écran, voir
|
||||
// `key` = 'objectId'/'screenId') avant de les reconstruire — jamais tout
|
||||
// le registre, sans quoi recharger les déclencheurs d'objet effacerait
|
||||
// au passage les entrées des déclencheurs d'écran déjà enregistrées (et
|
||||
// inversement), les deux rechargements étant indépendants l'un de
|
||||
// l'autre.
|
||||
function _clearRegistryEntriesWithKey(key) {
|
||||
Object.keys(_triggerDialogueRegistry).forEach(function (id) {
|
||||
if (Object.prototype.hasOwnProperty.call(_triggerDialogueRegistry[id], key)) delete _triggerDialogueRegistry[id];
|
||||
});
|
||||
}
|
||||
|
||||
function _registerTriggerDialogues(list) {
|
||||
_triggerDialogueRegistry = {};
|
||||
_clearRegistryEntriesWithKey('objectId');
|
||||
var dialogueIds = [];
|
||||
list.forEach(function (t) {
|
||||
if (t.condition) {
|
||||
['si_vrai', 'si_faux'].forEach(function (branchKey) {
|
||||
var branch = t.condition[branchKey];
|
||||
if (branch) _registerChainDialogues(branch.chain, t.object_id, dialogueIds);
|
||||
if (branch) _registerChainDialogues(branch.chain, { objectId: t.object_id }, dialogueIds);
|
||||
});
|
||||
} else {
|
||||
_registerChainDialogues(t.chain, t.object_id, dialogueIds);
|
||||
_registerChainDialogues(t.chain, { objectId: t.object_id }, dialogueIds);
|
||||
}
|
||||
});
|
||||
return dialogueIds;
|
||||
@@ -408,7 +578,13 @@ function triggerDeleteTrigger(objectId) {
|
||||
function triggerInlineSaveDialogue(dialogueId) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
fetch('/game/' + GAME_SLUG + '/triggers/' + entry.objectId + '/dialogue', {
|
||||
// "screenId" (déclencheur d'écran, routes/screens/screen_triggers.py)
|
||||
// ou "objectId" (déclencheur d'objet, routes/triggers/triggers.py) —
|
||||
// l'UN OU L'AUTRE, jamais les deux (voir _registerChainDialogues).
|
||||
var url = entry.screenId != null
|
||||
? '/game/' + GAME_SLUG + '/screens/' + entry.screenId + '/triggers/dialogue'
|
||||
: '/game/' + GAME_SLUG + '/triggers/' + entry.objectId + '/dialogue';
|
||||
fetch(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dialogue_id: dialogueId, lines: entry.dialogueRef.lines }),
|
||||
}).then(function (r) {
|
||||
@@ -425,13 +601,25 @@ function triggerNextDefaultSpeaker(lastSpeaker) {
|
||||
return 'Joueur';
|
||||
}
|
||||
|
||||
// Réaffiche le BON panneau après une mutation en ligne — un dialogue
|
||||
// d'écran (entry.screenId) vit dans #screenTriggersSlot
|
||||
// (screenTriggerRenderSlot), un dialogue d'objet (entry.objectId) dans
|
||||
// #triggersSlot (triggerRenderSlot) : rafraîchir systématiquement
|
||||
// triggerRenderSlot() laissait le panneau "Écran" affiché figé après
|
||||
// Réplique/Question (bug corrigé — la sauvegarde serveur fonctionnait
|
||||
// bien, seul le rendu restait périmé).
|
||||
function _triggerRenderSlotForEntry(entry) {
|
||||
if (entry.screenId != null) screenTriggerRenderSlot();
|
||||
else triggerRenderSlot();
|
||||
}
|
||||
|
||||
function triggerInlineAddBubble(dialogueId) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
var lines = entry.dialogueRef.lines;
|
||||
var lastSpeaker = lines.length ? lines[lines.length - 1].speaker : 'PNJ';
|
||||
lines.push({ type: 'dialogue', speaker: triggerNextDefaultSpeaker(lastSpeaker), text: '' });
|
||||
triggerRenderSlot();
|
||||
_triggerRenderSlotForEntry(entry);
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
@@ -442,7 +630,7 @@ function triggerInlineAddQuestion(dialogueId) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines.push({ type: 'question', text: '', choices: ['', ''], correct_index: 0, reward_type: 'score', reward_amount: 0 });
|
||||
triggerRenderSlot();
|
||||
_triggerRenderSlotForEntry(entry);
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
@@ -466,7 +654,7 @@ function triggerInlineSetQuestionChoiceCount(dialogueId, index, value) {
|
||||
while (line.choices.length < count) line.choices.push('');
|
||||
while (line.choices.length > count) line.choices.pop();
|
||||
if (line.correct_index >= line.choices.length) line.correct_index = 0;
|
||||
triggerRenderSlot();
|
||||
_triggerRenderSlotForEntry(entry);
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
@@ -495,7 +683,16 @@ function triggerInlineSetBubbleSpeaker(dialogueId, index, speaker) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines[index].speaker = speaker;
|
||||
triggerRenderSlot();
|
||||
_triggerRenderSlotForEntry(entry);
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
function triggerInlineSetBubbleAudio(dialogueId, index, audioUrl) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
if (audioUrl) entry.dialogueRef.lines[index].audio_url = audioUrl;
|
||||
else delete entry.dialogueRef.lines[index].audio_url;
|
||||
_triggerRenderSlotForEntry(entry); // ré-affiche l'aperçu <audio> (apparaît/disparaît selon le choix)
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
@@ -510,7 +707,7 @@ function triggerInlineDeleteBubble(dialogueId, index) {
|
||||
var entry = _triggerDialogueRegistry[dialogueId];
|
||||
if (!entry) return;
|
||||
entry.dialogueRef.lines.splice(index, 1);
|
||||
triggerRenderSlot();
|
||||
_triggerRenderSlotForEntry(entry);
|
||||
triggerInlineSaveDialogue(dialogueId);
|
||||
}
|
||||
|
||||
@@ -580,8 +777,8 @@ function triggerSelectObject(objectId) {
|
||||
// des actions" — pas besoin de repasser par la modale de création, on
|
||||
// est déjà DANS ce déclencheur (voir triggerActionChainHtml). Réutilise
|
||||
// les étapes de l'assistant (static/js/scenes/collision-rules-editor.js) :
|
||||
// choix dialogue/variable, puis "Ensuite ?" (Marquer terminé + chaîner
|
||||
// encore), avant d'envoyer le tout via /append-action.
|
||||
// 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) {
|
||||
var modal = ensureTriggerModal();
|
||||
modal.classList.add('is-active');
|
||||
@@ -589,28 +786,17 @@ function triggerOpenAppendActionModal(objectId, afterId) {
|
||||
var body = document.getElementById('questTriggerModalBody');
|
||||
// État minimal partagé avec collision-rules-editor.js (mêmes étapes
|
||||
// réutilisées telles quelles, voir renderCollisionWizardChainStep/
|
||||
// renderCollisionWizardVariableStep — elles ne lisent que .bodyEl).
|
||||
// collisionWizardBuildLeafAction — elles ne lisent que .bodyEl).
|
||||
_collisionWizard = { bodyEl: body };
|
||||
body.innerHTML =
|
||||
'<p class="hint mb-2">Ajouter...</p>' +
|
||||
'<div class="collisionWizardChoices">' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="triggerAppendLeaf(' + objectId + ', \'' + afterId + '\', \'dialogue\')">' +
|
||||
'<span>Déclencher un dialogue</span>' +
|
||||
'</button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="triggerAppendLeaf(' + objectId + ', \'' + afterId + '\', \'variable\')">' +
|
||||
'<span>Modifier une variable</span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
var buttons = COLLISION_CHAIN_CHOICES.map(function (choice, i) {
|
||||
return '<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) {
|
||||
if (type === 'dialogue') {
|
||||
var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
|
||||
collisionWizardBuildLeafAction(type, function (leaf) {
|
||||
renderCollisionWizardChainStep(leaf, function (finalLeaf) { triggerSaveAppendedAction(objectId, afterId, finalLeaf); });
|
||||
return;
|
||||
}
|
||||
renderCollisionWizardVariableStep(function (action) {
|
||||
renderCollisionWizardChainStep(action, function (finalLeaf) { triggerSaveAppendedAction(objectId, afterId, finalLeaf); });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -629,6 +815,150 @@ function closeTriggerModal() {
|
||||
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) {
|
||||
var chain = [];
|
||||
var node = leafAction;
|
||||
while (node) { chain.push(node); node = node.then; }
|
||||
return chain;
|
||||
}
|
||||
|
||||
function screenTriggerNormalize(rule) {
|
||||
if (rule.action.type === 'condition') {
|
||||
var 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 };
|
||||
}
|
||||
|
||||
var _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) {
|
||||
var normalized = screenTriggerNormalize(rule);
|
||||
var 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() {
|
||||
var slot = document.getElementById('screenTriggersSlot');
|
||||
if (!slot) return;
|
||||
slot.innerHTML = _screenTriggersCache.length
|
||||
? _screenTriggersCache.map(function (rule, i) { return screenTriggerRuleHtml(rule, i); }).join('')
|
||||
: '<p class="hint">Aucun déclencheur d\'écran pour l\'instant.</p>';
|
||||
}
|
||||
|
||||
function _registerScreenTriggerDialogues(list) {
|
||||
_clearRegistryEntriesWithKey('screenId');
|
||||
var dialogueIds = [];
|
||||
list.forEach(function (rule) {
|
||||
var normalized = screenTriggerNormalize(rule);
|
||||
if (normalized.condition) {
|
||||
['si_vrai', 'si_faux'].forEach(function (branchKey) {
|
||||
var 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(function (r) { return r.json(); })
|
||||
.then(function (list) {
|
||||
_screenTriggersCache = list;
|
||||
_registerScreenTriggerDialogues(list);
|
||||
screenTriggerRenderSlot();
|
||||
});
|
||||
}
|
||||
|
||||
function screenTriggerDeleteAt(index) {
|
||||
fetch('/game/' + GAME_SLUG + '/screens/' + CURRENT_SCREEN_ID + '/triggers/' + index, { method: 'DELETE' })
|
||||
.then(function () { screenTriggerLoadAll(); });
|
||||
}
|
||||
|
||||
function openScreenAddTriggerModal() {
|
||||
var modal = ensureTriggerModal();
|
||||
modal.classList.add('is-active');
|
||||
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter un déclencheur d\'écran';
|
||||
var 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) {
|
||||
var modal = ensureTriggerModal();
|
||||
modal.classList.add('is-active');
|
||||
document.getElementById('questTriggerModalTitle').textContent = 'Ajouter une action';
|
||||
var body = document.getElementById('questTriggerModalBody');
|
||||
_collisionWizard = { bodyEl: body };
|
||||
var buttons = COLLISION_CHAIN_CHOICES.map(function (choice, i) {
|
||||
return '<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, function (leaf) {
|
||||
renderCollisionWizardChainStep(leaf, function (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: action }),
|
||||
}).then(function (r) {
|
||||
if (!r.ok) return r.json().then(function (json) { alert(json.error || "Impossible d'ajouter cette action."); });
|
||||
}).then(function () { 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
|
||||
@@ -639,10 +969,46 @@ function closeTriggerModal() {
|
||||
// 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.
|
||||
var _triggersTabMode = 'object';
|
||||
|
||||
function triggersTabSwitchMode(mode) {
|
||||
_triggersTabMode = mode;
|
||||
var objectItem = document.getElementById('triggersTabSidebarObject');
|
||||
var screenItem = document.getElementById('triggersTabSidebarScreen');
|
||||
var objectPane = document.getElementById('triggersObjectPane');
|
||||
var 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() {
|
||||
var body = document.getElementById('triggersTabPanelBody');
|
||||
if (!body) return;
|
||||
body.innerHTML = '<div class="questDialogueColumns">' + triggerColumnHtml() + '</div>';
|
||||
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>';
|
||||
triggersTabSwitchMode(_triggersTabMode); // conserve l'onglet actif d'un ré-affichage à l'autre (pjax, sélection d'objet...)
|
||||
triggerLoadAll();
|
||||
screenTriggerLoadAll();
|
||||
}
|
||||
initTriggersTab();
|
||||
|
||||
Reference in New Issue
Block a user