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