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
@@ -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')"));
|
||||
});
|
||||
@@ -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, '<') + '</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, '<') + '</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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user