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