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
@@ -19,6 +19,7 @@ global.forgeApplyVariableActionOffline = global.forgeApplyVariableActionOffline
|
||||
const {
|
||||
forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick,
|
||||
forgeStartCollisionRuleControllers, forgeStopCollisionRuleControllers,
|
||||
forgeBindClicSurvolRules, forgeUnbindClicSurvolRules,
|
||||
} = require('../collision-rules-controller.js');
|
||||
|
||||
// La bulle "Appuie sur X" est ajoutée comme SŒUR de l'objet dans son
|
||||
@@ -179,6 +180,137 @@ test('forgeRunCollisionRuleAction — "dialogue" transmet son "then" à forgeSho
|
||||
assert.deepEqual(calls, [['d_1', 'Crokine', [], undefined, thenAction]]);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "surbrillance" bascule .forgeHighlight sur l\'objet CIBLÉ (object_id), pas forcément l\'objet du déclencheur', () => {
|
||||
const targetEl = fakeEl();
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => (sel.includes('"9"') ? targetEl : undefined) };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({ type: 'surbrillance', object_id: 9, valeur: 'on' }, fakeEl(), { id: 1, name: 'Interrupteur' });
|
||||
assert.equal(targetEl.classList.contains('forgeHighlight'), true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "visibilite" masque l\'objet CIBLÉ', () => {
|
||||
const targetEl = fakeEl();
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => targetEl };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({ type: 'visibilite', object_id: 4, valeur: 'masque' }, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.equal(targetEl.style.display, 'none');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "surbrillance" sans objet trouvé (référence orpheline) ne plante pas et exécute quand même "then"', () => {
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => undefined };
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'surbrillance', object_id: 999, valeur: 'on',
|
||||
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "son" joue l\'URL fournie via Audio, instantané (then exécuté tout de suite)', () => {
|
||||
const previousAudio = global.Audio;
|
||||
const played = [];
|
||||
global.Audio = function (url) {
|
||||
played.push(url);
|
||||
return { play: () => Promise.resolve() };
|
||||
};
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'son', asset_url: '/assets/1/bip.mp3',
|
||||
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.deepEqual(played, ['/assets/1/bip.mp3']);
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
} finally { global.Audio = previousAudio; }
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "video" attend forgeShowVideoOverlay avant d\'exécuter "then"', () => {
|
||||
const previousShow = global.forgeShowVideoOverlay;
|
||||
let capturedDone;
|
||||
global.forgeShowVideoOverlay = (url, mode, onDone) => { capturedDone = onDone; };
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'video', asset_url: '/assets/1/intro.mp4', mode: 'plein_ecran',
|
||||
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.equal(gameData.variables.score.value, '0'); // pas encore, la vidéo "joue"
|
||||
capturedDone();
|
||||
assert.equal(gameData.variables.score.value, '1'); // maintenant, la vidéo est "finie"
|
||||
} finally { global.forgeShowVideoOverlay = previousShow; }
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "attendre" suspend "then" jusqu\'à la fin du délai (data_value en secondes)', async () => {
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'attendre', data_value: '0',
|
||||
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.equal(gameData.variables.score.value, '0'); // pas encore, setTimeout pas encore résolu
|
||||
await new Promise(function (resolve) { setTimeout(resolve, 10); });
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "indication" affiche une bulle temporisée près de l\'objet CIBLÉ', () => {
|
||||
const parentEl = fakeParentEl();
|
||||
const targetEl = fakeEl(parentEl);
|
||||
const previousDoc = global.document;
|
||||
global.document = fakeDocument({ 4: targetEl });
|
||||
try {
|
||||
forgeRunCollisionRuleAction({ type: 'indication', object_id: 4, texte: 'Regarde ici !' }, fakeEl(), { id: 1, name: 'x' });
|
||||
const bubble = parentEl.querySelector('.collisionIndicationBubble[data-for-object-id="4"]');
|
||||
assert.equal(bubble.textContent, 'Regarde ici !');
|
||||
assert.equal(bubble.style.display, 'block');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeBindClicSurvolRules — "clic" exécute l\'action au clic, sans joueur ni tick', () => {
|
||||
const targetEl = { addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; }, removeEventListener() {} };
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => targetEl };
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
try {
|
||||
forgeBindClicSurvolRules({
|
||||
elements: [{
|
||||
id: 5, collision_rules: [{
|
||||
trigger: 'clic',
|
||||
action: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}],
|
||||
}],
|
||||
});
|
||||
targetEl._handlers.click();
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
} finally { global.document = previousDoc; forgeUnbindClicSurvolRules(); }
|
||||
});
|
||||
|
||||
test('forgeBindClicSurvolRules — "survol" se lie sur mouseenter, jamais sur click', () => {
|
||||
const targetEl = { addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; }, removeEventListener() {} };
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => targetEl };
|
||||
try {
|
||||
forgeBindClicSurvolRules({ elements: [{ id: 6, collision_rules: [{ trigger: 'survol', action: { type: 'dialogue' } }] }] });
|
||||
assert.equal(typeof targetEl._handlers.mouseenter, 'function');
|
||||
assert.equal(targetEl._handlers.click, undefined);
|
||||
} finally { global.document = previousDoc; forgeUnbindClicSurvolRules(); }
|
||||
});
|
||||
|
||||
test('forgeBindClicSurvolRules — un déclencheur "collision" n\'est jamais lié en écouteur DOM (reste géré par le tick)', () => {
|
||||
const targetEl = { addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; }, removeEventListener() {} };
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => targetEl };
|
||||
try {
|
||||
forgeBindClicSurvolRules({ elements: [{ id: 7, collision_rules: [{ trigger: 'collision', action: { type: 'dialogue' } }] }] });
|
||||
assert.equal(targetEl._handlers, undefined);
|
||||
} finally { global.document = previousDoc; forgeUnbindClicSurvolRules(); }
|
||||
});
|
||||
|
||||
test('tick — la collision entre le joueur et un objet ne redéclenche pas "dialogue" tant que le contact persiste', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } };
|
||||
const npc = { id: 2, kind: 'personnage', name: 'Crokine', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue', dialogue: { id: 'd_7', lines: [] } } }] };
|
||||
@@ -261,6 +393,14 @@ test('tick — "interagir" affiche la bulle "Appuie sur [touche]" pendant la col
|
||||
});
|
||||
|
||||
test('forgeStartCollisionRuleControllers — inactif sans joueur ou sans règle (aucun intervalle démarré)', () => {
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', personnage_role: 'joueur' }] }));
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue' } }] }] }));
|
||||
// "clic"/"survol" sont liés inconditionnellement (forgeBindClicSurvolRules,
|
||||
// voir plus bas) — jamais de joueur requis pour EUX, donc document.querySelector
|
||||
// est bien appelé même dans ces deux cas où le tick de collision, lui, ne
|
||||
// démarre pas ; un document minimal (aucun élément trouvé) suffit ici.
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => undefined };
|
||||
try {
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', personnage_role: 'joueur' }] }));
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue' } }] }] }));
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
@@ -103,6 +103,54 @@ test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les ré
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — une réplique avec audio_url joue la voix off dès son affichage', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
const previousAudio = global.Audio;
|
||||
const played = [];
|
||||
global.document = { querySelector: () => widget };
|
||||
global.Audio = function (url) {
|
||||
played.push(url);
|
||||
return { play: () => Promise.resolve(), pause: () => {} };
|
||||
};
|
||||
try {
|
||||
forgeShowDialogueBox('d_9', 'Marc', [{ speaker: 'Marc', text: 'Bonjour !', audio_url: '/assets/1/bonjour.mp3' }]);
|
||||
assert.deepEqual(played, ['/assets/1/bonjour.mp3']);
|
||||
} finally { global.document = previousDoc; global.Audio = previousAudio; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — une réplique SANS audio_url ne tente jamais de jouer un son', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
const previousAudio = global.Audio;
|
||||
let called = false;
|
||||
global.document = { querySelector: () => widget };
|
||||
global.Audio = function () { called = true; return { play: () => Promise.resolve(), pause: () => {} }; };
|
||||
try {
|
||||
forgeShowDialogueBox('d_9', 'Marc', [{ speaker: 'Marc', text: 'Bonjour !' }]);
|
||||
assert.equal(called, false);
|
||||
} finally { global.document = previousDoc; global.Audio = previousAudio; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — la voix off de la réplique précédente s\'arrête sur la suivante', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
const previousAudio = global.Audio;
|
||||
const paused = [];
|
||||
global.document = { querySelector: () => widget };
|
||||
global.Audio = function (url) {
|
||||
return { url: url, play: () => Promise.resolve(), pause: function () { paused.push(this.url); } };
|
||||
};
|
||||
try {
|
||||
forgeShowDialogueBox('d_9', 'Marc', [
|
||||
{ speaker: 'Marc', text: 'Bonjour !', audio_url: '/assets/1/a.mp3' },
|
||||
{ speaker: 'Marc', text: 'Ça va ?', audio_url: '/assets/1/b.mp3' },
|
||||
]);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
assert.deepEqual(paused, ['/assets/1/a.mp3']);
|
||||
} finally { global.document = previousDoc; global.Audio = previousAudio; }
|
||||
});
|
||||
|
||||
test('fin de dialogue SANS mark_completed : le dialogue n\'est jamais marqué terminé', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Déclencheurs D'ÉCRAN "à l'affichage" (voir
|
||||
// static/js/play/screen-triggers-controller.js, screens/rendering/
|
||||
// screen_triggers.py) — SANS objet requis, narration/cinématique.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
global.window = global.window || {};
|
||||
global.gameData = global.gameData || {};
|
||||
|
||||
const actionsMod = require('../actions.js');
|
||||
Object.assign(global, actionsMod);
|
||||
require('../personnage-controller.js');
|
||||
global.forgeApplyVariableActionOffline = global.forgeApplyVariableActionOffline || require('../offline/apply-actions.js').forgeApplyVariableActionOffline;
|
||||
global.forgeRunCollisionRuleAction = require('../collision-rules-controller.js').forgeRunCollisionRuleAction;
|
||||
|
||||
const { forgeRunScreenTriggers } = require('../screen-triggers-controller.js');
|
||||
|
||||
test('forgeRunScreenTriggers — ignore un écran sans screen_triggers', () => {
|
||||
assert.doesNotThrow(() => forgeRunScreenTriggers({ id: 1 }));
|
||||
assert.doesNotThrow(() => forgeRunScreenTriggers(null));
|
||||
});
|
||||
|
||||
test('forgeRunScreenTriggers — exécute une action "variable" pour un déclencheur "affichage"', () => {
|
||||
global.gameData = { variables: { intro_vue: { type: 'booleen', value: 'faux' } } };
|
||||
forgeRunScreenTriggers({
|
||||
id: 1,
|
||||
screen_triggers: [{
|
||||
trigger: 'affichage',
|
||||
action: { type: 'variable', target_variable: 'intro_vue', data_operation: 'definir_bool_vrai' },
|
||||
}],
|
||||
});
|
||||
assert.equal(gameData.variables.intro_vue.value, '1');
|
||||
});
|
||||
|
||||
test('forgeRunScreenTriggers — ignore tout déclencheur autre que "affichage" (jamais posé en pratique, garde-fou)', () => {
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
forgeRunScreenTriggers({
|
||||
id: 1,
|
||||
screen_triggers: [{
|
||||
trigger: 'collision',
|
||||
action: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}],
|
||||
});
|
||||
assert.equal(gameData.variables.score.value, '0');
|
||||
});
|
||||
@@ -112,6 +112,42 @@ function forgeHideCollisionInteractBubble(objectId, objectEl) {
|
||||
if (bubble) bubble.style.display = 'none';
|
||||
}
|
||||
|
||||
// ---------- Action "indication" — bulle de texte libre, temporisée ----------
|
||||
// Distincte de la bulle "Appuie sur X" ci-dessus (sa propre classe CSS,
|
||||
// sa propre minuterie) : ne dépend jamais du cycle "actif tant que la
|
||||
// collision dure" d'un déclencheur "interagir", peut être posée sur
|
||||
// N'IMPORTE QUEL objet de l'écran (voir action.object_id, pas
|
||||
// nécessairement l'objet qui porte le déclencheur).
|
||||
var FORGE_INDICATION_DEFAULT_DURATION_MS = 3000;
|
||||
var forgeIndicationTimeouts = {};
|
||||
|
||||
function forgeIndicationBubbleEl(objectId, parentEl) {
|
||||
var bubble = parentEl.querySelector('.collisionIndicationBubble[data-for-object-id="' + objectId + '"]');
|
||||
if (!bubble) {
|
||||
bubble = document.createElement('div');
|
||||
bubble.className = 'collisionIndicationBubble';
|
||||
bubble.setAttribute('data-for-object-id', objectId);
|
||||
parentEl.appendChild(bubble);
|
||||
}
|
||||
return bubble;
|
||||
}
|
||||
|
||||
function forgeShowIndicationBubble(objectId, texte, dureeMs) {
|
||||
var targetEl = document.querySelector('[data-object-id="' + objectId + '"]');
|
||||
var parentEl = targetEl && targetEl.parentElement;
|
||||
if (!targetEl || !parentEl || !texte) return;
|
||||
var objRect = forgeElementBoxRect(targetEl);
|
||||
var bubble = forgeIndicationBubbleEl(objectId, parentEl);
|
||||
bubble.textContent = texte;
|
||||
bubble.style.display = 'block';
|
||||
bubble.style.left = (objRect.left + objRect.width / 2) + 'px';
|
||||
bubble.style.top = objRect.top + 'px';
|
||||
if (forgeIndicationTimeouts[objectId]) clearTimeout(forgeIndicationTimeouts[objectId]);
|
||||
forgeIndicationTimeouts[objectId] = setTimeout(function () {
|
||||
bubble.style.display = 'none';
|
||||
}, dureeMs || FORGE_INDICATION_DEFAULT_DURATION_MS);
|
||||
}
|
||||
|
||||
// Exécute une action (top-level, sub_action d'un "interagir", ou branche
|
||||
// si_vrai/si_faux d'une "condition") — jamais appelée pour un "interagir"
|
||||
// lui-même à ce niveau, voir forgeCollisionRuleTick qui le gère à part
|
||||
@@ -156,6 +192,40 @@ function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
||||
});
|
||||
var branch = isTrue ? action.si_vrai : action.si_faux;
|
||||
if (branch) forgeRunCollisionRuleAction(branch, targetEl, obj);
|
||||
} else if (action.type === 'surbrillance' || action.type === 'visibilite') {
|
||||
// Cible N'IMPORTE QUEL objet de l'écran (action.object_id, pas
|
||||
// forcément celui qui porte le déclencheur) — réutilise TEL QUEL
|
||||
// applyObjectProperty (static/js/play/actions.js, équivalent scène 2D
|
||||
// d'applyElementProperty), plutôt que de dupliquer cette logique.
|
||||
var targetObjEl = document.querySelector('[data-object-id="' + action.object_id + '"]');
|
||||
if (targetObjEl) applyObjectProperty(targetObjEl, action.type, action.valeur);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'son') {
|
||||
// Effet sonore ponctuel, non bouclé — même comportement que l'action
|
||||
// "Jouer un son" du Flow (static/js/play/actions.js), instantané :
|
||||
// `then` s'exécute tout de suite, sans attendre la fin du son.
|
||||
if (action.asset_url) new Audio(action.asset_url).play().catch(function () {});
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'video') {
|
||||
// Bloque la suite de la chaîne "then" jusqu'à la fin de la vidéo (ou
|
||||
// "Passer") — voir static/js/play/video-overlay.js, même esprit
|
||||
// qu'un dialogue ci-dessus.
|
||||
forgeShowVideoOverlay(action.asset_url, action.mode, function () {
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
});
|
||||
} else if (action.type === 'indication') {
|
||||
forgeShowIndicationBubble(action.object_id, action.texte, action.duree_ms);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'attendre') {
|
||||
// Suspend la chaîne "then" pendant data_value SECONDES — même
|
||||
// vocabulaire que l'action "attendre" du Flow (static/js/play/
|
||||
// actions.js), utile ex. pour laisser un temps de lecture après une
|
||||
// narration à l'affichage de l'écran.
|
||||
var seconds = parseFloat(action.data_value);
|
||||
var ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
setTimeout(function () {
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
}, ms);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +244,11 @@ function forgeCollisionRuleTick(screenData) {
|
||||
|
||||
var anyInteractActive = false;
|
||||
rules.forEach(function (rule, ruleIndex) {
|
||||
// "clic"/"survol" sont liés à part (voir forgeBindClicSurvolRules,
|
||||
// écouteurs DOM directs) — jamais évalués ici, sans quoi ils
|
||||
// s'exécuteraient EN PLUS, à la proximité du joueur, en plus de
|
||||
// leur propre déclencheur.
|
||||
if (rule.trigger !== 'collision') return;
|
||||
var stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex);
|
||||
var isActive = forgeCollisionRuleTriggerActive(rule, objRect, playerRect);
|
||||
var wasActive = !!forgeCollisionRuleActiveState[stateKey];
|
||||
@@ -217,6 +292,36 @@ function forgeStartCollisionRuleInteractListener(screenData) {
|
||||
window.addEventListener('keydown', activeCollisionRuleInteractListener);
|
||||
}
|
||||
|
||||
// ---------- Déclencheurs "clic"/"survol" — écouteurs DOM directs ----------
|
||||
// Contrairement à "collision" (tick + proximité du joueur), ces deux
|
||||
// déclencheurs n'ont besoin NI d'un joueur NI d'un tick : un écouteur
|
||||
// posé une fois par objet suffit (voir screens/rendering/
|
||||
// collision_rules.py). Re-attachés à chaque affichage d'écran, comme le
|
||||
// reste de ce fichier.
|
||||
var activeClicSurvolListeners = [];
|
||||
|
||||
function forgeUnbindClicSurvolRules() {
|
||||
activeClicSurvolListeners.forEach(function (l) { l.el.removeEventListener(l.event, l.handler); });
|
||||
activeClicSurvolListeners = [];
|
||||
}
|
||||
|
||||
function forgeBindClicSurvolRules(screenData) {
|
||||
forgeUnbindClicSurvolRules();
|
||||
(screenData.elements || []).forEach(function (obj) {
|
||||
var rules = obj.collision_rules;
|
||||
if (!rules || !rules.length) return;
|
||||
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
||||
if (!targetEl) return;
|
||||
rules.forEach(function (rule) {
|
||||
if (rule.trigger !== 'clic' && rule.trigger !== 'survol') return;
|
||||
var eventName = rule.trigger === 'clic' ? 'click' : 'mouseenter';
|
||||
var handler = function () { forgeRunCollisionRuleAction(rule.action, targetEl, obj); };
|
||||
targetEl.addEventListener(eventName, handler);
|
||||
activeClicSurvolListeners.push({ el: targetEl, event: eventName, handler: handler });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function forgeStopCollisionRuleControllers() {
|
||||
if (activeCollisionRuleInterval) {
|
||||
clearInterval(activeCollisionRuleInterval);
|
||||
@@ -226,16 +331,20 @@ function forgeStopCollisionRuleControllers() {
|
||||
window.removeEventListener('keydown', activeCollisionRuleInteractListener);
|
||||
activeCollisionRuleInteractListener = null;
|
||||
}
|
||||
forgeUnbindClicSurvolRules();
|
||||
forgeCollisionRuleActiveState = {};
|
||||
}
|
||||
|
||||
// Même patron par écran que forgeStartPersonnageControllers — appelé
|
||||
// juste après lui (voir static/js/play/screens.js), n'a d'effet que si
|
||||
// l'écran a un joueur ET au moins un objet avec des règles.
|
||||
// juste après lui (voir static/js/play/screens.js). "clic"/"survol" sont
|
||||
// liés inconditionnellement (aucun joueur requis) ; le tick de collision
|
||||
// + l'écouteur "interagir" ne démarrent, eux, que si l'écran a un joueur
|
||||
// ET au moins un objet avec des règles.
|
||||
function forgeStartCollisionRuleControllers(screenData) {
|
||||
forgeStopCollisionRuleControllers();
|
||||
if (!screenData || screenData.kind !== 'jeu_2d') return;
|
||||
var elements = screenData.elements || [];
|
||||
forgeBindClicSurvolRules(screenData);
|
||||
var hasPlayer = elements.some(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
|
||||
var hasRules = elements.some(function (o) { return o.collision_rules && o.collision_rules.length; });
|
||||
if (!hasPlayer || !hasRules) return;
|
||||
@@ -249,6 +358,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick,
|
||||
forgeStartCollisionRuleControllers, forgeStopCollisionRuleControllers,
|
||||
forgeCollisionRuleStateKey, forgeElementBoxRect, forgeExpandRect,
|
||||
forgeBindClicSurvolRules, forgeUnbindClicSurvolRules, forgeShowIndicationBubble,
|
||||
};
|
||||
const conditionsMod = require('./conditions.js');
|
||||
global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox;
|
||||
@@ -259,4 +369,8 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8;
|
||||
const dialogueBoxMod = require('./dialogue-box-controller.js');
|
||||
global.forgeShowDialogueBox = global.forgeShowDialogueBox || dialogueBoxMod.forgeShowDialogueBox;
|
||||
const actionsMod = require('./actions.js');
|
||||
global.applyObjectProperty = global.applyObjectProperty || actionsMod.applyObjectProperty;
|
||||
const videoOverlayMod = require('./video-overlay.js');
|
||||
global.forgeShowVideoOverlay = global.forgeShowVideoOverlay || videoOverlayMod.forgeShowVideoOverlay;
|
||||
}
|
||||
|
||||
@@ -73,11 +73,30 @@ function forgeDialogueBoxFooterEl(widget) {
|
||||
return widget.querySelector('[data-dialogue-role="footer"]');
|
||||
}
|
||||
|
||||
// Voix off d'une réplique (demande explicite : "ajouter une réplique
|
||||
// audio", voir db/dialogue_lines.py::"dialogue".audio_url) — jouée dès
|
||||
// que la bulle s'affiche, arrêtée dès qu'une AUTRE ligne (avec ou sans
|
||||
// sa propre voix off) prend sa place, pour ne jamais superposer deux
|
||||
// voix off en même temps si le joueur avance vite dans la conversation.
|
||||
var forgeDialogueLineAudio = null;
|
||||
|
||||
function forgeStopDialogueLineAudio() {
|
||||
if (forgeDialogueLineAudio) {
|
||||
forgeDialogueLineAudio.pause();
|
||||
forgeDialogueLineAudio = null;
|
||||
}
|
||||
}
|
||||
|
||||
function forgeRenderDialogueBoxLine(widget, line) {
|
||||
var header = widget.querySelector('[data-dialogue-role="header"]');
|
||||
var body = widget.querySelector('[data-dialogue-role="body"]');
|
||||
if (header) header.textContent = line.speaker;
|
||||
if (body) body.textContent = line.text;
|
||||
forgeStopDialogueLineAudio();
|
||||
if (line.audio_url) {
|
||||
forgeDialogueLineAudio = new Audio(line.audio_url);
|
||||
forgeDialogueLineAudio.play().catch(function () {}); // lecture bloquée par le navigateur (pas d'interaction récente) : la bulle reste lisible sans voix off plutôt que de planter.
|
||||
}
|
||||
}
|
||||
|
||||
// Remet le pied de la boîte dans son état "dialogue" (un seul bouton
|
||||
@@ -257,6 +276,7 @@ function forgeAdvanceConversation(dialogueObjectId) {
|
||||
if (!state) return;
|
||||
state.index += 1;
|
||||
if (state.index >= state.lines.length) {
|
||||
forgeStopDialogueLineAudio();
|
||||
forgeHideQuizBoxOnCurrentScreen();
|
||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
if (state.markCompleted) forgeCompleteDialogue(state.dialogueId, state.objectName);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// ---------- Déclencheurs D'ÉCRAN — "à l'affichage" (SANS objet) ----------
|
||||
// Voir screens/rendering/screen_triggers.py, section "Déclencheurs de
|
||||
// l'écran" — narration/cinématique dès l'affichage, sans avoir besoin
|
||||
// d'un objet de scène. Réutilise TEL QUEL le même moteur d'exécution
|
||||
// d'action que collision-rules-controller.js (forgeRunCollisionRuleAction)
|
||||
// plutôt que d'en dupliquer un second : targetEl/obj passés vides, une
|
||||
// action qui a besoin d'un objet précis le porte elle-même (object_id,
|
||||
// voir surbrillance/visibilite/indication).
|
||||
function forgeRunScreenTriggers(screenData) {
|
||||
if (!screenData) return;
|
||||
var rules = screenData.screen_triggers;
|
||||
if (!rules || !rules.length) return;
|
||||
rules.forEach(function (rule) {
|
||||
if (rule.trigger !== 'affichage') return;
|
||||
forgeRunCollisionRuleAction(rule.action, null, {});
|
||||
});
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même garde que le reste de
|
||||
// static/js/play/.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeRunScreenTriggers };
|
||||
}
|
||||
@@ -57,6 +57,10 @@ function showScreen(id) {
|
||||
// static/js/play/collision-rules-controller.js) — démarré juste après
|
||||
// le contrôleur de personnage dont il dépend (position du joueur).
|
||||
forgeStartCollisionRuleControllers(screensData.find(function(s) { return s.id === id; }));
|
||||
// Déclencheurs D'ÉCRAN "à l'affichage" (voir screen-triggers-controller.js,
|
||||
// screens/rendering/screen_triggers.py) — narration/cinématique sans
|
||||
// objet requis, exécutée à chaque affichage de CET écran.
|
||||
forgeRunScreenTriggers(screensData.find(function(s) { return s.id === id; }));
|
||||
// Widget "🏆 Score" (voir static/js/play/dialogue-box-controller.js) :
|
||||
// synchronise le compteur affiché sur CET écran avec le total déjà
|
||||
// gagné — sans ça, un score posé sur un écran affiché APRÈS que le
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// ---------- Action "video" — lecture d'une vidéo de "Mes assets" ----------
|
||||
// Voir screens/rendering/collision_rules.py::"video" — bloque la suite de
|
||||
// la chaîne "then" jusqu'à la fin de la vidéo (ou un clic sur "Passer"),
|
||||
// même esprit qu'un dialogue (voir dialogue-box-controller.js). Un seul
|
||||
// overlay posé sur <body>, réutilisé pour chaque lecture (pas la peine
|
||||
// d'en recréer un par appel).
|
||||
|
||||
var forgeVideoOverlayEl = null;
|
||||
|
||||
function forgeVideoOverlayRoot() {
|
||||
if (forgeVideoOverlayEl) return forgeVideoOverlayEl;
|
||||
var el = document.createElement('div');
|
||||
el.className = 'forgeVideoOverlay';
|
||||
el.innerHTML =
|
||||
'<video class="forgeVideoOverlayPlayer" playsinline></video>' +
|
||||
'<button type="button" class="forgeVideoOverlaySkip">Passer ▶</button>';
|
||||
document.body.appendChild(el);
|
||||
forgeVideoOverlayEl = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
// `onDone` : appelé une fois (fin naturelle OU "Passer") — jamais les
|
||||
// deux, `finished` protège contre un double appel (l'événement 'ended'
|
||||
// pouvant encore se déclencher juste après un clic sur "Passer").
|
||||
function forgeShowVideoOverlay(url, mode, onDone) {
|
||||
if (!url) {
|
||||
if (onDone) onDone();
|
||||
return;
|
||||
}
|
||||
var root = forgeVideoOverlayRoot();
|
||||
root.className = 'forgeVideoOverlay ' + (mode === 'incrustation' ? 'forgeVideoOverlay-incrustation' : 'forgeVideoOverlay-pleinEcran');
|
||||
root.style.display = 'flex';
|
||||
var player = root.querySelector('.forgeVideoOverlayPlayer');
|
||||
var skipBtn = root.querySelector('.forgeVideoOverlaySkip');
|
||||
var finished = false;
|
||||
|
||||
function finish() {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
root.style.display = 'none';
|
||||
player.pause();
|
||||
player.removeAttribute('src');
|
||||
player.onended = null;
|
||||
skipBtn.onclick = null;
|
||||
if (onDone) onDone();
|
||||
}
|
||||
|
||||
player.src = url;
|
||||
player.onended = finish;
|
||||
skipBtn.onclick = finish;
|
||||
player.play().catch(function () {}); // lecture bloquée par le navigateur (pas d'interaction récente) : reste affichée, "Passer" fonctionne toujours.
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même garde que le reste de
|
||||
// static/js/play/.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeShowVideoOverlay };
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Assistant "déclencheur -> action" (voir static/js/scenes/
|
||||
// collision-rules-editor.js, static/js/triggers/trigger-editor.js) — ce
|
||||
// fichier n'a pas de module.exports (script de navigateur, pas un module
|
||||
// CommonJS) : on l'exécute via vm.runInThisContext (comme une vraie
|
||||
// balise <script>, les déclarations top-level deviennent globales) plutôt
|
||||
// que require(), qui les aurait gardées privées au module.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function loadAsScript(relativePath) {
|
||||
const fullPath = path.join(__dirname, relativePath);
|
||||
vm.runInThisContext(fs.readFileSync(fullPath, 'utf8'), { filename: fullPath });
|
||||
}
|
||||
|
||||
function makeEl() {
|
||||
const el = {
|
||||
_html: '', style: {}, value: '',
|
||||
classList: { add() {}, remove() {}, toggle() {} },
|
||||
children: [],
|
||||
appendChild(c) { this.children.push(c); },
|
||||
querySelector() { return null; },
|
||||
setAttribute() {}, getAttribute() { return null; },
|
||||
addEventListener() {}, removeEventListener() {},
|
||||
};
|
||||
Object.defineProperty(el, 'innerHTML', {
|
||||
get() { return this._html; },
|
||||
set(v) { this._html = v; },
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function setupGlobals() {
|
||||
global.window = global.window || {};
|
||||
global.GAME_SLUG = 'pytest_wizard';
|
||||
global.CURRENT_SCREEN_ID = 7;
|
||||
global.SCENE_OBJECT_NAMES_JSON = [];
|
||||
global.GLOBAL_VARIABLE_NAMES = [];
|
||||
global.DATA_OPERATION_LABELS_MAP = { incrementer: 'Incrémenter' };
|
||||
global.CONDITION_OPERATOR_LABELS_MAP = { egal: 'égal à' };
|
||||
global.SURBRILLANCE_LABELS_MAP = { on: 'Activer' };
|
||||
global.ELEMENT_VISIBILITY_LABELS_MAP = { visible: 'Visible' };
|
||||
global.VIDEO_MODE_LABELS_MAP = { plein_ecran: 'Plein écran' };
|
||||
global.USER_ASSETS_OPTIONS = [];
|
||||
|
||||
const elementsById = {};
|
||||
global.document = {
|
||||
createElement() { return makeEl(); },
|
||||
getElementById(id) { return elementsById[id] || (elementsById[id] = makeEl()); },
|
||||
querySelector() { return null; },
|
||||
body: makeEl(),
|
||||
};
|
||||
|
||||
const posted = [];
|
||||
global.fetch = function (url, opts) {
|
||||
if (opts && opts.method === 'POST') posted.push({ url, body: opts.body ? JSON.parse(opts.body) : null });
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve([]) });
|
||||
};
|
||||
return posted;
|
||||
}
|
||||
|
||||
// setupGlobals() DOIT être appelé avant de charger les scripts —
|
||||
// trigger-editor.js exécute initTriggersTab() à son propre chargement
|
||||
// (voir son commentaire "Point d'entrée"), qui a besoin de `document`.
|
||||
// Les scripts eux-mêmes ne sont chargés qu'UNE FOIS (déclarations
|
||||
// top-level -> globales, voir loadAsScript) ; chaque test rappelle
|
||||
// setupGlobals() seulement pour repartir d'un DOM/fetch factices propres,
|
||||
// jamais pour recharger les scripts.
|
||||
setupGlobals();
|
||||
loadAsScript('../collision-rules-editor.js');
|
||||
loadAsScript('../../triggers/trigger-editor.js');
|
||||
|
||||
test('assistant — une chaîne "attendre -> puis dialogue" sauvegarde bien LES DEUX, dans le bon ordre', () => {
|
||||
// Bug corrigé : "Rien de plus" renvoyait la DERNIÈRE feuille ajoutée
|
||||
// (_collisionWizard.chainLeaf, réassignée à chaque étape) plutôt que la
|
||||
// PREMIÈRE — une chaîne de 2 actions ou plus perdait silencieusement
|
||||
// tout sauf la toute dernière avant sauvegarde.
|
||||
const posted = setupGlobals();
|
||||
const body = document.getElementById('questTriggerModalBody');
|
||||
|
||||
global.openScreenTriggerWizard(7, body);
|
||||
global.collisionWizardChooseTrigger('affichage');
|
||||
global.collisionWizardChooseAction('attendre', false);
|
||||
document.getElementById('attendreWizSecondes').value = '2';
|
||||
global.collisionWizardAttendreNext();
|
||||
global.collisionWizardChainDone('dialogue');
|
||||
global.collisionWizardChainDone('none');
|
||||
|
||||
const saveCall = posted.find((p) => p.url === '/game/pytest_wizard/screens/7/triggers');
|
||||
assert.ok(saveCall, 'la règle aurait dû être postée');
|
||||
const action = saveCall.body.rule.action;
|
||||
assert.equal(action.type, 'attendre');
|
||||
assert.equal(action.data_value, '2');
|
||||
assert.equal(action.then.type, 'dialogue');
|
||||
});
|
||||
|
||||
test('assistant — une chaîne de 3 actions ("variable" -> "attendre" -> "dialogue") garde la tête et l\'ordre', () => {
|
||||
const posted = setupGlobals();
|
||||
const body = document.getElementById('questTriggerModalBody');
|
||||
|
||||
global.openScreenTriggerWizard(7, body);
|
||||
global.collisionWizardChooseTrigger('affichage');
|
||||
global.collisionWizardChooseAction('variable', false);
|
||||
document.getElementById('varWizVariable').value = 'score';
|
||||
document.getElementById('varWizOperation').value = 'incrementer';
|
||||
document.getElementById('varWizValue').value = '1';
|
||||
global.collisionWizardVariableNext();
|
||||
global.collisionWizardChainDone('attendre');
|
||||
document.getElementById('attendreWizSecondes').value = '1.5';
|
||||
global.collisionWizardAttendreNext();
|
||||
global.collisionWizardChainDone('dialogue');
|
||||
global.collisionWizardChainDone('none');
|
||||
|
||||
const saveCall = posted.find((p) => p.url === '/game/pytest_wizard/screens/7/triggers');
|
||||
const action = saveCall.body.rule.action;
|
||||
assert.equal(action.type, 'variable');
|
||||
assert.equal(action.then.type, 'attendre');
|
||||
assert.equal(action.then.data_value, '1.5');
|
||||
assert.equal(action.then.then.type, 'dialogue');
|
||||
});
|
||||
|
||||
test('assistant — une seule action ("Rien de plus" tout de suite) sauvegarde bien cette action seule', () => {
|
||||
const posted = setupGlobals();
|
||||
const body = document.getElementById('questTriggerModalBody');
|
||||
|
||||
global.openScreenTriggerWizard(7, body);
|
||||
global.collisionWizardChooseTrigger('affichage');
|
||||
global.collisionWizardChooseAction('dialogue', false);
|
||||
global.collisionWizardChainDone('none');
|
||||
|
||||
const saveCall = posted.find((p) => p.url === '/game/pytest_wizard/screens/7/triggers');
|
||||
const action = saveCall.body.rule.action;
|
||||
assert.equal(action.type, 'dialogue');
|
||||
assert.equal(action.then, undefined);
|
||||
});
|
||||
|
||||
test('assistant — l\'action "vidéo" ne propose QUE les fichiers vidéo (jamais les images/sons)', () => {
|
||||
// Bug corrigé : "toute une liste de png et mp3" pour un déclencheur
|
||||
// vidéo — renderCollisionWizardAssetStep listait TOUJOURS
|
||||
// USER_ASSETS_OPTIONS en entier, sans filtrer par kind.
|
||||
setupGlobals();
|
||||
global.USER_ASSETS_OPTIONS = [
|
||||
{ id: 1, url: '/assets/1/photo.png', name: 'photo.png', kind: 'image' },
|
||||
{ id: 2, url: '/assets/1/bip.mp3', name: 'bip.mp3', kind: 'audio' },
|
||||
{ id: 3, url: '/assets/1/clip.mp4', name: 'clip.mp4', kind: 'video' },
|
||||
];
|
||||
const body = document.getElementById('questTriggerModalBody');
|
||||
|
||||
global.openScreenTriggerWizard(7, body);
|
||||
global.collisionWizardChooseTrigger('affichage');
|
||||
global.collisionWizardChooseAction('video', false);
|
||||
|
||||
const html = body.innerHTML;
|
||||
assert.ok(html.includes('clip.mp4'), 'le fichier vidéo doit apparaître');
|
||||
assert.ok(!html.includes('photo.png'), 'une image ne doit jamais apparaître pour une action vidéo');
|
||||
assert.ok(!html.includes('bip.mp3'), 'un son ne doit jamais apparaître pour une action vidéo');
|
||||
});
|
||||
|
||||
test('assistant — l\'action "son" ne propose QUE les fichiers audio (jamais les images/vidéos)', () => {
|
||||
setupGlobals();
|
||||
global.USER_ASSETS_OPTIONS = [
|
||||
{ id: 1, url: '/assets/1/photo.png', name: 'photo.png', kind: 'image' },
|
||||
{ id: 2, url: '/assets/1/bip.mp3', name: 'bip.mp3', kind: 'audio' },
|
||||
{ id: 3, url: '/assets/1/clip.mp4', name: 'clip.mp4', kind: 'video' },
|
||||
];
|
||||
const body = document.getElementById('questTriggerModalBody');
|
||||
|
||||
global.openScreenTriggerWizard(7, body);
|
||||
global.collisionWizardChooseTrigger('affichage');
|
||||
global.collisionWizardChooseAction('son', false);
|
||||
|
||||
const html = body.innerHTML;
|
||||
assert.ok(html.includes('bip.mp3'), 'le fichier son doit apparaître');
|
||||
assert.ok(!html.includes('photo.png'), 'une image ne doit jamais apparaître pour une action son');
|
||||
assert.ok(!html.includes('clip.mp4'), 'une vidéo ne doit jamais apparaître pour une action son');
|
||||
});
|
||||
|
||||
test('triggerActionChainHtml — un bouton "+ Ajouter une action" apparaît après CHAQUE bloc, pas seulement le dernier', () => {
|
||||
// Demande explicite : "pouvoir ajouter n'importe quelle action à
|
||||
// n'importe quel niveau du schéma" — chaque bloc doit pouvoir insérer
|
||||
// juste après lui, pas seulement le tout dernier de la chaîne.
|
||||
setupGlobals();
|
||||
const chain = [
|
||||
{ type: 'variable', id: 'v_1', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
{ type: 'attendre', id: 'a_1', data_value: '2' },
|
||||
{ type: 'dialogue', dialogue: { id: 'd_1', lines: [] } },
|
||||
];
|
||||
const html = global.triggerActionChainHtml(chain, 'screen', null);
|
||||
const matches = html.match(/\+ Ajouter une action/g) || [];
|
||||
assert.equal(matches.length, 3);
|
||||
assert.ok(html.includes("screenTriggerOpenAppendActionModal('v_1')"));
|
||||
assert.ok(html.includes("screenTriggerOpenAppendActionModal('a_1')"));
|
||||
assert.ok(html.includes("screenTriggerOpenAppendActionModal('d_1')"));
|
||||
});
|
||||
@@ -11,10 +11,23 @@
|
||||
|
||||
var COLLISION_ACTION_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',
|
||||
interagir: 'Interagir avec une touche', condition: 'Si une condition est vraie',
|
||||
};
|
||||
var COLLISION_WIZARD_ACTION_TYPES = ['dialogue', 'variable', 'interagir', 'condition'];
|
||||
var COLLISION_TRIGGER_LABELS = { collision: 'À la collision' };
|
||||
// Ordre d'affichage — "interagir"/"condition" en dernier (comportements
|
||||
// spéciaux), le reste (les FEUILLES, voir screens/rendering/
|
||||
// collision_rules.py::LEAF_ACTION_TYPES) dans le même ordre que
|
||||
// COLLISION_CHAIN_CHOICES ci-dessous, pour rester cohérent d'un écran à
|
||||
// l'autre de l'assistant.
|
||||
var COLLISION_WIZARD_ACTION_TYPES = ['dialogue', 'variable', 'surbrillance', 'visibilite', 'son', 'video', 'indication', 'attendre', 'interagir', 'condition'];
|
||||
// FEUILLES chaînables via "then" — utilisées à la fois pour l'étape
|
||||
// "Ensuite ?" (renderCollisionWizardChainStep) et les branches Vrai/Faux
|
||||
// d'une condition (renderCollisionWizardBranchStep) : MÊME liste des deux
|
||||
// côtés, voir screens/rendering/collision_rules.py::LEAF_ACTION_TYPES.
|
||||
var COLLISION_CHAIN_CHOICES = ['dialogue', 'variable', 'surbrillance', 'visibilite', 'son', 'video', 'indication', 'attendre'];
|
||||
var COLLISION_TRIGGER_LABELS = { collision: 'À la collision', clic: 'Au clic', survol: 'Au survol', affichage: 'À l\'affichage de l\'écran' };
|
||||
// Opérations qui n'ont besoin d'AUCUNE valeur (voir screens/labels/
|
||||
// data_operations.py::DATA_OPERATIONS) — le champ valeur de l'étape
|
||||
// "Modifier une variable" (renderCollisionWizardVariableStep) se masque
|
||||
@@ -60,7 +73,17 @@ var _collisionWizard = null;
|
||||
// Appelée après avoir choisi l'objet dans la modale d'ajout de déclencheur
|
||||
// (voir triggerSelectObject, trigger-editor.js).
|
||||
function openTriggerWizard(objectId, bodyEl) {
|
||||
_collisionWizard = { objectId: objectId, trigger: null, bodyEl: bodyEl };
|
||||
_collisionWizard = { target: 'object', objectId: objectId, trigger: null, bodyEl: bodyEl };
|
||||
renderCollisionWizardTriggerStep();
|
||||
}
|
||||
|
||||
// Section "Déclencheurs de l'écran" (voir static/js/triggers/
|
||||
// trigger-editor.js::openScreenAddTriggerModal) — même assistant, mais
|
||||
// SANS objet propriétaire (voir screens/rendering/screen_triggers.py) :
|
||||
// un seul déclencheur possible ("affichage"), donc l'étape 1 n'a qu'un
|
||||
// choix, gardée pour rester cohérente avec le parcours objet.
|
||||
function openScreenTriggerWizard(screenId, bodyEl) {
|
||||
_collisionWizard = { target: 'screen', screenId: screenId, trigger: null, bodyEl: bodyEl };
|
||||
renderCollisionWizardTriggerStep();
|
||||
}
|
||||
|
||||
@@ -68,18 +91,20 @@ function collisionWizardBodyEl() {
|
||||
return _collisionWizard.bodyEl;
|
||||
}
|
||||
|
||||
// Étape 1 — déclencheur (un seul choix pour l'instant, "collision") : la
|
||||
// carte à choix reste affichée telle quelle (plutôt que sautée
|
||||
// automatiquement) pour rester extensible à de futurs types sans rien
|
||||
// changer à la structure de l'assistant.
|
||||
// Étape 1 — déclencheur : "collision"/"clic"/"survol" pour un objet,
|
||||
// "affichage" (seul choix) pour un déclencheur d'écran — la carte à choix
|
||||
// reste affichée telle quelle même à un seul choix (plutôt que sautée
|
||||
// automatiquement) pour rester cohérente d'un parcours à l'autre.
|
||||
function renderCollisionWizardTriggerStep() {
|
||||
var keys = _collisionWizard.target === 'screen' ? ['affichage'] : ['collision', 'clic', 'survol'];
|
||||
var buttons = keys.map(function (key, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardChooseTrigger(\'' + key + '\')">' +
|
||||
'<span>' + COLLISION_TRIGGER_LABELS[key] + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Cette règle se déclenche...</p>' +
|
||||
'<div class="collisionWizardChoices">' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChooseTrigger(\'collision\')">' +
|
||||
'<span>' + COLLISION_TRIGGER_LABELS.collision + '</span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
'<div class="collisionWizardChoices">' + buttons + '</div>';
|
||||
}
|
||||
|
||||
function collisionWizardChooseTrigger(trigger) {
|
||||
@@ -90,9 +115,13 @@ function collisionWizardChooseTrigger(trigger) {
|
||||
// Étape 2 (et, pour "interagir", étape 3 en second niveau — `nested`
|
||||
// distingue les deux, "interagir" n'étant proposé qu'au premier niveau,
|
||||
// voir screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH) :
|
||||
// quelle action exécuter.
|
||||
// quelle action exécuter. "interagir" n'a de sens QUE pour "collision"
|
||||
// (bulle "Appuie sur X" tant que le contact dure) — jamais pour "clic"/
|
||||
// "survol"/"affichage", qui n'ont pas cette notion de contact prolongé.
|
||||
function renderCollisionWizardActionStep(nested) {
|
||||
var types = nested ? COLLISION_WIZARD_ACTION_TYPES.filter(function (t) { return t !== 'interagir'; }) : COLLISION_WIZARD_ACTION_TYPES;
|
||||
var types = (nested || _collisionWizard.trigger !== 'collision')
|
||||
? COLLISION_WIZARD_ACTION_TYPES.filter(function (t) { return t !== 'interagir'; })
|
||||
: COLLISION_WIZARD_ACTION_TYPES;
|
||||
var buttons = types.map(function (type, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardChooseAction(\'' + type + '\', ' + (nested ? 'true' : 'false') + ')">' +
|
||||
'<span>' + COLLISION_ACTION_LABELS[type] + '</span>' +
|
||||
@@ -121,46 +150,250 @@ function collisionWizardFinalizeAction(action, nested) {
|
||||
collisionWizardSaveRule(action);
|
||||
}
|
||||
|
||||
// Enregistre via la route /game/<slug>/triggers (routes/triggers/triggers.py)
|
||||
// — même contrat "liste ENTIÈRE" que l'ancien éditeur de collision
|
||||
// (recharge les règles existantes de l'objet, y ajoute la nouvelle,
|
||||
// sauvegarde tout), géré côté serveur : le client ne poste que la
|
||||
// nouvelle règle + l'id d'objet.
|
||||
// Enregistre via /game/<slug>/triggers (objet, routes/triggers/triggers.py)
|
||||
// ou /game/<slug>/screens/<id>/triggers (écran, routes/screens/
|
||||
// screen_triggers.py) selon _collisionWizard.target — même contrat
|
||||
// "liste ENTIÈRE" des deux côtés (recharge les règles existantes, y
|
||||
// ajoute la nouvelle, sauvegarde tout), géré côté serveur.
|
||||
function collisionWizardSaveRule(action) {
|
||||
var rule = { trigger: _collisionWizard.trigger, action: action };
|
||||
closeTriggerModal();
|
||||
fetch('/game/' + GAME_SLUG + '/triggers', {
|
||||
var isScreen = _collisionWizard.target === 'screen';
|
||||
var url = isScreen
|
||||
? '/game/' + GAME_SLUG + '/screens/' + _collisionWizard.screenId + '/triggers'
|
||||
: '/game/' + GAME_SLUG + '/triggers';
|
||||
var body = isScreen ? { rule: rule } : { object_id: _collisionWizard.objectId, rule: rule };
|
||||
fetch(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ object_id: _collisionWizard.objectId, rule: rule }),
|
||||
}).then(function () { triggerLoadAll(); });
|
||||
body: JSON.stringify(body),
|
||||
}).then(function () { isScreen ? screenTriggerLoadAll() : triggerLoadAll(); });
|
||||
}
|
||||
|
||||
// "dialogue"/"variable" (des FEUILLES, voir screens/rendering/
|
||||
// collision_rules.py) enchaînent sur "Ensuite ?" (voir
|
||||
// renderCollisionWizardChainStep — Marquer terminé + chaîner une autre
|
||||
// action, demande explicite : "pouvoir modifier un déclencheur pour lui
|
||||
// ajouter des actions") ; "interagir" enchaîne sur une carte de plus
|
||||
// (voir collisionWizardFinalizeAction) ; "condition" enchaîne sur son
|
||||
// propre mini-formulaire (voir renderCollisionWizardConditionStep).
|
||||
// Construit une FEUILLE du type demandé — passe par ses éventuelles
|
||||
// étapes intermédiaires (objet ciblé, valeur, fichier "Mes assets"...)
|
||||
// puis appelle `onBuilt(action)`. PARTAGÉ entre le premier choix
|
||||
// d'action (collisionWizardChooseAction), le choix "Ensuite ?"
|
||||
// (collisionWizardChainDone), les branches Vrai/Faux d'une condition
|
||||
// (collisionWizardChooseBranch) et "+ Ajouter une action" sur un
|
||||
// déclencheur déjà posé (trigger-editor.js::triggerAppendLeaf/
|
||||
// screenTriggerAppendLeaf) — ces quatre points d'entrée ont besoin
|
||||
// EXACTEMENT du même parcours pour chaque type de feuille.
|
||||
function collisionWizardBuildLeafAction(type, onBuilt) {
|
||||
if (type === 'dialogue') {
|
||||
onBuilt({ type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } });
|
||||
return;
|
||||
}
|
||||
if (type === 'variable') {
|
||||
renderCollisionWizardVariableStep(onBuilt);
|
||||
return;
|
||||
}
|
||||
if (type === 'surbrillance' || type === 'visibilite') {
|
||||
renderCollisionWizardObjectTargetStep(function (objectId) {
|
||||
renderCollisionWizardValeurStep(type, function (valeur) {
|
||||
onBuilt({ type: type, id: collisionGenerateActionId(), object_id: objectId, valeur: valeur });
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (type === 'son') {
|
||||
renderCollisionWizardAssetStep(function (assetUrl) {
|
||||
onBuilt({ type: 'son', id: collisionGenerateActionId(), asset_url: assetUrl });
|
||||
}, 'audio');
|
||||
return;
|
||||
}
|
||||
if (type === 'video') {
|
||||
renderCollisionWizardAssetStep(function (assetUrl) {
|
||||
renderCollisionWizardVideoModeStep(function (mode) {
|
||||
onBuilt({ type: 'video', id: collisionGenerateActionId(), asset_url: assetUrl, mode: mode });
|
||||
});
|
||||
}, 'video');
|
||||
return;
|
||||
}
|
||||
if (type === 'indication') {
|
||||
renderCollisionWizardObjectTargetStep(function (objectId) {
|
||||
_collisionWizard.pendingIndicationObjectId = objectId;
|
||||
renderCollisionWizardIndicationStep(function (action) { onBuilt(action); });
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 'attendre'
|
||||
renderCollisionWizardAttendreStep(function (secondes) {
|
||||
onBuilt({ type: 'attendre', id: collisionGenerateActionId(), data_value: secondes });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Étape "attendre" (nombre de secondes) ----------
|
||||
function renderCollisionWizardAttendreStep(onChoose) {
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Attendre combien de secondes avant de continuer ?</p>' +
|
||||
'<input type="number" id="attendreWizSecondes" min="0" step="0.5" placeholder="2" style="width:100px; margin-bottom:10px;">' +
|
||||
'<button type="button" class="button primary" onclick="collisionWizardAttendreNext()">Continuer</button>';
|
||||
_collisionWizard.onAttendreChosen = onChoose;
|
||||
}
|
||||
|
||||
function collisionWizardAttendreNext() {
|
||||
var raw = document.getElementById('attendreWizSecondes').value;
|
||||
var secondes = String(Math.max(0, parseFloat(raw) || 0));
|
||||
var onChoose = _collisionWizard.onAttendreChosen;
|
||||
_collisionWizard.onAttendreChosen = null;
|
||||
onChoose(secondes);
|
||||
}
|
||||
|
||||
// ---------- Étape "Sur quel objet ?" (surbrillance/visibilite/indication) ----------
|
||||
// Réutilise le MÊME catalogue que le choix d'objet propriétaire du
|
||||
// déclencheur (voir triggerRenderObjectStep, trigger-editor.js) : ces
|
||||
// trois actions peuvent cibler N'IMPORTE QUEL objet éligible du jeu, pas
|
||||
// forcément celui qui porte la règle (ex. le joueur touche un
|
||||
// interrupteur -> une indication apparaît près d'une porte ailleurs).
|
||||
function renderCollisionWizardObjectTargetStep(onChoose) {
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Sur quel objet ?</p>' +
|
||||
'<div id="collisionWizardObjectTargetGrid" class="collisionWizardChoices">Chargement…</div>';
|
||||
fetch('/game/' + GAME_SLUG + '/triggers/objects-catalog')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (objects) {
|
||||
var grid = document.getElementById('collisionWizardObjectTargetGrid');
|
||||
if (!grid) return;
|
||||
if (!objects.length) { grid.innerHTML = '<p class="hint">Aucun objet éligible sur ce jeu.</p>'; return; }
|
||||
grid.innerHTML = objects.map(function (o, i) {
|
||||
return '<button type="button" class="collisionWizardChoice questTriggerObjectChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardObjectTargetChosen(' + o.id + ')">' +
|
||||
'<div class="questTriggerObjectThumb">' + collisionRuleThumbHtml(o) + '</div>' +
|
||||
'<span>' + (o.name || '').replace(/</g, '<') + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
_collisionWizard.onObjectTargetChosen = onChoose;
|
||||
});
|
||||
}
|
||||
|
||||
function collisionWizardObjectTargetChosen(objectId) {
|
||||
var onChoose = _collisionWizard.onObjectTargetChosen;
|
||||
_collisionWizard.onObjectTargetChosen = null;
|
||||
onChoose(objectId);
|
||||
}
|
||||
|
||||
// ---------- Étape "valeur" (surbrillance/visibilite) ----------
|
||||
// SURBRILLANCE_LABELS_MAP/ELEMENT_VISIBILITY_LABELS_MAP posés par
|
||||
// templates/scene_edit.html (voir screens/labels/surbrillance_values.py
|
||||
// et screens/labels/element_visibility.py — mêmes valeurs que le moteur).
|
||||
function renderCollisionWizardValeurStep(actionType, onChoose) {
|
||||
var labelsMap = actionType === 'surbrillance' ? SURBRILLANCE_LABELS_MAP : ELEMENT_VISIBILITY_LABELS_MAP;
|
||||
var keys = Object.keys(labelsMap);
|
||||
var buttons = keys.map(function (key, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardValeurChosen(\'' + key + '\')">' +
|
||||
'<span>' + labelsMap[key] + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">' + (actionType === 'surbrillance' ? 'Surbrillance :' : 'Visibilité :') + '</p>' +
|
||||
'<div class="collisionWizardChoices">' + buttons + '</div>';
|
||||
_collisionWizard.onValeurChosen = onChoose;
|
||||
}
|
||||
|
||||
function collisionWizardValeurChosen(valeur) {
|
||||
var onChoose = _collisionWizard.onValeurChosen;
|
||||
_collisionWizard.onValeurChosen = null;
|
||||
onChoose(valeur);
|
||||
}
|
||||
|
||||
// ---------- Étape "quel fichier ?" (son/vidéo) ----------
|
||||
// USER_ASSETS_OPTIONS posé par templates/scene_edit.html ([{id, url,
|
||||
// name, kind}], voir auth/user_asset_kind.py) — le créateur doit d'abord
|
||||
// importer un fichier dans "Mes assets" (panneau de gauche) avant de
|
||||
// pouvoir le choisir ici. `kind` ("audio"/"video", demande explicite : "la
|
||||
// vidéo devra être en mp4 ou webm") filtre la liste au bon type — sans
|
||||
// ça, l'action "vidéo" proposait AUSSI les images et les sons (et
|
||||
// inversement pour "son"), un fichier du mauvais type ne pouvant de toute
|
||||
// façon jamais être lu par <video>/<audio> côté jeu.
|
||||
function renderCollisionWizardAssetStep(onChoose, kind) {
|
||||
var assets = USER_ASSETS_OPTIONS.filter(function (a) { return a.kind === kind; });
|
||||
if (!assets.length) {
|
||||
var label = kind === 'video' ? 'vidéo (mp4/webm)' : 'son';
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Aucun fichier ' + label + ' dans "Mes assets" pour l\'instant — importe-en un depuis le panneau de gauche (section "Mes assets"), puis recommence.</p>';
|
||||
return;
|
||||
}
|
||||
var options = assets.map(function (a) {
|
||||
return '<option value="' + a.url + '">' + (a.name || '').replace(/</g, '<') + '</option>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Quel fichier (voir "Mes assets") ?</p>' +
|
||||
'<select id="collisionWizardAssetSelect" style="width:100%; margin-bottom:10px;">' + options + '</select>' +
|
||||
'<button type="button" class="button primary" onclick="collisionWizardAssetNext()">Continuer</button>';
|
||||
_collisionWizard.onAssetChosen = onChoose;
|
||||
}
|
||||
|
||||
function collisionWizardAssetNext() {
|
||||
var url = document.getElementById('collisionWizardAssetSelect').value;
|
||||
var onChoose = _collisionWizard.onAssetChosen;
|
||||
_collisionWizard.onAssetChosen = null;
|
||||
onChoose(url);
|
||||
}
|
||||
|
||||
// ---------- Étape "comment l'afficher ?" (vidéo) ----------
|
||||
function renderCollisionWizardVideoModeStep(onChoose) {
|
||||
var keys = Object.keys(VIDEO_MODE_LABELS_MAP);
|
||||
var buttons = keys.map(function (key, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardVideoModeChosen(\'' + key + '\')">' +
|
||||
'<span>' + VIDEO_MODE_LABELS_MAP[key] + '</span>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Comment l\'afficher ?</p>' +
|
||||
'<div class="collisionWizardChoices">' + buttons + '</div>';
|
||||
_collisionWizard.onVideoModeChosen = onChoose;
|
||||
}
|
||||
|
||||
function collisionWizardVideoModeChosen(mode) {
|
||||
var onChoose = _collisionWizard.onVideoModeChosen;
|
||||
_collisionWizard.onVideoModeChosen = null;
|
||||
onChoose(mode);
|
||||
}
|
||||
|
||||
// ---------- Étape "indication" (texte + durée) ----------
|
||||
// L'objet ciblé a déjà été choisi juste avant (voir
|
||||
// collisionWizardBuildLeafAction) — mémorisé sur le wizard le temps de
|
||||
// cette étape plutôt que reporté en paramètre partout.
|
||||
function renderCollisionWizardIndicationStep(onChoose) {
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Texte de la bulle :</p>' +
|
||||
'<input type="text" id="indicationWizText" placeholder="Ex. Clique ici pour continuer" style="width:100%; margin-bottom:8px;">' +
|
||||
'<label class="hint" style="display:block; margin-bottom:10px;">Durée d\'affichage en ms (optionnel, 3000 par défaut) : ' +
|
||||
'<input type="number" id="indicationWizDuree" min="500" step="500" placeholder="3000" style="width:100px;"></label>' +
|
||||
'<button type="button" class="button primary" onclick="collisionWizardIndicationNext()">Continuer</button>';
|
||||
_collisionWizard.onIndicationChosen = onChoose;
|
||||
}
|
||||
|
||||
function collisionWizardIndicationNext() {
|
||||
var texte = document.getElementById('indicationWizText').value;
|
||||
var dureeRaw = document.getElementById('indicationWizDuree').value;
|
||||
var action = { type: 'indication', id: collisionGenerateActionId(), object_id: _collisionWizard.pendingIndicationObjectId, texte: texte };
|
||||
if (dureeRaw) action.duree_ms = parseInt(dureeRaw, 10);
|
||||
_collisionWizard.pendingIndicationObjectId = null;
|
||||
var onChoose = _collisionWizard.onIndicationChosen;
|
||||
_collisionWizard.onIndicationChosen = null;
|
||||
onChoose(action);
|
||||
}
|
||||
|
||||
// "dialogue"/"variable"/"surbrillance"/"visibilite"/"son"/"video"/
|
||||
// "indication" (des FEUILLES, voir screens/rendering/collision_rules.py)
|
||||
// enchaînent sur "Ensuite ?" (voir renderCollisionWizardChainStep —
|
||||
// Marquer terminé + chaîner une autre action, demande explicite :
|
||||
// "pouvoir modifier un déclencheur pour lui ajouter des actions") ;
|
||||
// "interagir" enchaîne sur une carte de plus (voir
|
||||
// collisionWizardFinalizeAction) ; "condition" enchaîne sur son propre
|
||||
// mini-formulaire (voir renderCollisionWizardConditionStep).
|
||||
function collisionWizardChooseAction(type, nested) {
|
||||
if (type === 'interagir') {
|
||||
collisionWizardFinalizeAction({ type: type }, nested);
|
||||
return;
|
||||
}
|
||||
if (type === 'dialogue') {
|
||||
var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
|
||||
renderCollisionWizardChainStep(leaf, function (finalLeaf) { collisionWizardFinalizeAction(finalLeaf, nested); });
|
||||
return;
|
||||
}
|
||||
if (type === 'variable') {
|
||||
renderCollisionWizardVariableStep(function (action) {
|
||||
renderCollisionWizardChainStep(action, function (finalLeaf) { collisionWizardFinalizeAction(finalLeaf, nested); });
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (type === 'condition') {
|
||||
renderCollisionWizardConditionStep(nested);
|
||||
return;
|
||||
}
|
||||
collisionWizardBuildLeafAction(type, function (leaf) {
|
||||
renderCollisionWizardChainStep(leaf, function (finalLeaf) { collisionWizardFinalizeAction(finalLeaf, nested); });
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Étape "Modifier une variable" ----------
|
||||
@@ -216,47 +449,53 @@ function collisionWizardVariableNext() {
|
||||
// chaîner une autre action à la suite (voir screens/rendering/
|
||||
// collision_rules.py::"then", demande explicite "pouvoir modifier un
|
||||
// déclencheur pour lui ajouter des actions") — jamais bloqué à une seule
|
||||
// action par déclencheur. `onDone(leafAction)` reçoit la PREMIÈRE
|
||||
// feuille de la chaîne (les suivantes sont attachées via `.then`).
|
||||
function renderCollisionWizardChainStep(leafAction, onDone) {
|
||||
// action par déclencheur. `onDone(headAction)` reçoit la PREMIÈRE feuille
|
||||
// de la chaîne (les suivantes sont attachées via `.then`) — `headAction`
|
||||
// (3e paramètre, optionnel) est la TÊTE de la chaîne, transmise SANS
|
||||
// changement à travers chaque ré-appel récursif : `leafAction`, lui,
|
||||
// change à chaque étape (c'est la feuille COURANTE qu'on est en train
|
||||
// d'éditer/étendre). Bug corrigé : sans `headAction` distincte, "Rien de
|
||||
// plus" renvoyait `_collisionWizard.chainLeaf` — qui pointe vers la
|
||||
// DERNIÈRE feuille ajoutée après un premier chaînage, pas la première —
|
||||
// une chaîne "attendre -> puis dialogue" ne sauvegardait donc QUE le
|
||||
// dialogue, "attendre" disparaissait silencieusement.
|
||||
function renderCollisionWizardChainStep(leafAction, onDone, headAction) {
|
||||
headAction = headAction || leafAction;
|
||||
var completionHtml = leafAction.type === 'dialogue'
|
||||
? '<label style="display:flex; align-items:center; gap:6px; font-size:13px; margin-bottom:10px;">' +
|
||||
'<input type="checkbox" id="condWizMarkCompleted"' + (leafAction.mark_completed ? ' checked' : '') + '> Marquer ce dialogue comme terminé une fois joué' +
|
||||
'</label>'
|
||||
: '';
|
||||
var choiceButtons = COLLISION_CHAIN_CHOICES.map(function (choice, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + ((i + 1) * 40) + 'ms" onclick="collisionWizardChainDone(\'' + choice + '\')"><span>' + COLLISION_ACTION_LABELS[choice] + '</span></button>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
completionHtml +
|
||||
'<p class="hint mb-2">Ensuite ?</p>' +
|
||||
'<div class="collisionWizardChoices">' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChainDone(\'none\')"><span>Rien de plus</span></button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="collisionWizardChainDone(\'dialogue\')"><span>Déclencher un dialogue</span></button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:80ms" onclick="collisionWizardChainDone(\'variable\')"><span>Modifier une variable</span></button>' +
|
||||
choiceButtons +
|
||||
'</div>';
|
||||
_collisionWizard.chainLeaf = leafAction;
|
||||
_collisionWizard.chainHead = headAction;
|
||||
_collisionWizard.onChainDone = onDone;
|
||||
}
|
||||
|
||||
function collisionWizardChainDone(choice) {
|
||||
var leafAction = _collisionWizard.chainLeaf;
|
||||
var headAction = _collisionWizard.chainHead;
|
||||
var onDone = _collisionWizard.onChainDone;
|
||||
if (leafAction.type === 'dialogue') {
|
||||
if (document.getElementById('condWizMarkCompleted').checked) leafAction.mark_completed = true;
|
||||
else delete leafAction.mark_completed;
|
||||
}
|
||||
if (choice === 'none') {
|
||||
onDone(leafAction);
|
||||
onDone(headAction);
|
||||
return;
|
||||
}
|
||||
if (choice === 'dialogue') {
|
||||
var nextLeaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
|
||||
collisionWizardBuildLeafAction(choice, function (nextLeaf) {
|
||||
leafAction.then = nextLeaf;
|
||||
renderCollisionWizardChainStep(nextLeaf, onDone);
|
||||
return;
|
||||
}
|
||||
// 'variable'
|
||||
renderCollisionWizardVariableStep(function (action) {
|
||||
leafAction.then = action;
|
||||
renderCollisionWizardChainStep(action, onDone);
|
||||
renderCollisionWizardChainStep(nextLeaf, onDone, headAction);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -303,16 +542,14 @@ function collisionWizardConditionNext(nested) {
|
||||
// indépendamment l'une de l'autre.
|
||||
function renderCollisionWizardBranchStep(branchKey) {
|
||||
var label = branchKey === 'si_vrai' ? '✓ Si VRAI...' : '✕ Si FAUX...';
|
||||
var choiceButtons = COLLISION_CHAIN_CHOICES.map(function (choice, i) {
|
||||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'' + choice + '\')"><span>' + COLLISION_ACTION_LABELS[choice] + '</span></button>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">' + label + '</p>' +
|
||||
'<div class="collisionWizardChoices">' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'dialogue\')">' +
|
||||
'<span>Déclencher un dialogue</span>' +
|
||||
'</button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'variable\')">' +
|
||||
'<span>Modifier une variable</span>' +
|
||||
'</button>' +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:80ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'none\')">' +
|
||||
choiceButtons +
|
||||
'<button type="button" class="collisionWizardChoice" style="animation-delay:' + (COLLISION_CHAIN_CHOICES.length * 40) + 'ms" onclick="collisionWizardChooseBranch(\'' + branchKey + '\', \'none\')">' +
|
||||
'<span>Ne rien faire</span>' +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
@@ -324,20 +561,11 @@ function collisionWizardChooseBranch(branchKey, choice) {
|
||||
collisionWizardBranchDone(branchKey);
|
||||
return;
|
||||
}
|
||||
if (choice === 'dialogue') {
|
||||
var leaf = { type: 'dialogue', dialogue: { id: collisionGenerateActionId(), lines: [] } };
|
||||
collisionWizardBuildLeafAction(choice, function (leaf) {
|
||||
renderCollisionWizardChainStep(leaf, function (finalLeaf) {
|
||||
_collisionWizard.pendingCondition[branchKey] = finalLeaf;
|
||||
collisionWizardBranchDone(branchKey);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 'variable'
|
||||
renderCollisionWizardVariableStep(function (action) {
|
||||
renderCollisionWizardChainStep(action, function (finalLeaf) {
|
||||
_collisionWizard.pendingCondition[branchKey] = finalLeaf;
|
||||
collisionWizardBranchDone(branchKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// automatiquement sélectionné/ouvert dans le panneau de propriétés (voir
|
||||
// onSceneObjectMouseDown, qui recharge encore la page pour ça) — juste
|
||||
// posé sur le canevas, cliquable ensuite comme n'importe quel autre.
|
||||
function addSceneObject(kind, forgeCharacter, backgroundSlug, assetId) {
|
||||
function addSceneObject(kind, forgeCharacter, backgroundSlug, assetId, x, y) {
|
||||
var body = new FormData();
|
||||
body.append('kind', kind);
|
||||
if (forgeCharacter) body.append('forge_character', forgeCharacter);
|
||||
@@ -24,6 +24,10 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug, assetId) {
|
||||
// "Mes assets" (voir auth/user_assets_dir.py) : une image importée par
|
||||
// le créateur ou générée par l'IA, distincte de BACKGROUND_LIBRARY.
|
||||
if (assetId) body.append('asset_id', assetId);
|
||||
// Vrai glisser-déposer (voir onCanvasDrop ci-dessous) : position EXACTE
|
||||
// du dépôt — absent pour un simple clic sur la galerie, qui garde le
|
||||
// défaut habituel (100,100, voir screens/scenes/add_scene_object.py).
|
||||
if (x != null && y != null) { body.append('x', x); body.append('y', y); }
|
||||
fetch("/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/scene-objects/add", {
|
||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: body,
|
||||
})
|
||||
@@ -65,18 +69,113 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug, assetId) {
|
||||
.catch(function () { window.location.reload(); });
|
||||
}
|
||||
|
||||
// ---------- Vrai glisser-déposer depuis la galerie (demande explicite :
|
||||
// "n'importe où sur la scène", pas seulement un clic qui pose à une
|
||||
// position par défaut) ----------
|
||||
// dataTransfer ne porte que des CHAÎNES : les 4 arguments d'addSceneObject
|
||||
// sont sérialisés en JSON plutôt qu'éclatés en plusieurs champs (certains
|
||||
// sont `null` — JSON les distingue proprement d'une chaîne vide, un
|
||||
// simple attribut HTML ne le pourrait pas). `draggable="true"` sur un
|
||||
// <button> coexiste nativement avec son `onclick` (un clic sans
|
||||
// mouvement déclenche toujours "click", un glisser réel "dragstart" —
|
||||
// jamais les deux à la fois) : le clic simple (pose à la position par
|
||||
// défaut) continue donc de fonctionner exactement comme avant.
|
||||
function onGalleryTileDragStart(ev, kind, forgeCharacter, backgroundSlug, assetId) {
|
||||
ev.dataTransfer.effectAllowed = 'copy';
|
||||
ev.dataTransfer.setData('application/json', JSON.stringify({
|
||||
kind: kind, forgeCharacter: forgeCharacter || null,
|
||||
backgroundSlug: backgroundSlug || null, assetId: assetId || null,
|
||||
}));
|
||||
}
|
||||
|
||||
// Un navigateur refuse "drop" par défaut sur TOUT élément — preventDefault()
|
||||
// sur "dragover" est ce qui l'autorise explicitement ici (voir MDN, aucun
|
||||
// contournement plus simple).
|
||||
function onCanvasDragOver(ev) {
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
|
||||
function onCanvasDrop(ev) {
|
||||
ev.preventDefault();
|
||||
var raw = ev.dataTransfer.getData('application/json');
|
||||
if (!raw) return;
|
||||
var data;
|
||||
try { data = JSON.parse(raw); } catch (e) { return; }
|
||||
var canvas = document.getElementById('canvas');
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
// Pas de zoom/échelle CSS sur #canvas (voir templates/scene_edit.html,
|
||||
// juste width/height en px fixes) : une simple soustraction suffit,
|
||||
// aucune division par un facteur d'échelle à appliquer. getBoundingClientRect()
|
||||
// intègre déjà nativement le défilement de .canvasFrame.
|
||||
var x = ev.clientX - rect.left;
|
||||
var y = ev.clientY - rect.top;
|
||||
addSceneObject(data.kind, data.forgeCharacter, data.backgroundSlug, data.assetId, x, y);
|
||||
}
|
||||
|
||||
// "Mes assets" (voir routes/assets/upload_user_asset.py, panneau "Mes
|
||||
// assets" de scene_edit.html) : un simple rechargement après l'upload —
|
||||
// la galerie est rendue côté serveur (user_assets), pas la peine de la
|
||||
// reconstruire en JS pour un flux qui n'arrive pas souvent.
|
||||
function uploadUserAsset(inputEl) {
|
||||
// `sceneKind` ("fond"/"decor", images uniquement — demande explicite :
|
||||
// "un bouton d'import séparé pour les images de fond et les objets") :
|
||||
// décide comment un CLIC sur la tuile résultante pose l'objet (voir
|
||||
// screens/scenes/add_scene_object.py) — absent pour un son/une vidéo,
|
||||
// qui n'ont rien à "poser".
|
||||
function uploadUserAsset(inputEl, sceneKind) {
|
||||
var file = inputEl.files && inputEl.files[0];
|
||||
if (!file) return;
|
||||
var body = new FormData();
|
||||
body.append('file', file);
|
||||
if (sceneKind) body.append('scene_kind', sceneKind);
|
||||
fetch('/assets/upload', { method: 'POST', body: body })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function () { window.location.reload(); })
|
||||
.then(function (r) {
|
||||
// Bug corrigé : un rechargement inconditionnel masquait toute
|
||||
// erreur serveur (ex. son de plus de 3 minutes, voir
|
||||
// auth/validate_audio_duration.py) — le fichier semblait juste
|
||||
// "disparaître" sans explication, jamais ajouté à la galerie.
|
||||
return r.json().then(function (json) { return { ok: r.ok, json: json }; });
|
||||
})
|
||||
.then(function (result) {
|
||||
if (!result.ok) { alert(result.json.error || "Impossible d'importer ce fichier."); return; }
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function () { window.location.reload(); });
|
||||
}
|
||||
|
||||
// Suppression d'un fichier de "Mes assets" (demande explicite : "voir,
|
||||
// utiliser ou supprimer des son, image et vidéo") — voir
|
||||
// routes/assets/delete_user_asset.py. Un simple rechargement après coup,
|
||||
// comme uploadUserAsset() juste au-dessus : ce panneau ne se recompose
|
||||
// pas assez souvent pour justifier une suppression ciblée du DOM.
|
||||
function deleteUserAsset(assetId, name) {
|
||||
if (!confirm('Supprimer "' + name + '" ? Cette action est définitive.')) return;
|
||||
fetch('/assets/' + assetId, { method: 'DELETE' })
|
||||
.then(function (r) { return r.json().then(function (json) { return { ok: r.ok, json: json }; }); })
|
||||
.then(function (result) {
|
||||
if (!result.ok) { alert(result.json.error || "Impossible de supprimer ce fichier."); return; }
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function () { window.location.reload(); });
|
||||
}
|
||||
|
||||
// "→ Fond"/"→ Décor" (demande diagnostiquée depuis un "décalage" : une
|
||||
// image importée par le mauvais bouton, ou avant la séparation fond/décor,
|
||||
// restait bloquée dans la mauvaise section, impossible à poser comme fond
|
||||
// sans la ré-importer en double) — voir auth/update_user_asset_scene_kind.py.
|
||||
// Ne touche jamais un objet DÉJÀ posé sur une scène : juste la classification
|
||||
// de l'ASSET, pour que la prochaine pose utilise la bonne section/le bon
|
||||
// rendu (object-fit:fill pour un fond, :contain pour un décor).
|
||||
function reclassifyUserAsset(assetId, sceneKind) {
|
||||
fetch('/assets/' + assetId + '/scene-kind', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scene_kind: sceneKind }),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (json) { return { ok: r.ok, json: json }; }); })
|
||||
.then(function (result) {
|
||||
if (!result.ok) { alert(result.json.error || "Reclassement impossible."); return; }
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function () { window.location.reload(); });
|
||||
}
|
||||
|
||||
@@ -137,6 +236,24 @@ function saveSceneObjectGeometry(id, x, y, width, height) {
|
||||
return fetch("/game/" + GAME_SLUG + "/scene-objects/" + id + "/geometry", {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ x: x, y: y, width: width, height: height }),
|
||||
}).then(function (r) { return r.json(); }).then(function (data) {
|
||||
// Bug corrigé (même famille que iaChatRefreshAfterTurn(), voir
|
||||
// static/js/ia/ia-chat.js) : un "fond" redimensionné/déplacé à la
|
||||
// souris au-delà du champ de la caméra doit élargir le "monde" tout
|
||||
// de suite — sans ça, window.SCENE_WIDTH/SCENE_HEIGHT (posées une
|
||||
// seule fois à l'ouverture de la page) restaient à leur ancienne
|
||||
// valeur et le glisser-déposer continuait de tout borner à l'ancien
|
||||
// cadre, rendant injoignable à la souris tout ce qui dépassait
|
||||
// désormais la scène nominale.
|
||||
if (data && typeof data.world_width === 'number' && typeof data.world_height === 'number') {
|
||||
window.SCENE_WIDTH = data.world_width;
|
||||
window.SCENE_HEIGHT = data.world_height;
|
||||
var canvas = document.getElementById('canvas');
|
||||
if (canvas) {
|
||||
canvas.style.width = data.world_width + 'px';
|
||||
canvas.style.height = data.world_height + 'px';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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