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 };
}