Enrichit les declencheurs/actions de scene (clic/survol/affichage, surbrillance/video/son/visibilite/indication/attendre) et fiabilise la pose d'un fond/decor importe
- Ajoute clic/survol/affichage-ecran comme declencheurs, et surbrillance, video, son, visibilite, indication, attendre comme actions, utilisables aussi bien par l'editeur manuel (menu lateral Objets/Ecran) que par Ruby (IA), avec blocs deplacables/supprimables dans une chaine. - Corrige plusieurs variantes du bug "impossible de poser un objet hors du champ de la camera" (troncature du chainage d'actions a 4 maillons, fond importe pose a 128x128 au lieu de sa taille reelle, decalage du fond au vrai glisser-depose, redimensionnement manuel jamais propage au monde). - Ajoute un vrai glisser-depose depuis la galerie vers la scene, la gestion complete de "Mes assets" (sous-sections Fonds/Decors/Sons/ Videos, suppression, reclassement fond<->decor sans re-upload). - Ajoute l'upload de son (limite 3 min) et de video (MP4 uniquement, limite 5 min), avec validation de la duree reelle du fichier, et une replique audio optionnelle dans une bulle de dialogue. - Fixe la taille de pose d'un objet/decor importe a 200x200 avec une boite de collision de 150x150. - Filtre le selecteur de fichier des actions son/video par type reel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b07b231a61
commit
559331f9cf
@@ -19,6 +19,7 @@ global.forgeApplyVariableActionOffline = global.forgeApplyVariableActionOffline
|
||||
const {
|
||||
forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick,
|
||||
forgeStartCollisionRuleControllers, forgeStopCollisionRuleControllers,
|
||||
forgeBindClicSurvolRules, forgeUnbindClicSurvolRules,
|
||||
} = require('../collision-rules-controller.js');
|
||||
|
||||
// La bulle "Appuie sur X" est ajoutée comme SŒUR de l'objet dans son
|
||||
@@ -179,6 +180,137 @@ test('forgeRunCollisionRuleAction — "dialogue" transmet son "then" à forgeSho
|
||||
assert.deepEqual(calls, [['d_1', 'Crokine', [], undefined, thenAction]]);
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "surbrillance" bascule .forgeHighlight sur l\'objet CIBLÉ (object_id), pas forcément l\'objet du déclencheur', () => {
|
||||
const targetEl = fakeEl();
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => (sel.includes('"9"') ? targetEl : undefined) };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({ type: 'surbrillance', object_id: 9, valeur: 'on' }, fakeEl(), { id: 1, name: 'Interrupteur' });
|
||||
assert.equal(targetEl.classList.contains('forgeHighlight'), true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "visibilite" masque l\'objet CIBLÉ', () => {
|
||||
const targetEl = fakeEl();
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => targetEl };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({ type: 'visibilite', object_id: 4, valeur: 'masque' }, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.equal(targetEl.style.display, 'none');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "surbrillance" sans objet trouvé (référence orpheline) ne plante pas et exécute quand même "then"', () => {
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => undefined };
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'surbrillance', object_id: 999, valeur: 'on',
|
||||
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "son" joue l\'URL fournie via Audio, instantané (then exécuté tout de suite)', () => {
|
||||
const previousAudio = global.Audio;
|
||||
const played = [];
|
||||
global.Audio = function (url) {
|
||||
played.push(url);
|
||||
return { play: () => Promise.resolve() };
|
||||
};
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'son', asset_url: '/assets/1/bip.mp3',
|
||||
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.deepEqual(played, ['/assets/1/bip.mp3']);
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
} finally { global.Audio = previousAudio; }
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "video" attend forgeShowVideoOverlay avant d\'exécuter "then"', () => {
|
||||
const previousShow = global.forgeShowVideoOverlay;
|
||||
let capturedDone;
|
||||
global.forgeShowVideoOverlay = (url, mode, onDone) => { capturedDone = onDone; };
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
try {
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'video', asset_url: '/assets/1/intro.mp4', mode: 'plein_ecran',
|
||||
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.equal(gameData.variables.score.value, '0'); // pas encore, la vidéo "joue"
|
||||
capturedDone();
|
||||
assert.equal(gameData.variables.score.value, '1'); // maintenant, la vidéo est "finie"
|
||||
} finally { global.forgeShowVideoOverlay = previousShow; }
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "attendre" suspend "then" jusqu\'à la fin du délai (data_value en secondes)', async () => {
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'attendre', data_value: '0',
|
||||
then: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}, fakeEl(), { id: 1, name: 'x' });
|
||||
assert.equal(gameData.variables.score.value, '0'); // pas encore, setTimeout pas encore résolu
|
||||
await new Promise(function (resolve) { setTimeout(resolve, 10); });
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "indication" affiche une bulle temporisée près de l\'objet CIBLÉ', () => {
|
||||
const parentEl = fakeParentEl();
|
||||
const targetEl = fakeEl(parentEl);
|
||||
const previousDoc = global.document;
|
||||
global.document = fakeDocument({ 4: targetEl });
|
||||
try {
|
||||
forgeRunCollisionRuleAction({ type: 'indication', object_id: 4, texte: 'Regarde ici !' }, fakeEl(), { id: 1, name: 'x' });
|
||||
const bubble = parentEl.querySelector('.collisionIndicationBubble[data-for-object-id="4"]');
|
||||
assert.equal(bubble.textContent, 'Regarde ici !');
|
||||
assert.equal(bubble.style.display, 'block');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeBindClicSurvolRules — "clic" exécute l\'action au clic, sans joueur ni tick', () => {
|
||||
const targetEl = { addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; }, removeEventListener() {} };
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => targetEl };
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
try {
|
||||
forgeBindClicSurvolRules({
|
||||
elements: [{
|
||||
id: 5, collision_rules: [{
|
||||
trigger: 'clic',
|
||||
action: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}],
|
||||
}],
|
||||
});
|
||||
targetEl._handlers.click();
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
} finally { global.document = previousDoc; forgeUnbindClicSurvolRules(); }
|
||||
});
|
||||
|
||||
test('forgeBindClicSurvolRules — "survol" se lie sur mouseenter, jamais sur click', () => {
|
||||
const targetEl = { addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; }, removeEventListener() {} };
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => targetEl };
|
||||
try {
|
||||
forgeBindClicSurvolRules({ elements: [{ id: 6, collision_rules: [{ trigger: 'survol', action: { type: 'dialogue' } }] }] });
|
||||
assert.equal(typeof targetEl._handlers.mouseenter, 'function');
|
||||
assert.equal(targetEl._handlers.click, undefined);
|
||||
} finally { global.document = previousDoc; forgeUnbindClicSurvolRules(); }
|
||||
});
|
||||
|
||||
test('forgeBindClicSurvolRules — un déclencheur "collision" n\'est jamais lié en écouteur DOM (reste géré par le tick)', () => {
|
||||
const targetEl = { addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; }, removeEventListener() {} };
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => targetEl };
|
||||
try {
|
||||
forgeBindClicSurvolRules({ elements: [{ id: 7, collision_rules: [{ trigger: 'collision', action: { type: 'dialogue' } }] }] });
|
||||
assert.equal(targetEl._handlers, undefined);
|
||||
} finally { global.document = previousDoc; forgeUnbindClicSurvolRules(); }
|
||||
});
|
||||
|
||||
test('tick — la collision entre le joueur et un objet ne redéclenche pas "dialogue" tant que le contact persiste', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } };
|
||||
const npc = { id: 2, kind: 'personnage', name: 'Crokine', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue', dialogue: { id: 'd_7', lines: [] } } }] };
|
||||
@@ -261,6 +393,14 @@ test('tick — "interagir" affiche la bulle "Appuie sur [touche]" pendant la col
|
||||
});
|
||||
|
||||
test('forgeStartCollisionRuleControllers — inactif sans joueur ou sans règle (aucun intervalle démarré)', () => {
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', personnage_role: 'joueur' }] }));
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue' } }] }] }));
|
||||
// "clic"/"survol" sont liés inconditionnellement (forgeBindClicSurvolRules,
|
||||
// voir plus bas) — jamais de joueur requis pour EUX, donc document.querySelector
|
||||
// est bien appelé même dans ces deux cas où le tick de collision, lui, ne
|
||||
// démarre pas ; un document minimal (aucun élément trouvé) suffit ici.
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => undefined };
|
||||
try {
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', personnage_role: 'joueur' }] }));
|
||||
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'dialogue' } }] }] }));
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
@@ -103,6 +103,54 @@ test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les ré
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — une réplique avec audio_url joue la voix off dès son affichage', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
const previousAudio = global.Audio;
|
||||
const played = [];
|
||||
global.document = { querySelector: () => widget };
|
||||
global.Audio = function (url) {
|
||||
played.push(url);
|
||||
return { play: () => Promise.resolve(), pause: () => {} };
|
||||
};
|
||||
try {
|
||||
forgeShowDialogueBox('d_9', 'Marc', [{ speaker: 'Marc', text: 'Bonjour !', audio_url: '/assets/1/bonjour.mp3' }]);
|
||||
assert.deepEqual(played, ['/assets/1/bonjour.mp3']);
|
||||
} finally { global.document = previousDoc; global.Audio = previousAudio; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox — une réplique SANS audio_url ne tente jamais de jouer un son', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
const previousAudio = global.Audio;
|
||||
let called = false;
|
||||
global.document = { querySelector: () => widget };
|
||||
global.Audio = function () { called = true; return { play: () => Promise.resolve(), pause: () => {} }; };
|
||||
try {
|
||||
forgeShowDialogueBox('d_9', 'Marc', [{ speaker: 'Marc', text: 'Bonjour !' }]);
|
||||
assert.equal(called, false);
|
||||
} finally { global.document = previousDoc; global.Audio = previousAudio; }
|
||||
});
|
||||
|
||||
test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — la voix off de la réplique précédente s\'arrête sur la suivante', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
const previousAudio = global.Audio;
|
||||
const paused = [];
|
||||
global.document = { querySelector: () => widget };
|
||||
global.Audio = function (url) {
|
||||
return { url: url, play: () => Promise.resolve(), pause: function () { paused.push(this.url); } };
|
||||
};
|
||||
try {
|
||||
forgeShowDialogueBox('d_9', 'Marc', [
|
||||
{ speaker: 'Marc', text: 'Bonjour !', audio_url: '/assets/1/a.mp3' },
|
||||
{ speaker: 'Marc', text: 'Ça va ?', audio_url: '/assets/1/b.mp3' },
|
||||
]);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
assert.deepEqual(paused, ['/assets/1/a.mp3']);
|
||||
} finally { global.document = previousDoc; global.Audio = previousAudio; }
|
||||
});
|
||||
|
||||
test('fin de dialogue SANS mark_completed : le dialogue n\'est jamais marqué terminé', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Déclencheurs D'ÉCRAN "à l'affichage" (voir
|
||||
// static/js/play/screen-triggers-controller.js, screens/rendering/
|
||||
// screen_triggers.py) — SANS objet requis, narration/cinématique.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
global.window = global.window || {};
|
||||
global.gameData = global.gameData || {};
|
||||
|
||||
const actionsMod = require('../actions.js');
|
||||
Object.assign(global, actionsMod);
|
||||
require('../personnage-controller.js');
|
||||
global.forgeApplyVariableActionOffline = global.forgeApplyVariableActionOffline || require('../offline/apply-actions.js').forgeApplyVariableActionOffline;
|
||||
global.forgeRunCollisionRuleAction = require('../collision-rules-controller.js').forgeRunCollisionRuleAction;
|
||||
|
||||
const { forgeRunScreenTriggers } = require('../screen-triggers-controller.js');
|
||||
|
||||
test('forgeRunScreenTriggers — ignore un écran sans screen_triggers', () => {
|
||||
assert.doesNotThrow(() => forgeRunScreenTriggers({ id: 1 }));
|
||||
assert.doesNotThrow(() => forgeRunScreenTriggers(null));
|
||||
});
|
||||
|
||||
test('forgeRunScreenTriggers — exécute une action "variable" pour un déclencheur "affichage"', () => {
|
||||
global.gameData = { variables: { intro_vue: { type: 'booleen', value: 'faux' } } };
|
||||
forgeRunScreenTriggers({
|
||||
id: 1,
|
||||
screen_triggers: [{
|
||||
trigger: 'affichage',
|
||||
action: { type: 'variable', target_variable: 'intro_vue', data_operation: 'definir_bool_vrai' },
|
||||
}],
|
||||
});
|
||||
assert.equal(gameData.variables.intro_vue.value, '1');
|
||||
});
|
||||
|
||||
test('forgeRunScreenTriggers — ignore tout déclencheur autre que "affichage" (jamais posé en pratique, garde-fou)', () => {
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' } } };
|
||||
forgeRunScreenTriggers({
|
||||
id: 1,
|
||||
screen_triggers: [{
|
||||
trigger: 'collision',
|
||||
action: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
}],
|
||||
});
|
||||
assert.equal(gameData.variables.score.value, '0');
|
||||
});
|
||||
@@ -112,6 +112,42 @@ function forgeHideCollisionInteractBubble(objectId, objectEl) {
|
||||
if (bubble) bubble.style.display = 'none';
|
||||
}
|
||||
|
||||
// ---------- Action "indication" — bulle de texte libre, temporisée ----------
|
||||
// Distincte de la bulle "Appuie sur X" ci-dessus (sa propre classe CSS,
|
||||
// sa propre minuterie) : ne dépend jamais du cycle "actif tant que la
|
||||
// collision dure" d'un déclencheur "interagir", peut être posée sur
|
||||
// N'IMPORTE QUEL objet de l'écran (voir action.object_id, pas
|
||||
// nécessairement l'objet qui porte le déclencheur).
|
||||
var FORGE_INDICATION_DEFAULT_DURATION_MS = 3000;
|
||||
var forgeIndicationTimeouts = {};
|
||||
|
||||
function forgeIndicationBubbleEl(objectId, parentEl) {
|
||||
var bubble = parentEl.querySelector('.collisionIndicationBubble[data-for-object-id="' + objectId + '"]');
|
||||
if (!bubble) {
|
||||
bubble = document.createElement('div');
|
||||
bubble.className = 'collisionIndicationBubble';
|
||||
bubble.setAttribute('data-for-object-id', objectId);
|
||||
parentEl.appendChild(bubble);
|
||||
}
|
||||
return bubble;
|
||||
}
|
||||
|
||||
function forgeShowIndicationBubble(objectId, texte, dureeMs) {
|
||||
var targetEl = document.querySelector('[data-object-id="' + objectId + '"]');
|
||||
var parentEl = targetEl && targetEl.parentElement;
|
||||
if (!targetEl || !parentEl || !texte) return;
|
||||
var objRect = forgeElementBoxRect(targetEl);
|
||||
var bubble = forgeIndicationBubbleEl(objectId, parentEl);
|
||||
bubble.textContent = texte;
|
||||
bubble.style.display = 'block';
|
||||
bubble.style.left = (objRect.left + objRect.width / 2) + 'px';
|
||||
bubble.style.top = objRect.top + 'px';
|
||||
if (forgeIndicationTimeouts[objectId]) clearTimeout(forgeIndicationTimeouts[objectId]);
|
||||
forgeIndicationTimeouts[objectId] = setTimeout(function () {
|
||||
bubble.style.display = 'none';
|
||||
}, dureeMs || FORGE_INDICATION_DEFAULT_DURATION_MS);
|
||||
}
|
||||
|
||||
// Exécute une action (top-level, sub_action d'un "interagir", ou branche
|
||||
// si_vrai/si_faux d'une "condition") — jamais appelée pour un "interagir"
|
||||
// lui-même à ce niveau, voir forgeCollisionRuleTick qui le gère à part
|
||||
@@ -156,6 +192,40 @@ function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
||||
});
|
||||
var branch = isTrue ? action.si_vrai : action.si_faux;
|
||||
if (branch) forgeRunCollisionRuleAction(branch, targetEl, obj);
|
||||
} else if (action.type === 'surbrillance' || action.type === 'visibilite') {
|
||||
// Cible N'IMPORTE QUEL objet de l'écran (action.object_id, pas
|
||||
// forcément celui qui porte le déclencheur) — réutilise TEL QUEL
|
||||
// applyObjectProperty (static/js/play/actions.js, équivalent scène 2D
|
||||
// d'applyElementProperty), plutôt que de dupliquer cette logique.
|
||||
var targetObjEl = document.querySelector('[data-object-id="' + action.object_id + '"]');
|
||||
if (targetObjEl) applyObjectProperty(targetObjEl, action.type, action.valeur);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'son') {
|
||||
// Effet sonore ponctuel, non bouclé — même comportement que l'action
|
||||
// "Jouer un son" du Flow (static/js/play/actions.js), instantané :
|
||||
// `then` s'exécute tout de suite, sans attendre la fin du son.
|
||||
if (action.asset_url) new Audio(action.asset_url).play().catch(function () {});
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'video') {
|
||||
// Bloque la suite de la chaîne "then" jusqu'à la fin de la vidéo (ou
|
||||
// "Passer") — voir static/js/play/video-overlay.js, même esprit
|
||||
// qu'un dialogue ci-dessus.
|
||||
forgeShowVideoOverlay(action.asset_url, action.mode, function () {
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
});
|
||||
} else if (action.type === 'indication') {
|
||||
forgeShowIndicationBubble(action.object_id, action.texte, action.duree_ms);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'attendre') {
|
||||
// Suspend la chaîne "then" pendant data_value SECONDES — même
|
||||
// vocabulaire que l'action "attendre" du Flow (static/js/play/
|
||||
// actions.js), utile ex. pour laisser un temps de lecture après une
|
||||
// narration à l'affichage de l'écran.
|
||||
var seconds = parseFloat(action.data_value);
|
||||
var ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
setTimeout(function () {
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
}, ms);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +244,11 @@ function forgeCollisionRuleTick(screenData) {
|
||||
|
||||
var anyInteractActive = false;
|
||||
rules.forEach(function (rule, ruleIndex) {
|
||||
// "clic"/"survol" sont liés à part (voir forgeBindClicSurvolRules,
|
||||
// écouteurs DOM directs) — jamais évalués ici, sans quoi ils
|
||||
// s'exécuteraient EN PLUS, à la proximité du joueur, en plus de
|
||||
// leur propre déclencheur.
|
||||
if (rule.trigger !== 'collision') return;
|
||||
var stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex);
|
||||
var isActive = forgeCollisionRuleTriggerActive(rule, objRect, playerRect);
|
||||
var wasActive = !!forgeCollisionRuleActiveState[stateKey];
|
||||
@@ -217,6 +292,36 @@ function forgeStartCollisionRuleInteractListener(screenData) {
|
||||
window.addEventListener('keydown', activeCollisionRuleInteractListener);
|
||||
}
|
||||
|
||||
// ---------- Déclencheurs "clic"/"survol" — écouteurs DOM directs ----------
|
||||
// Contrairement à "collision" (tick + proximité du joueur), ces deux
|
||||
// déclencheurs n'ont besoin NI d'un joueur NI d'un tick : un écouteur
|
||||
// posé une fois par objet suffit (voir screens/rendering/
|
||||
// collision_rules.py). Re-attachés à chaque affichage d'écran, comme le
|
||||
// reste de ce fichier.
|
||||
var activeClicSurvolListeners = [];
|
||||
|
||||
function forgeUnbindClicSurvolRules() {
|
||||
activeClicSurvolListeners.forEach(function (l) { l.el.removeEventListener(l.event, l.handler); });
|
||||
activeClicSurvolListeners = [];
|
||||
}
|
||||
|
||||
function forgeBindClicSurvolRules(screenData) {
|
||||
forgeUnbindClicSurvolRules();
|
||||
(screenData.elements || []).forEach(function (obj) {
|
||||
var rules = obj.collision_rules;
|
||||
if (!rules || !rules.length) return;
|
||||
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
||||
if (!targetEl) return;
|
||||
rules.forEach(function (rule) {
|
||||
if (rule.trigger !== 'clic' && rule.trigger !== 'survol') return;
|
||||
var eventName = rule.trigger === 'clic' ? 'click' : 'mouseenter';
|
||||
var handler = function () { forgeRunCollisionRuleAction(rule.action, targetEl, obj); };
|
||||
targetEl.addEventListener(eventName, handler);
|
||||
activeClicSurvolListeners.push({ el: targetEl, event: eventName, handler: handler });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function forgeStopCollisionRuleControllers() {
|
||||
if (activeCollisionRuleInterval) {
|
||||
clearInterval(activeCollisionRuleInterval);
|
||||
@@ -226,16 +331,20 @@ function forgeStopCollisionRuleControllers() {
|
||||
window.removeEventListener('keydown', activeCollisionRuleInteractListener);
|
||||
activeCollisionRuleInteractListener = null;
|
||||
}
|
||||
forgeUnbindClicSurvolRules();
|
||||
forgeCollisionRuleActiveState = {};
|
||||
}
|
||||
|
||||
// Même patron par écran que forgeStartPersonnageControllers — appelé
|
||||
// juste après lui (voir static/js/play/screens.js), n'a d'effet que si
|
||||
// l'écran a un joueur ET au moins un objet avec des règles.
|
||||
// juste après lui (voir static/js/play/screens.js). "clic"/"survol" sont
|
||||
// liés inconditionnellement (aucun joueur requis) ; le tick de collision
|
||||
// + l'écouteur "interagir" ne démarrent, eux, que si l'écran a un joueur
|
||||
// ET au moins un objet avec des règles.
|
||||
function forgeStartCollisionRuleControllers(screenData) {
|
||||
forgeStopCollisionRuleControllers();
|
||||
if (!screenData || screenData.kind !== 'jeu_2d') return;
|
||||
var elements = screenData.elements || [];
|
||||
forgeBindClicSurvolRules(screenData);
|
||||
var hasPlayer = elements.some(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
|
||||
var hasRules = elements.some(function (o) { return o.collision_rules && o.collision_rules.length; });
|
||||
if (!hasPlayer || !hasRules) return;
|
||||
@@ -249,6 +358,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick,
|
||||
forgeStartCollisionRuleControllers, forgeStopCollisionRuleControllers,
|
||||
forgeCollisionRuleStateKey, forgeElementBoxRect, forgeExpandRect,
|
||||
forgeBindClicSurvolRules, forgeUnbindClicSurvolRules, forgeShowIndicationBubble,
|
||||
};
|
||||
const conditionsMod = require('./conditions.js');
|
||||
global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox;
|
||||
@@ -259,4 +369,8 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8;
|
||||
const dialogueBoxMod = require('./dialogue-box-controller.js');
|
||||
global.forgeShowDialogueBox = global.forgeShowDialogueBox || dialogueBoxMod.forgeShowDialogueBox;
|
||||
const actionsMod = require('./actions.js');
|
||||
global.applyObjectProperty = global.applyObjectProperty || actionsMod.applyObjectProperty;
|
||||
const videoOverlayMod = require('./video-overlay.js');
|
||||
global.forgeShowVideoOverlay = global.forgeShowVideoOverlay || videoOverlayMod.forgeShowVideoOverlay;
|
||||
}
|
||||
|
||||
@@ -73,11 +73,30 @@ function forgeDialogueBoxFooterEl(widget) {
|
||||
return widget.querySelector('[data-dialogue-role="footer"]');
|
||||
}
|
||||
|
||||
// Voix off d'une réplique (demande explicite : "ajouter une réplique
|
||||
// audio", voir db/dialogue_lines.py::"dialogue".audio_url) — jouée dès
|
||||
// que la bulle s'affiche, arrêtée dès qu'une AUTRE ligne (avec ou sans
|
||||
// sa propre voix off) prend sa place, pour ne jamais superposer deux
|
||||
// voix off en même temps si le joueur avance vite dans la conversation.
|
||||
var forgeDialogueLineAudio = null;
|
||||
|
||||
function forgeStopDialogueLineAudio() {
|
||||
if (forgeDialogueLineAudio) {
|
||||
forgeDialogueLineAudio.pause();
|
||||
forgeDialogueLineAudio = null;
|
||||
}
|
||||
}
|
||||
|
||||
function forgeRenderDialogueBoxLine(widget, line) {
|
||||
var header = widget.querySelector('[data-dialogue-role="header"]');
|
||||
var body = widget.querySelector('[data-dialogue-role="body"]');
|
||||
if (header) header.textContent = line.speaker;
|
||||
if (body) body.textContent = line.text;
|
||||
forgeStopDialogueLineAudio();
|
||||
if (line.audio_url) {
|
||||
forgeDialogueLineAudio = new Audio(line.audio_url);
|
||||
forgeDialogueLineAudio.play().catch(function () {}); // lecture bloquée par le navigateur (pas d'interaction récente) : la bulle reste lisible sans voix off plutôt que de planter.
|
||||
}
|
||||
}
|
||||
|
||||
// Remet le pied de la boîte dans son état "dialogue" (un seul bouton
|
||||
@@ -257,6 +276,7 @@ function forgeAdvanceConversation(dialogueObjectId) {
|
||||
if (!state) return;
|
||||
state.index += 1;
|
||||
if (state.index >= state.lines.length) {
|
||||
forgeStopDialogueLineAudio();
|
||||
forgeHideQuizBoxOnCurrentScreen();
|
||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
if (state.markCompleted) forgeCompleteDialogue(state.dialogueId, state.objectName);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// ---------- Déclencheurs D'ÉCRAN — "à l'affichage" (SANS objet) ----------
|
||||
// Voir screens/rendering/screen_triggers.py, section "Déclencheurs de
|
||||
// l'écran" — narration/cinématique dès l'affichage, sans avoir besoin
|
||||
// d'un objet de scène. Réutilise TEL QUEL le même moteur d'exécution
|
||||
// d'action que collision-rules-controller.js (forgeRunCollisionRuleAction)
|
||||
// plutôt que d'en dupliquer un second : targetEl/obj passés vides, une
|
||||
// action qui a besoin d'un objet précis le porte elle-même (object_id,
|
||||
// voir surbrillance/visibilite/indication).
|
||||
function forgeRunScreenTriggers(screenData) {
|
||||
if (!screenData) return;
|
||||
var rules = screenData.screen_triggers;
|
||||
if (!rules || !rules.length) return;
|
||||
rules.forEach(function (rule) {
|
||||
if (rule.trigger !== 'affichage') return;
|
||||
forgeRunCollisionRuleAction(rule.action, null, {});
|
||||
});
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même garde que le reste de
|
||||
// static/js/play/.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeRunScreenTriggers };
|
||||
}
|
||||
@@ -57,6 +57,10 @@ function showScreen(id) {
|
||||
// static/js/play/collision-rules-controller.js) — démarré juste après
|
||||
// le contrôleur de personnage dont il dépend (position du joueur).
|
||||
forgeStartCollisionRuleControllers(screensData.find(function(s) { return s.id === id; }));
|
||||
// Déclencheurs D'ÉCRAN "à l'affichage" (voir screen-triggers-controller.js,
|
||||
// screens/rendering/screen_triggers.py) — narration/cinématique sans
|
||||
// objet requis, exécutée à chaque affichage de CET écran.
|
||||
forgeRunScreenTriggers(screensData.find(function(s) { return s.id === id; }));
|
||||
// Widget "🏆 Score" (voir static/js/play/dialogue-box-controller.js) :
|
||||
// synchronise le compteur affiché sur CET écran avec le total déjà
|
||||
// gagné — sans ça, un score posé sur un écran affiché APRÈS que le
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// ---------- Action "video" — lecture d'une vidéo de "Mes assets" ----------
|
||||
// Voir screens/rendering/collision_rules.py::"video" — bloque la suite de
|
||||
// la chaîne "then" jusqu'à la fin de la vidéo (ou un clic sur "Passer"),
|
||||
// même esprit qu'un dialogue (voir dialogue-box-controller.js). Un seul
|
||||
// overlay posé sur <body>, réutilisé pour chaque lecture (pas la peine
|
||||
// d'en recréer un par appel).
|
||||
|
||||
var forgeVideoOverlayEl = null;
|
||||
|
||||
function forgeVideoOverlayRoot() {
|
||||
if (forgeVideoOverlayEl) return forgeVideoOverlayEl;
|
||||
var el = document.createElement('div');
|
||||
el.className = 'forgeVideoOverlay';
|
||||
el.innerHTML =
|
||||
'<video class="forgeVideoOverlayPlayer" playsinline></video>' +
|
||||
'<button type="button" class="forgeVideoOverlaySkip">Passer ▶</button>';
|
||||
document.body.appendChild(el);
|
||||
forgeVideoOverlayEl = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
// `onDone` : appelé une fois (fin naturelle OU "Passer") — jamais les
|
||||
// deux, `finished` protège contre un double appel (l'événement 'ended'
|
||||
// pouvant encore se déclencher juste après un clic sur "Passer").
|
||||
function forgeShowVideoOverlay(url, mode, onDone) {
|
||||
if (!url) {
|
||||
if (onDone) onDone();
|
||||
return;
|
||||
}
|
||||
var root = forgeVideoOverlayRoot();
|
||||
root.className = 'forgeVideoOverlay ' + (mode === 'incrustation' ? 'forgeVideoOverlay-incrustation' : 'forgeVideoOverlay-pleinEcran');
|
||||
root.style.display = 'flex';
|
||||
var player = root.querySelector('.forgeVideoOverlayPlayer');
|
||||
var skipBtn = root.querySelector('.forgeVideoOverlaySkip');
|
||||
var finished = false;
|
||||
|
||||
function finish() {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
root.style.display = 'none';
|
||||
player.pause();
|
||||
player.removeAttribute('src');
|
||||
player.onended = null;
|
||||
skipBtn.onclick = null;
|
||||
if (onDone) onDone();
|
||||
}
|
||||
|
||||
player.src = url;
|
||||
player.onended = finish;
|
||||
skipBtn.onclick = finish;
|
||||
player.play().catch(function () {}); // lecture bloquée par le navigateur (pas d'interaction récente) : reste affichée, "Passer" fonctionne toujours.
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même garde que le reste de
|
||||
// static/js/play/.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeShowVideoOverlay };
|
||||
}
|
||||
Reference in New Issue
Block a user