Files
Forge-Engine/static/js/screen_edit/panel-init.js
T
williamandClaude Sonnet 5 76331f9285
Build and deploy / test-python (push) Successful in 1m27s
Build and deploy / test-js (push) Successful in 6s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Phase 6 : son (musique de fond par écran + action "Jouer un son")
Ajoute deux mécanismes distincts, à la demande de l'utilisateur qui a
précisé qu'un son doit pouvoir être attaché à une SCÈNE (pas seulement
joué ponctuellement par une action de flow, comme prévu initialement) :

- Musique de fond par écran (nouveau champ background_music_url sur
  _screens, ALTER TABLE nullable) : réglée dans le panneau gauche de
  l'éditeur (URL ou envoi de fichier, réutilise la route d'upload
  générique existante), enregistrée en AJAX au même patron que le format
  d'aperçu (screen_set_aspect.py). Démarrée en boucle à l'affichage de
  l'écran et arrêtée au changement d'écran (runScreenBackgroundMusic(),
  appelée depuis showScreen() dans static/js/play/screens.js) — un seul
  Audio actif à la fois, jamais cumulé avec une musique restée d'un écran
  précédent.

- Action de flow "jouer_son" (aux côtés des actions existantes) : effet
  sonore ponctuel, non bouclé, déclenchable sur n'importe quel nœud
  Déclencheur. Réutilise data_value (déjà un champ texte générique sur
  le nœud action, comme pour "attendre") plutôt qu'une nouvelle colonne
  dédiée — fire-and-forget côté client (runActionNode), ne bloque jamais
  la suite du graphe.

Les deux réutilisent le même mécanisme d'upload de fichier déjà en place
ailleurs dans l'éditeur (ex. source d'une vidéo), sans nouvelle route.

Ceci complète les 6 phases du plan d'extension du moteur (état par
joueur, hasard/maths, clavier/minuteur, ajout de ligne, position/
collision, son) : Forge Engine peut désormais couvrir des jeux bien
au-delà du narratif/puzzle/quiz (action, arcade, jeux à contrainte de
temps).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 17:56:49 +02:00

500 lines
25 KiB
JavaScript

function initBuilderPanel() {
(function () {
var xEl = document.getElementById('pos-x'), yEl = document.getElementById('pos-y');
var wEl = document.getElementById('pos-w'), hEl = document.getElementById('pos-h');
// pos-w/pos-h n'existent pas pour un élément posé dans un conteneur (sa
// taille se règle via les champs "Largeur/Hauteur (%)"/"fixe (px)" du
// formulaire général à la place, voir size_ctrls plus haut) — X/Y non
// plus, mais ils restent dans le DOM (juste désactivés) pour cet élément.
if (!xEl || !wEl) return;
function apply() {
var canvasEl = document.getElementById('el-' + CURRENT_SELECTED_ID);
var x = parseFloat(xEl.value) || 0, y = parseFloat(yEl.value) || 0;
var w = parseFloat(wEl.value) || 1, h = parseFloat(hEl.value) || 1;
if (canvasEl) {
canvasEl.style.left = x + '%'; canvasEl.style.top = y + '%';
canvasEl.style.width = w + '%'; canvasEl.style.height = h + '%';
}
saveGeometry(CURRENT_SELECTED_ID, x, y, w, h);
}
[xEl, yEl, wEl, hEl].forEach(function (el) { el.addEventListener('change', apply); });
})();
bindAspectButtons();
bindScreenBackgroundMusicField();
document.querySelectorAll('.uploadInput').forEach(function (input) {
input.addEventListener('change', function () {
if (!input.files.length) return;
var status = input.parentElement.querySelector('.uploadStatus');
var targetField = document.getElementById(input.dataset.target);
status.textContent = 'Envoi en cours...';
var data = new FormData();
data.append('file', input.files[0]);
fetch("/game/" + GAME_SLUG + "/upload", { method: 'POST', body: data })
.then(function (r) { return r.json(); })
.then(function (json) {
if (json.url) {
targetField.value = json.url;
status.textContent = 'Fichier envoyé ✓ Enregistrement...';
// Enregistre tout de suite, comme n'importe quel autre réglage —
// un fichier envoyé ne doit jamais rester "en attente".
submitPropsForm().then(function () { status.textContent = 'Fichier envoyé ✓ Enregistré.'; });
}
else { status.textContent = "Échec de l'envoi."; }
})
.catch(function () { status.textContent = "Échec de l'envoi."; });
});
});
// Phase 6 — champ d'upload DANS le formulaire de nœud (action "Jouer un
// son") : contrairement à .uploadInput (panneau de propriétés d'un
// élément), il n'y a pas de formulaire à autosauvegarder ici — le champ
// texte est simplement rempli, la valeur part avec le reste du nœud au
// clic sur "Créer" (voir submitNodeForm()).
document.querySelectorAll('.nodeFormUploadInput').forEach(function (input) {
input.addEventListener('change', function () {
if (!input.files.length) return;
var status = input.parentElement.querySelector('.uploadStatus');
var targetField = document.getElementById(input.dataset.target);
status.textContent = 'Envoi en cours...';
var data = new FormData();
data.append('file', input.files[0]);
fetch("/game/" + GAME_SLUG + "/upload", { method: 'POST', body: data })
.then(function (r) { return r.json(); })
.then(function (json) {
if (json.url) { targetField.value = json.url; status.textContent = 'Fichier envoyé ✓'; }
else { status.textContent = "Échec de l'envoi."; }
})
.catch(function () { status.textContent = "Échec de l'envoi."; });
});
});
bindPropsAutosave();
bindJaugeDefinitionSelect();
bindDefinitionFieldSelects('field-definition_id', ['field-filtre_champ', 'field-filtre2_champ']);
bindDefinitionFieldSelects('field-visibilite_cond_definition_id', ['field-visibilite_cond_champ']);
bindClauseListDefinitionSelect('field-data_definition_id');
bindClauseListRows();
initFilterValuePickers();
var visCondModeSel = document.getElementById('field-visibilite_cond_mode');
if (visCondModeSel) toggleVisCondFields(visCondModeSel);
restoreTreeCollapsedState();
applySelectionHighlight(CURRENT_SELECTED_ID);
restoreFloatPanelState('props', 'propsPanelFloat', 'propsPanelReopenBtn');
restoreFloatPanelState('left', 'panelLeftFloat', 'panelLeftReopenBtn');
}
// Jauge : quand on change l'objet de données à suivre SANS quitter le
// panneau (donc sans le rechargement serveur qui repeuplerait row_id/champ/
// champ_nom via controls_with_values.py), on reconstruit ces trois menus à
// la volée à partir de DEFINITIONS_DATA (même source que les formulaires de
// la Logique de la scène, voir onFlowDefinitionChange). DEFINITIONS_DATA
// est déclaré plus bas dans ce script (donc encore "undefined" au moment où
// initBuilderPanel() tourne en tout premier) — sans conséquence ici,
// puisqu'on ne le lit que dans le gestionnaire, jamais au moment du bind.
// Libellé d'un champ dans une liste déroulante — précise l'objet visé pour
// un champ "relation" (ex. "parcour (→ parcours)"), même règle que
// _field_label() côté Python (controls_with_values.py).
function fieldOptionLabel(f) {
return (f.type === 'relation' && f.relation_definition_name) ? (f.name + ' (→ ' + f.relation_definition_name + ')') : f.name;
}
function bindJaugeDefinitionSelect() {
var defSel = document.getElementById('field-definition_id');
var rowSel = document.getElementById('field-row_id');
var champSel = document.getElementById('field-champ');
var champNomSel = document.getElementById('field-champ_nom');
if (!defSel || !rowSel || !champSel || !champNomSel) return; // pas la Jauge
defSel.addEventListener('change', function () {
var info = (DEFINITIONS_DATA || {})[defSel.value] || { rows: [], fields: [] };
rowSel.innerHTML = info.rows.length
? info.rows.map(function (r) { return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('')
: '<option value="">Aucune donnée dans cet objet</option>';
// La Jauge affiche une valeur/un nom tel quel — un champ "relation" (un
// id, pas une valeur) n'a pas sa place ici, même exclusion que côté
// serveur (voir controls_with_values.py).
var simpleFields = info.fields.filter(function (f) { return f.type !== 'relation'; });
var fieldOptions = simpleFields.map(function (f) { return '<option value="' + f.name + '">' + f.name + '</option>'; }).join('');
champSel.innerHTML = fieldOptions || '<option value="">Aucun champ simple</option>';
champNomSel.innerHTML = '<option value="">— aucun —</option>' + fieldOptions;
});
}
// ---------- Réglages "champ" d'un filtre (Répéteur/Donnée liée) : listes
// déroulantes peuplées avec les VRAIS champs de l'objet choisi, jamais
// tapées à la main — même principe que bindJaugeDefinitionSelect() ci-dessus,
// généralisé pour s'appliquer à un nombre quelconque de listes "champ"
// rattachées à un même sélecteur d'objet (ici : filtre_champ + filtre2_champ,
// ou data_filtre_champ + data_filtre2_champ). ----------
// ---------- Condition de visibilité (voir VISIBILITY_CONDITION_CONTROLS) :
// n'affiche que les réglages pertinents pour le mode choisi (variable
// globale, ou champ d'un objet) — les deux autres restent en base (peu
// importe, seul le mode courant est lu par is_element_visible()) mais sont
// masqués pour ne pas embrouiller. ----------
function toggleVisCondFields(sel) {
var mode = sel.value;
document.querySelectorAll('.visCondRow').forEach(function (row) {
var scope = row.dataset.visCondScope;
var show = scope === 'any' ? !!mode : scope === mode;
row.style.display = show ? '' : 'none';
});
}
function bindDefinitionFieldSelects(defSelId, fieldSelIds) {
var defSel = document.getElementById(defSelId);
if (!defSel) return;
var fieldSels = fieldSelIds.map(function (id) { return document.getElementById(id); }).filter(Boolean);
if (!fieldSels.length) return;
defSel.addEventListener('change', function () {
var info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
var opts = '<option value="">(sélectionner un objet d\'abord)</option>' +
info.fields.map(function (f) { return '<option value="' + f.name + '">' + fieldOptionLabel(f) + '</option>'; }).join('');
fieldSels.forEach(function (sel) { sel.innerHTML = opts; });
});
}
// ---------- "Donnée liée" : liste de conditions ILLIMITÉE (champ/opérateur/
// valeur), combinées entre elles par ET/OU (voir c_clause_list.py,
// save_element_controls.py, resolve_bound_row.py). Chaque ligne existante
// est rendue côté serveur (au chargement du panneau) ; "+ Ajouter une
// condition" clone un <template> pour en ajouter de nouvelles à la volée,
// sans aller-retour serveur — même principe que "fieldsList"/"varValueRows"
// ailleurs dans l'outil. ----------
function wireClauseRow(row, key) {
row.querySelectorAll('input, select').forEach(function (field) {
var debounced = field.tagName === 'INPUT';
field.addEventListener(debounced ? 'input' : 'change', function () {
if (debounced) debouncedSubmitPropsForm(); else submitPropsForm();
});
});
var removeBtn = row.querySelector('.clauseRemoveBtn');
if (removeBtn) {
removeBtn.addEventListener('click', function () {
row.remove();
updateClauseCombinatorVisibility(key);
submitPropsForm();
});
}
}
function updateClauseCombinatorVisibility(key) {
var wrap = document.getElementById('clauseListWrap-' + key);
var combRow = document.getElementById('clauseCombinatorRow-' + key);
if (!wrap || !combRow) return;
combRow.style.display = wrap.querySelectorAll('.clauseRow').length > 1 ? '' : 'none';
}
function addClauseRow(key) {
var tmpl = document.getElementById('clauseRowTemplate-' + key);
var wrap = document.getElementById('clauseListWrap-' + key);
if (!tmpl || !wrap) return;
var clone = tmpl.content.cloneNode(true);
var row = clone.querySelector('.clauseRow');
wireClauseRow(row, key);
wrap.appendChild(clone);
// Repeuple tout de suite son sélecteur "Champ" avec les VRAIS champs de
// l'objet déjà choisi (sans ça, une ligne ajoutée après coup resterait
// bloquée sur "(sélectionner un objet d'abord)" même si un objet est
// déjà réglé plus haut dans le panneau).
var defSel = document.getElementById('field-data_definition_id');
if (defSel && defSel.value) defSel.dispatchEvent(new Event('change'));
// Initialise le sélecteur de valeur (fixe/dynamique/variable) de cette
// nouvelle ligne — sans effet sur les lignes déjà initialisées.
initFilterValuePickers();
updateClauseCombinatorVisibility(key);
submitPropsForm();
}
// Rebranche les lignes déjà rendues par le serveur (rechargement du
// panneau, ex. sélection d'un autre élément) — les lignes ajoutées EN
// COURS de session par addClauseRow() se branchent déjà elles-mêmes.
function bindClauseListRows() {
document.querySelectorAll('.clauseListControl').forEach(function (control) {
var key = control.dataset.clauseKey;
control.querySelectorAll('.clauseRow').forEach(function (row) { wireClauseRow(row, key); });
});
}
// Repeuple le sélecteur "champ" de TOUTES les lignes de condition actuelles
// (interrogées à chaque changement, donc couvre aussi les lignes ajoutées
// après coup) quand on change l'objet lié — même principe que
// bindDefinitionFieldSelects ci-dessus, généralisé pour un nombre de lignes
// variable plutôt qu'une liste fixe d'ids capturée une fois pour toutes.
function bindClauseListDefinitionSelect(defSelId) {
var defSel = document.getElementById(defSelId);
if (!defSel) return;
defSel.addEventListener('change', function () {
var info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
var opts = '<option value="">(sélectionner un objet d\'abord)</option>' +
info.fields.map(function (f) { return '<option value="' + f.name + '">' + fieldOptionLabel(f) + '</option>'; }).join('');
document.querySelectorAll('.clauseChampSelect').forEach(function (sel) {
var current = sel.value;
sel.innerHTML = opts;
sel.value = current; // reste sélectionné si ce champ existe encore dans le nouvel objet
});
});
}
// ---------- Valeur de comparaison d'un filtre : fixe, ou donnée d'un autre
// objet ----------
//
// La valeur envoyée au serveur reste une simple chaîne "texte" (voir
// _resolve_filter_value côté Python) — soit une valeur fixe, soit une
// référence à un champ d'un autre objet dans un format interne précis. Le
// mode "dynamique" ci-dessous se contente de reconstruire cette chaîne à
// partir de deux listes déroulantes (objet, champ) plutôt que de la faire
// taper à la main. OPEN/CLOSE évitent d'écrire les deux accolades l'une
// contre l'autre dans le source (Jinja les interpréterait comme le DÉBUT
// d'une expression s'il les rencontrait telles quelles).
var _FILTER_REF_OPEN = '{' + '{';
var _FILTER_REF_CLOSE = '}' + '}';
var _FILTER_REF_RE = new RegExp('^\\{\\{\\s*([^.{}]+)\\.([^.{}]+)\\s*\\}\\}$');
// Une paire d'accolades doublées entourant "$nom_variable" (voir
// _VAR_REF_PATTERN côté Python, filter_repeater_rows.py) référence une
// variable globale — le "$" la distingue sans ambiguïté de _FILTER_REF_RE
// ci-dessus, qui attend toujours un point ("Objet.champ"). Groupe 2 : un
// chemin optionnel ".champ"/"[index]" (chaînable) à l'intérieur de la
// variable, utile pour une variable "objet"/"tableau".
var _VAR_REF_RE = new RegExp('^\\{\\{\\s*\\$([^.{}\\[\\]]+)((?:\\.[^.{}\\[\\]]+|\\[\\d+\\])*)\\s*\\}\\}$');
function _filterValueDefinitionId(objName) {
var opt = document.querySelector('.filterValueObjSel option[value="' + CSS.escape(objName) + '"]');
return opt ? opt.dataset.definitionId : null;
}
function populateFilterValueFields(fieldSel, defId, selectedName) {
var info = (DEFINITIONS_DATA || {})[defId] || { fields: [] };
fieldSel.innerHTML = '<option value="">— champ —</option>' + info.fields.map(function (f) {
return '<option value="' + f.name + '"' + (f.name === selectedName ? ' selected' : '') + '>' + fieldOptionLabel(f) + '</option>';
}).join('');
}
function updateFilterValueFromDynamic(wrap) {
var objSel = wrap.querySelector('.filterValueObjSel');
var fieldSel = wrap.querySelector('.filterValueFieldSel');
var input = wrap.querySelector('.filterValueFixe input');
input.value = (objSel.value && fieldSel.value) ? (_FILTER_REF_OPEN + objSel.value + '.' + fieldSel.value + _FILTER_REF_CLOSE) : '';
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function updateFilterValueFromVariable(wrap) {
var varSel = wrap.querySelector('.filterValueVarSel');
var pathInput = wrap.querySelector('.filterValueVarPath');
var input = wrap.querySelector('.filterValueFixe input');
var path = pathInput ? pathInput.value.trim() : '';
input.value = varSel.value ? (_FILTER_REF_OPEN + '$' + varSel.value + path + _FILTER_REF_CLOSE) : '';
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function onFilterValueModeChange(sel) {
var wrap = sel.closest('.filterValueControl');
var fixe = wrap.querySelector('.filterValueFixe');
var dyn = wrap.querySelector('.filterValueDynamique');
var varWrap = wrap.querySelector('.filterValueVariable');
fixe.style.display = sel.value === 'fixe' ? 'block' : 'none';
dyn.style.display = sel.value === 'dynamique' ? 'block' : 'none';
varWrap.style.display = sel.value === 'variable' ? 'block' : 'none';
if (sel.value === 'dynamique') updateFilterValueFromDynamic(wrap);
else if (sel.value === 'variable') updateFilterValueFromVariable(wrap);
}
function onFilterValueObjChange(sel) {
var wrap = sel.closest('.filterValueControl');
populateFilterValueFields(wrap.querySelector('.filterValueFieldSel'), _filterValueDefinitionId(sel.value), '');
updateFilterValueFromDynamic(wrap);
}
function onFilterValueFieldChange(sel) {
updateFilterValueFromDynamic(sel.closest('.filterValueControl'));
}
function onFilterValueVarChange(sel) {
updateFilterValueFromVariable(sel.closest('.filterValueControl'));
}
function initFilterValuePickers() {
document.querySelectorAll('.filterValueControl').forEach(function (wrap) {
var modeSel = wrap.querySelector('.filterValueMode');
var objSel = wrap.querySelector('.filterValueObjSel');
var fieldSel = wrap.querySelector('.filterValueFieldSel');
var varSel = wrap.querySelector('.filterValueVarSel');
var varPathInput = wrap.querySelector('.filterValueVarPath');
var current = (wrap.dataset.currentValue || '').trim();
var varMatch = _VAR_REF_RE.exec(current);
var objMatch = _FILTER_REF_RE.exec(current);
if (varMatch) {
modeSel.value = 'variable';
wrap.querySelector('.filterValueFixe').style.display = 'none';
wrap.querySelector('.filterValueVariable').style.display = 'block';
varSel.value = varMatch[1].trim();
if (varPathInput) varPathInput.value = (varMatch[2] || '').trim();
} else if (objMatch) {
modeSel.value = 'dynamique';
wrap.querySelector('.filterValueFixe').style.display = 'none';
wrap.querySelector('.filterValueDynamique').style.display = 'block';
objSel.value = objMatch[1].trim();
populateFilterValueFields(fieldSel, _filterValueDefinitionId(objSel.value), objMatch[2].trim());
} else {
modeSel.value = 'fixe';
}
});
}
// Le retour/avance du navigateur (bouton "précédent") est déjà pris en
// charge par pjax.js (navigation globale sans rechargement, voir
// static/pjax.js) : il refait un rendu complet de <main> et rejoue ce
// script (voir runScriptsIn dans pjax.js), ce qui rappelle initBuilderPanel()
// ci-dessous — donc le canevas et le panneau de propriétés se retrouvent à
// jour automatiquement, sans qu'on ait besoin d'un second écouteur ici.
initBuilderPanel();
var CONDITION_OPERATORS_HTML = document.getElementById('nf-cond-operator') ? document.getElementById('nf-cond-operator').innerHTML : '';
var GLOBAL_VARIABLES_OPTIONS_HTML = document.getElementById('nf-cond-variable') ? document.getElementById('nf-cond-variable').innerHTML : '';
function toggleActionFields(sel) {
var form = sel.closest('.actionAddForm');
var screenSel = form.querySelector('.targetScreenSelect');
var elementFields = form.querySelector('.targetElementFields');
var dataFields = form.querySelector('.targetDataFields');
screenSel.style.display = sel.value === 'aller_a' ? 'block' : 'none';
elementFields.style.display = sel.value === 'modifier_element' ? 'block' : 'none';
dataFields.style.display = sel.value === 'modifier_donnee' ? 'block' : 'none';
if (sel.value === 'modifier_element') {
var propSel = elementFields.querySelector('.elementPropertySelect');
toggleElementPropertyValue(propSel);
}
if (sel.value === 'modifier_donnee') {
var defSel = dataFields.querySelector('.targetDefinitionSelect');
if (defSel) onDataDefinitionChange(defSel);
}
}
document.querySelectorAll('.actionTypeSelect').forEach(toggleActionFields);
function toggleElementPropertyValue(sel) {
var wrap = sel.closest('.targetElementFields');
var visSel = wrap.querySelector('.elementValueVisibility');
var colorInp = wrap.querySelector('.elementValueColor');
var numInp = wrap.querySelector('.elementValueNumber');
[visSel, colorInp, numInp].forEach(function(el){ el.style.display = 'none'; el.removeAttribute('name'); });
if (sel.value === 'visibilite') {
visSel.style.display = 'block'; visSel.setAttribute('name', 'element_value');
} else if (sel.value === 'bg_color' || sel.value === 'border_color') {
colorInp.style.display = 'block'; colorInp.setAttribute('name', 'element_value');
} else {
numInp.style.display = 'block'; numInp.setAttribute('name', 'element_value');
}
}
document.querySelectorAll('.elementPropertySelect').forEach(toggleElementPropertyValue);
function onDataDefinitionChange(sel) {
var wrap = sel.closest('.targetDataFields');
var info = DEFINITIONS_DATA[sel.value] || {rows: [], fields: []};
var rowSel = wrap.querySelector('.targetRowSelect');
var fieldSel = wrap.querySelector('.targetFieldSelect');
if (!rowSel || !fieldSel) return;
rowSel.innerHTML = info.rows.length
? info.rows.map(function(r){ return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('')
: '<option value="">Aucune donnée dans cet objet</option>';
fieldSel.innerHTML = info.fields.length
? info.fields.map(function(f){ return '<option value="' + f.name + '">' + f.name + ' (' + f.type + ')</option>'; }).join('')
: '<option value="">Aucun champ simple</option>';
}
document.querySelectorAll('.targetDefinitionSelect').forEach(onDataDefinitionChange);
function syncAlign(radio) {
radio.closest('.alignGroup').querySelectorAll('.alignOption').forEach(function(o){ o.classList.remove('active', 'is-primary'); });
radio.closest('.alignOption').classList.add('active', 'is-primary');
}
// Choix du format d'aperçu (Portrait/Paysage/Carré) : mémorisé côté serveur
// (voir screen_set_aspect.py) pour ne pas repartir sur Portrait à chaque
// ajout/suppression d'élément ou changement de sélection — ces actions
// redemandent ce panneau au serveur, qui doit donc connaître le choix déjà
// fait. Appelé depuis initBuilderPanel() (voir plus haut), une seule fois
// par (re)rendu de ce panneau.
// Phase 6 — musique de fond de cet écran : enregistrement en AJAX au
// blur/changement (pas d'autosave "au caractère près", même choix que le
// format d'aperçu ci-dessous), plus un bouton d'envoi de fichier qui
// remplit le champ URL puis déclenche le même enregistrement.
function saveScreenBackgroundMusic(url) {
return fetch("/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/background-music", {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url }),
});
}
function bindScreenBackgroundMusicField() {
var urlInput = document.getElementById('screen-bg-music-url');
if (urlInput) {
urlInput.addEventListener('change', function () { saveScreenBackgroundMusic(urlInput.value); });
}
document.querySelectorAll('.screenUploadInput').forEach(function (input) {
input.addEventListener('change', function () {
if (!input.files.length) return;
var status = input.parentElement.querySelector('.uploadStatus');
var targetField = document.getElementById(input.dataset.target);
status.textContent = 'Envoi en cours...';
var data = new FormData();
data.append('file', input.files[0]);
fetch("/game/" + GAME_SLUG + "/upload", { method: 'POST', body: data })
.then(function (r) { return r.json(); })
.then(function (json) {
if (json.url) {
targetField.value = json.url;
status.textContent = 'Fichier envoyé ✓ Enregistrement...';
saveScreenBackgroundMusic(json.url).then(function () { status.textContent = 'Fichier envoyé ✓ Enregistré.'; });
}
else { status.textContent = "Échec de l'envoi."; }
})
.catch(function () { status.textContent = "Échec de l'envoi."; });
});
});
}
function bindAspectButtons() {
document.querySelectorAll('.aspectBtn').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('.aspectBtn').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
// Dans l'éditeur (≥1300px), le canevas remplit tout .canvasFrame
// (voir .canvas dans style.css) — cet aspect-ratio ne sert donc plus
// qu'au repli en page empilée (<1300px, voir le media query) et à
// l'aperçu jouable ("Jouer"), pas à la mise en page ici.
document.getElementById('canvas').style.aspectRatio = btn.dataset.aspect;
fetch("/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/aspect", {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ aspect: btn.dataset.aspect }),
});
});
});
}
document.querySelectorAll('.uploadInput').forEach(function(input){
input.addEventListener('change', function(){
if (!input.files.length) return;
var status = input.parentElement.querySelector('.uploadStatus');
var targetField = document.getElementById(input.dataset.target);
status.textContent = 'Envoi en cours...';
var data = new FormData();
data.append('file', input.files[0]);
fetch("/game/" + GAME_SLUG + "/upload", {method: 'POST', body: data})
.then(function(r){ return r.json(); })
.then(function(json){
if (json.url) {
targetField.value = json.url;
status.textContent = 'Fichier envoyé ✓ Enregistrement...';
// Enregistre tout de suite (comme un clic sur "Enregistrer") pour
// qu'un fichier envoyé ne reste jamais "en attente" si on oublie
// de cliquer sur le bouton ensuite — l'image doit apparaître dès
// l'envoi, sans étape supplémentaire à retenir.
var form = document.getElementById('propsForm');
if (form) { form.requestSubmit ? form.requestSubmit() : form.submit(); }
}
else { status.textContent = "Échec de l'envoi."; }
})
.catch(function(){ status.textContent = "Échec de l'envoi."; });
});
});