Enrichit les declencheurs/actions de scene (clic/survol/affichage, surbrillance/video/son/visibilite/indication/attendre) et fiabilise la pose d'un fond/decor importe
Build and deploy / test-python (push) Successful in 10m59s
Build and deploy / test-js (push) Successful in 1m27s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

- 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:
william
2026-09-12 00:28:46 +02:00
co-authored by Claude Sonnet 5
parent b07b231a61
commit 559331f9cf
67 changed files with 4739 additions and 306 deletions
@@ -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');
});
+116 -2
View File
@@ -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;
}
+20
View File
@@ -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 };
}
+4
View File
@@ -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
+58
View File
@@ -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')"));
});
+300 -72
View File
@@ -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, '&lt;') + '</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, '&lt;') + '</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);
});
});
}
+121 -4
View File
@@ -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';
}
}
});
}
+428 -62
View File
@@ -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, '&quot;') + '"' + (a.url === audioUrl ? ' selected' : '') + '>' + a.name.replace(/</g, '&lt;') + '</option>';
}).join('');
var preview = audioUrl
? '<audio controls preload="none" src="' + audioUrl.replace(/"/g, '&quot;') + '" class="questBubbleAudioPreview"></audio>'
: '';
return (
'<div class="questBubbleAudioRow">' +
'<select class="questBubbleAudioSelect" title="Voix off (optionnel)" onchange="triggerInlineSetBubbleAudio(\'' + dialogueId + '\', ' + index + ', this.value)">' + selectOptions + '</select>' +
preview +
'</div>'
);
}
// Une bulle = header (qui parle, menu déroulant des objets de scène
// nommés — voir "ℹ️ Informations") + body (le texte) — 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, '&lt;') + '</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, '&lt;');
}
function triggerGenericLeafBoxHtml(title, text) {
return (
'<div class="dialogueBox questVariableAction">' +
'<div class="dialogueBoxHeader"><span class="dialogueBoxTitle">' + title + '</span></div>' +
'<p class="questVariableActionLabel">' + text + '</p>' +
'</div>'
);
}
function triggerLeafBoxHtml(item) {
if (item.type === 'dialogue') return triggerInlineDialogueHtml(item.dialogue);
if (item.type === 'variable') return triggerVariableActionHtml(item);
if (item.type === 'surbrillance') {
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, '&lt;'));
}
if (item.type === 'attendre') {
return triggerGenericLeafBoxHtml('Attendre', (item.data_value || '0') + ' seconde(s)');
}
return '';
}
// Une chaîne d'actions (voir screens/rendering/trigger_graph.py::
// _describe_chain) : "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, '&lt;') + ' ' + (opLabel || '').replace(/</g, '&lt;') + ' ' + (condition.valeur || '').replace(/</g, '&lt;');
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();