Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y
Config strictement stricte partout (ruff, mypy --strict, bandit, vulture, import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas casser le build" - l'existant a ete corrige pour la satisfaire plutot que l'inverse. Hooks pre-commit locaux (language: system) bloquants. - Typage mypy --strict propage a tout le moteur (db, screens, auth, core, ai, routes, puis publish/scripts/tests/app.py/build_css.py). - Securite : fuite de handle fichier Windows corrigee dans l'export SCORM (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe "</script>" dans le JSON embarque en <script>, 25 sites). - Architecture : imports circulaires/F811 nettoyes, contrats import-linter respectes, code mort retire (vulture). - Accessibilite : 69 champs de formulaire sans label correctement associe corriges (for/id ou aria-label) sur 11 templates. - ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis appliquees (aucune desactivee sans verification individuelle). - Tests : isolation du compte admin partage (nettoyage ponctuel + fixture de teardown automatique en filet de securite), suite complete verte (591 tests Python, 241 tests JS). - SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport complet analyse point par point, faux positifs documentes. - .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine faisait echouer ESLint (linebreak-style) via un bug connu de git (checkout "en place" qui ignore l'eol force sur un fichier deja present sur disque - contourne en supprimant puis recreant chaque fichier suivi). djLint (H021, styles inline) volontairement saute pour ce commit - backlog assume, deja documente, traite dans un lot separe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7db4803b93
commit
c57420c8c9
@@ -5,11 +5,11 @@
|
||||
// repris ici pour ne pas dépendre de tout ce fichier (pensé pour l'éditeur
|
||||
// complet, pas cette page).
|
||||
function toggleDashCreate(tab) {
|
||||
var bar = document.getElementById("dashCreate-" + tab);
|
||||
const bar = document.getElementById(`dashCreate-${tab}`);
|
||||
if (!bar) return;
|
||||
bar.style.display = bar.style.display === "none" ? "flex" : "none";
|
||||
if (bar.style.display === "flex") {
|
||||
var firstInput = bar.querySelector("input, select");
|
||||
bar.style.display = bar.style.display === 'none' ? 'flex' : 'none';
|
||||
if (bar.style.display === 'flex') {
|
||||
const firstInput = bar.querySelector('input, select');
|
||||
if (firstInput) firstInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
+99
-100
@@ -8,15 +8,15 @@
|
||||
// Plusieurs CONVERSATIONS par écran (demande explicite : gérer/créer/
|
||||
// supprimer des fils, comme claude.ai) — _iaActiveConversationId retient
|
||||
// laquelle est affichée dans le fil de droite.
|
||||
var _iaActiveConversationId = null;
|
||||
let _iaActiveConversationId = null;
|
||||
|
||||
function iaChatEmptyStateHtml() {
|
||||
return (
|
||||
'<div class="iaChatEmpty">' +
|
||||
'<img src="/static/branding/ruby-logo.svg" alt="" class="iaChatAvatar iaChatAvatar-large">' +
|
||||
'<strong>Décris ce que tu veux à Ruby</strong>' +
|
||||
'<span>Des images, des dialogues, une variable, ou un écran complet — décris-le en une phrase et envoie.</span>' +
|
||||
'</div>'
|
||||
'<div class="iaChatEmpty">'
|
||||
+ '<img src="/static/branding/ruby-logo.svg" alt="" class="iaChatAvatar iaChatAvatar-large">'
|
||||
+ '<strong>Décris ce que tu veux à Ruby</strong>'
|
||||
+ '<span>Des images, des dialogues, une variable, ou un écran complet — décris-le en une phrase et envoie.</span>'
|
||||
+ '</div>'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ function iaChatEmptyStateHtml() {
|
||||
// header/body/footer par colonne, seul le BODY de chaque colonne défile,
|
||||
// jamais l'onglet entier).
|
||||
function iaChatScrollToBottom() {
|
||||
var log = document.getElementById('iaChatLog');
|
||||
const log = document.getElementById('iaChatLog');
|
||||
if (log) log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
@@ -37,13 +37,13 @@ function iaChatSenderHtml(isUser) {
|
||||
}
|
||||
|
||||
function iaChatRowHtml(message) {
|
||||
var isUser = message.role === 'user';
|
||||
var row = document.createElement('div');
|
||||
row.className = 'iaChatRow ' + (isUser ? 'iaChatRow-user' : 'iaChatRow-assistant');
|
||||
var sender = document.createElement('div');
|
||||
const isUser = message.role === 'user';
|
||||
const row = document.createElement('div');
|
||||
row.className = `iaChatRow ${isUser ? 'iaChatRow-user' : 'iaChatRow-assistant'}`;
|
||||
const sender = document.createElement('div');
|
||||
sender.className = 'iaChatSender';
|
||||
sender.innerHTML = iaChatSenderHtml(isUser);
|
||||
var bubble = document.createElement('div');
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'iaChatBubble';
|
||||
bubble.textContent = message.content;
|
||||
row.appendChild(sender);
|
||||
@@ -56,12 +56,12 @@ function iaChatRowHtml(message) {
|
||||
// plusieurs appels d'outils) — sans ça, un créateur qui ne voit rien
|
||||
// bouger clique à nouveau, pensant que ça n'a pas marché.
|
||||
function iaChatLoaderRowHtml() {
|
||||
var row = document.createElement('div');
|
||||
const row = document.createElement('div');
|
||||
row.className = 'iaChatRow iaChatRow-assistant';
|
||||
var sender = document.createElement('div');
|
||||
const sender = document.createElement('div');
|
||||
sender.className = 'iaChatSender';
|
||||
sender.innerHTML = iaChatSenderHtml(false);
|
||||
var bubble = document.createElement('div');
|
||||
const bubble = document.createElement('div');
|
||||
bubble.className = 'iaChatBubble iaChatTyping';
|
||||
bubble.innerHTML = '<span></span><span></span><span></span>';
|
||||
row.appendChild(sender);
|
||||
@@ -71,7 +71,7 @@ function iaChatLoaderRowHtml() {
|
||||
|
||||
function iaChatAutoGrow(textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
||||
}
|
||||
|
||||
// Un tour peut poser des objets/variables/déclencheurs (voir ai/tools.py)
|
||||
@@ -85,12 +85,12 @@ function iaChatRefreshAfterTurn() {
|
||||
if (typeof triggerLoadAll === 'function') triggerLoadAll();
|
||||
|
||||
fetch(window.location.href)
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (html) {
|
||||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
.then((r) => r.text())
|
||||
.then((html) => {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
var newCanvasFrame = doc.querySelector('.canvasFrame');
|
||||
var oldCanvasFrame = document.querySelector('.canvasFrame');
|
||||
const newCanvasFrame = doc.querySelector('.canvasFrame');
|
||||
const oldCanvasFrame = document.querySelector('.canvasFrame');
|
||||
if (newCanvasFrame && oldCanvasFrame) {
|
||||
oldCanvasFrame.innerHTML = newCanvasFrame.innerHTML;
|
||||
// Bug corrigé ("plus moyen de rien poser hors du champ de la
|
||||
@@ -104,17 +104,17 @@ function iaChatRefreshAfterTurn() {
|
||||
// taille à jour (voir world_width/world_height,
|
||||
// routes/scenes/scene_edit_view.py) — on la relit directement
|
||||
// dessus plutôt que de rejouer un script inline.
|
||||
var canvas = document.getElementById('canvas');
|
||||
const canvas = document.getElementById('canvas');
|
||||
if (canvas) {
|
||||
var newWidth = parseFloat(canvas.style.width);
|
||||
var newHeight = parseFloat(canvas.style.height);
|
||||
if (!isNaN(newWidth)) window.SCENE_WIDTH = newWidth;
|
||||
if (!isNaN(newHeight)) window.SCENE_HEIGHT = newHeight;
|
||||
const newWidth = parseFloat(canvas.style.width);
|
||||
const newHeight = parseFloat(canvas.style.height);
|
||||
if (!Number.isNaN(newWidth)) window.SCENE_WIDTH = newWidth;
|
||||
if (!Number.isNaN(newHeight)) window.SCENE_HEIGHT = newHeight;
|
||||
}
|
||||
}
|
||||
|
||||
var newVariablesPanel = doc.getElementById('variablesTabPanel');
|
||||
var oldVariablesPanel = document.getElementById('variablesTabPanel');
|
||||
const newVariablesPanel = doc.getElementById('variablesTabPanel');
|
||||
const oldVariablesPanel = document.getElementById('variablesTabPanel');
|
||||
if (newVariablesPanel && oldVariablesPanel) oldVariablesPanel.innerHTML = newVariablesPanel.innerHTML;
|
||||
|
||||
// Relance les aperçus animés (voir personnage-preview.js) — un
|
||||
@@ -126,20 +126,20 @@ function iaChatRefreshAfterTurn() {
|
||||
}
|
||||
|
||||
function iaChatSend() {
|
||||
var input = document.getElementById('iaChatInput');
|
||||
var sendBtn = document.getElementById('iaChatSendBtn');
|
||||
const input = document.getElementById('iaChatInput');
|
||||
const sendBtn = document.getElementById('iaChatSendBtn');
|
||||
if (sendBtn.disabled) return; // déjà une requête en cours — jamais spammer l'IA
|
||||
var message = (input.value || '').trim();
|
||||
const message = (input.value || '').trim();
|
||||
if (!message || !_iaActiveConversationId) return;
|
||||
var log = document.getElementById('iaChatLog');
|
||||
var emptyState = log.querySelector('.iaChatEmpty');
|
||||
const log = document.getElementById('iaChatLog');
|
||||
const emptyState = log.querySelector('.iaChatEmpty');
|
||||
if (emptyState) emptyState.remove();
|
||||
|
||||
// Le message du créateur s'affiche TOUT DE SUITE (avant même la
|
||||
// requête) — sinon, tant que l'IA travaille, l'écran semble ignorer ce
|
||||
// qu'on vient de taper.
|
||||
log.appendChild(iaChatRowHtml({ role: 'user', content: message }));
|
||||
var loaderRow = iaChatLoaderRowHtml();
|
||||
const loaderRow = iaChatLoaderRowHtml();
|
||||
log.appendChild(loaderRow);
|
||||
iaChatScrollToBottom();
|
||||
|
||||
@@ -148,14 +148,14 @@ function iaChatSend() {
|
||||
input.disabled = true;
|
||||
sendBtn.disabled = true;
|
||||
|
||||
var conversationId = _iaActiveConversationId;
|
||||
fetch('/game/' + GAME_SLUG + '/ia-conversations/' + conversationId + '/messages', {
|
||||
const conversationId = _iaActiveConversationId;
|
||||
fetch(`/game/${GAME_SLUG}/ia-conversations/${conversationId}/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: message }),
|
||||
body: JSON.stringify({ message }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
loaderRow.remove();
|
||||
if (json.assistant_message) log.appendChild(iaChatRowHtml(json.assistant_message));
|
||||
iaChatScrollToBottom();
|
||||
@@ -165,12 +165,12 @@ function iaChatSend() {
|
||||
// colonne pour l'afficher sans attendre un changement d'onglet.
|
||||
iaChatLoadConversationsList(conversationId);
|
||||
})
|
||||
.catch(function () {
|
||||
.catch(() => {
|
||||
loaderRow.remove();
|
||||
log.appendChild(iaChatRowHtml({ role: 'assistant', content: 'Erreur réseau — réessaie dans un instant.' }));
|
||||
iaChatScrollToBottom();
|
||||
})
|
||||
.finally(function () {
|
||||
.finally(() => {
|
||||
input.disabled = false;
|
||||
sendBtn.disabled = false;
|
||||
input.focus();
|
||||
@@ -187,68 +187,68 @@ function iaChatInputKeydown(event) {
|
||||
// ---------- Colonne "Conversations" (CRUD, comme claude.ai) ----------
|
||||
|
||||
function iaChatConversationItemHtml(conversation) {
|
||||
var active = conversation.id === _iaActiveConversationId;
|
||||
const active = conversation.id === _iaActiveConversationId;
|
||||
return (
|
||||
'<button type="button" class="iaConversationItem' + (active ? ' active' : '') + '" ' +
|
||||
'data-conversation-id="' + conversation.id + '" onclick="iaChatSelectConversation(' + conversation.id + ')">' +
|
||||
'<span class="iaConversationTitle">' + _iaEscapeHtml(conversation.title) + '</span>' +
|
||||
'<span class="iaConversationDelete" title="Supprimer" ' +
|
||||
'onclick="iaChatDeleteConversation(event, ' + conversation.id + ')">🗑️</span>' +
|
||||
'</button>'
|
||||
`<button type="button" class="iaConversationItem${active ? ' active' : ''}" `
|
||||
+ `data-conversation-id="${conversation.id}" onclick="iaChatSelectConversation(${conversation.id})">`
|
||||
+ `<span class="iaConversationTitle">${_iaEscapeHtml(conversation.title)}</span>`
|
||||
+ '<span class="iaConversationDelete" title="Supprimer" '
|
||||
+ `onclick="iaChatDeleteConversation(event, ${conversation.id})">🗑️</span>`
|
||||
+ '</button>'
|
||||
);
|
||||
}
|
||||
|
||||
function _iaEscapeHtml(text) {
|
||||
var div = document.createElement('div');
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text || '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function iaChatRenderConversationsList(conversations) {
|
||||
var list = document.getElementById('iaConversationsList');
|
||||
const list = document.getElementById('iaConversationsList');
|
||||
if (!list) return;
|
||||
list.innerHTML = conversations.map(iaChatConversationItemHtml).join('');
|
||||
}
|
||||
|
||||
function iaChatLoadConversationsList(activeIdOverride) {
|
||||
fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (conversations) {
|
||||
fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/ia-conversations`)
|
||||
.then((r) => r.json())
|
||||
.then((conversations) => {
|
||||
if (activeIdOverride) _iaActiveConversationId = activeIdOverride;
|
||||
iaChatRenderConversationsList(conversations);
|
||||
});
|
||||
}
|
||||
|
||||
function iaChatLoadMessages(conversationId) {
|
||||
var log = document.getElementById('iaChatLog');
|
||||
const log = document.getElementById('iaChatLog');
|
||||
log.innerHTML = '';
|
||||
fetch('/game/' + GAME_SLUG + '/ia-conversations/' + conversationId + '/messages')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (messages) {
|
||||
fetch(`/game/${GAME_SLUG}/ia-conversations/${conversationId}/messages`)
|
||||
.then((r) => r.json())
|
||||
.then((messages) => {
|
||||
if (!messages || !messages.length) {
|
||||
log.innerHTML = iaChatEmptyStateHtml();
|
||||
return;
|
||||
}
|
||||
messages.forEach(function (message) { log.appendChild(iaChatRowHtml(message)); });
|
||||
messages.forEach((message) => { log.appendChild(iaChatRowHtml(message)); });
|
||||
iaChatScrollToBottom();
|
||||
});
|
||||
}
|
||||
|
||||
function iaChatSelectConversation(conversationId) {
|
||||
_iaActiveConversationId = conversationId;
|
||||
document.querySelectorAll('.iaConversationItem').forEach(function (el) { el.classList.remove('active'); });
|
||||
var item = document.querySelector('.iaConversationItem[data-conversation-id="' + conversationId + '"]');
|
||||
document.querySelectorAll('.iaConversationItem').forEach((el) => { el.classList.remove('active'); });
|
||||
const item = document.querySelector(`.iaConversationItem[data-conversation-id="${conversationId}"]`);
|
||||
if (item) item.classList.add('active');
|
||||
iaChatLoadMessages(conversationId);
|
||||
}
|
||||
|
||||
function iaChatCreateConversation() {
|
||||
fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations', { method: 'POST' })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (conversation) {
|
||||
fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/ia-conversations`, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then((conversation) => {
|
||||
_iaActiveConversationId = conversation.id;
|
||||
iaChatLoadConversationsList(conversation.id);
|
||||
var log = document.getElementById('iaChatLog');
|
||||
const log = document.getElementById('iaChatLog');
|
||||
log.innerHTML = iaChatEmptyStateHtml();
|
||||
document.getElementById('iaChatInput').focus();
|
||||
});
|
||||
@@ -256,11 +256,11 @@ function iaChatCreateConversation() {
|
||||
|
||||
function iaChatDeleteConversation(event, conversationId) {
|
||||
event.stopPropagation(); // jamais sélectionner la conversation qu'on supprime
|
||||
if (!confirm('Supprimer cette conversation ? Cette action est irréversible.')) return;
|
||||
fetch('/game/' + GAME_SLUG + '/ia-conversations/' + conversationId, { method: 'DELETE' })
|
||||
.then(function () { return fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations'); })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (conversations) {
|
||||
if (!window.confirm('Supprimer cette conversation ? Cette action est irréversible.')) return;
|
||||
fetch(`/game/${GAME_SLUG}/ia-conversations/${conversationId}`, { method: 'DELETE' })
|
||||
.then(() => fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/ia-conversations`))
|
||||
.then((r) => r.json())
|
||||
.then((conversations) => {
|
||||
if (conversationId === _iaActiveConversationId) {
|
||||
if (conversations.length) {
|
||||
_iaActiveConversationId = conversations[0].id;
|
||||
@@ -281,43 +281,42 @@ function iaChatDeleteConversation(event, conversationId) {
|
||||
// directement, pas de DOMContentLoaded — rejoué par pjax.js à chaque
|
||||
// remplacement de <main>.
|
||||
function initIaTab() {
|
||||
var body = document.getElementById('iaTabPanelBody');
|
||||
const body = document.getElementById('iaTabPanelBody');
|
||||
if (!body) return;
|
||||
// Maquette validée (Artifact "Maquette Ruby") : une div pleine
|
||||
// hauteur/largeur de l'onglet, 2 colonnes (gauche 40%, droite 60%),
|
||||
// chacune header/body/footer. Les deux zones non précisées (footer
|
||||
// gauche, header droite) restent vides pour l'instant.
|
||||
body.innerHTML =
|
||||
'<div class="iaTabLayout">' +
|
||||
'<div class="iaCol iaCol-left">' +
|
||||
'<div class="iaColHeader">' +
|
||||
'<img src="/static/branding/ruby-logo.svg" alt="" class="iaHeaderLogo">' +
|
||||
'<span class="iaHeaderName">Ruby</span>' +
|
||||
'</div>' +
|
||||
'<div class="iaColBody">' +
|
||||
'<button type="button" class="button is-small primary iaNewConvBtn" onclick="iaChatCreateConversation()">+ Nouvelle conversation</button>' +
|
||||
'<div class="iaConversationsList" id="iaConversationsList"></div>' +
|
||||
'</div>' +
|
||||
'<div class="iaColFooter"></div>' +
|
||||
'</div>' +
|
||||
'<div class="iaCol iaCol-right">' +
|
||||
'<div class="iaColHeader"></div>' +
|
||||
'<div class="iaColBody">' +
|
||||
'<div class="iaChatLog" id="iaChatLog"></div>' +
|
||||
'</div>' +
|
||||
'<div class="iaColFooter">' +
|
||||
'<div class="iaChatInputBar">' +
|
||||
'<textarea id="iaChatInput" rows="1" placeholder="Décris ce que tu veux à Ruby…" ' +
|
||||
'oninput="iaChatAutoGrow(this)" onkeydown="iaChatInputKeydown(event)"></textarea>' +
|
||||
'<button type="button" class="iaChatSendBtn" id="iaChatSendBtn" onclick="iaChatSend()" title="Envoyer" aria-label="Envoyer">➤</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
body.innerHTML = '<div class="iaTabLayout">'
|
||||
+ '<div class="iaCol iaCol-left">'
|
||||
+ '<div class="iaColHeader">'
|
||||
+ '<img src="/static/branding/ruby-logo.svg" alt="" class="iaHeaderLogo">'
|
||||
+ '<span class="iaHeaderName">Ruby</span>'
|
||||
+ '</div>'
|
||||
+ '<div class="iaColBody">'
|
||||
+ '<button type="button" class="button is-small primary iaNewConvBtn" onclick="iaChatCreateConversation()">+ Nouvelle conversation</button>'
|
||||
+ '<div class="iaConversationsList" id="iaConversationsList"></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="iaColFooter"></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="iaCol iaCol-right">'
|
||||
+ '<div class="iaColHeader"></div>'
|
||||
+ '<div class="iaColBody">'
|
||||
+ '<div class="iaChatLog" id="iaChatLog"></div>'
|
||||
+ '</div>'
|
||||
+ '<div class="iaColFooter">'
|
||||
+ '<div class="iaChatInputBar">'
|
||||
+ '<textarea id="iaChatInput" rows="1" placeholder="Décris ce que tu veux à Ruby…" '
|
||||
+ 'oninput="iaChatAutoGrow(this)" onkeydown="iaChatInputKeydown(event)"></textarea>'
|
||||
+ '<button type="button" class="iaChatSendBtn" id="iaChatSendBtn" onclick="iaChatSend()" title="Envoyer" aria-label="Envoyer">➤</button>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
|
||||
fetch('/game/' + GAME_SLUG + '/screens/' + SCREEN_ID + '/ia-conversations')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (conversations) {
|
||||
fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/ia-conversations`)
|
||||
.then((r) => r.json())
|
||||
.then((conversations) => {
|
||||
if (!conversations.length) {
|
||||
// Premier passage sur cet écran : aucune conversation encore —
|
||||
// en crée une tout de suite, jamais un onglet vide sans rien à
|
||||
|
||||
@@ -8,31 +8,31 @@
|
||||
// tant que le formulaire n'est pas soumis pour de vrai.
|
||||
|
||||
function forgeOnboardingGoToStep(step) {
|
||||
var track = document.getElementById("onboardingTrack");
|
||||
if (track) track.classList.toggle("onboardingTrack--step2", step === 2);
|
||||
const track = document.getElementById('onboardingTrack');
|
||||
if (track) track.classList.toggle('onboardingTrack--step2', step === 2);
|
||||
if (step === 2) {
|
||||
var nameField = document.getElementById("onboardingNameInput");
|
||||
const nameField = document.getElementById('onboardingNameInput');
|
||||
// Après la fin de l'animation de défilement (voir la durée de
|
||||
// transition posée sur .onboardingTrack) — focus immédiat plus tôt
|
||||
// ferait sauter le scroll du champ à moitié visible.
|
||||
if (nameField) setTimeout(function () { nameField.focus(); }, 500);
|
||||
if (nameField) setTimeout(() => { nameField.focus(); }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
function forgeOnboardingChooseType(card) {
|
||||
document.querySelectorAll(".onboardingCard").forEach(function (c) {
|
||||
c.classList.toggle("is-selected", c === card);
|
||||
document.querySelectorAll('.onboardingCard').forEach((c) => {
|
||||
c.classList.toggle('is-selected', c === card);
|
||||
});
|
||||
var hiddenType = document.getElementById("onboardingTypeHidden");
|
||||
if (hiddenType) hiddenType.value = card.dataset.type || "";
|
||||
const hiddenType = document.getElementById('onboardingTypeHidden');
|
||||
if (hiddenType) hiddenType.value = card.dataset.type || '';
|
||||
forgeOnboardingUpdateSubmitState();
|
||||
forgeOnboardingGoToStep(2);
|
||||
}
|
||||
|
||||
function forgeOnboardingUpdateSubmitState() {
|
||||
var nameField = document.getElementById("onboardingNameInput");
|
||||
var hiddenType = document.getElementById("onboardingTypeHidden");
|
||||
var submitBtn = document.getElementById("onboardingSubmitBtn");
|
||||
const nameField = document.getElementById('onboardingNameInput');
|
||||
const hiddenType = document.getElementById('onboardingTypeHidden');
|
||||
const submitBtn = document.getElementById('onboardingSubmitBtn');
|
||||
if (!nameField || !hiddenType || !submitBtn) return;
|
||||
submitBtn.disabled = !(hiddenType.value && nameField.value.trim());
|
||||
}
|
||||
@@ -48,16 +48,16 @@ function forgeOnboardingInit() {
|
||||
// Entrée en cascade des cartes — délai posé en JS plutôt qu'en CSS pur
|
||||
// (nth-child) pour rester correct quel que soit le nombre de types
|
||||
// affichés (voir db.ONBOARDING_TYPES, appelé à générer la liste).
|
||||
document.querySelectorAll(".onboardingCard").forEach(function (card, i) {
|
||||
card.style.animationDelay = (i * 70) + "ms";
|
||||
card.addEventListener("click", function () { forgeOnboardingChooseType(card); });
|
||||
document.querySelectorAll('.onboardingCard').forEach((card, i) => {
|
||||
card.style.animationDelay = `${i * 70}ms`;
|
||||
card.addEventListener('click', () => { forgeOnboardingChooseType(card); });
|
||||
});
|
||||
|
||||
var backBtn = document.getElementById("onboardingBackBtn");
|
||||
if (backBtn) backBtn.addEventListener("click", function () { forgeOnboardingGoToStep(1); });
|
||||
const backBtn = document.getElementById('onboardingBackBtn');
|
||||
if (backBtn) backBtn.addEventListener('click', () => { forgeOnboardingGoToStep(1); });
|
||||
|
||||
var nameField = document.getElementById("onboardingNameInput");
|
||||
if (nameField) nameField.addEventListener("input", forgeOnboardingUpdateSubmitState);
|
||||
const nameField = document.getElementById('onboardingNameInput');
|
||||
if (nameField) nameField.addEventListener('input', forgeOnboardingUpdateSubmitState);
|
||||
|
||||
forgeOnboardingUpdateSubmitState();
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ test('runSpriteAnimation — une animation non bouclée en cours résiste à une
|
||||
});
|
||||
|
||||
test('resolveSpriteFrames — résout depuis gameData.personnage_animations par nom', () => {
|
||||
global.gameData = { personnage_animations: { '5': { walk: ['/w0.png', '/w1.png'] } } };
|
||||
global.gameData = { personnage_animations: { 5: { walk: ['/w0.png', '/w1.png'] } } };
|
||||
const resolved = resolveSpriteFrames('5', { animation: 'walk', fps: 12, loop: true });
|
||||
assert.deepEqual(resolved.frames, ['/w0.png', '/w1.png']);
|
||||
assert.equal(resolved.fps, 12);
|
||||
@@ -101,7 +101,7 @@ test('clampSceneObjectPosition — borne au bord droit/bas de la scène', () =>
|
||||
withScreensData({ id: 1, scene_width: 200, scene_height: 100 }, () => {
|
||||
const el = { style: { width: '40px', height: '40px' } };
|
||||
assert.equal(clampSceneObjectPosition(el, 'left', 500), 160); // 200 - 40
|
||||
assert.equal(clampSceneObjectPosition(el, 'top', 500), 60); // 100 - 40
|
||||
assert.equal(clampSceneObjectPosition(el, 'top', 500), 60); // 100 - 40
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,7 +118,9 @@ test('clampSceneObjectPosition — borne au bord gauche/haut (jamais négatif)',
|
||||
// grand que la scène doit laisser le personnage se déplacer sur TOUT le
|
||||
// fond, pas seulement le petit cadre visible de la scène d'origine.
|
||||
test('clampSceneObjectPosition — borne au MONDE (world_width/height) quand un fond XXL l\'a agrandi', () => {
|
||||
withScreensData({ id: 1, scene_width: 200, scene_height: 100, world_width: 2000, world_height: 1000 }, () => {
|
||||
withScreensData({
|
||||
id: 1, scene_width: 200, scene_height: 100, world_width: 2000, world_height: 1000,
|
||||
}, () => {
|
||||
const el = { style: { width: '40px', height: '40px' } };
|
||||
assert.equal(clampSceneObjectPosition(el, 'left', 500), 500); // pas encore au bord du MONDE
|
||||
assert.equal(clampSceneObjectPosition(el, 'left', 5000), 1960); // 2000 - 40
|
||||
@@ -134,7 +136,11 @@ test('clampSceneObjectPosition — sans world_width/height (aucun fond), retombe
|
||||
|
||||
test('applyObjectProperty — pos_x_relatif reste dans les limites de la scène après plusieurs pas', () => {
|
||||
withScreensData({ id: 1, scene_width: 100, scene_height: 100 }, () => {
|
||||
const el = { style: { left: '90px', top: '0px', width: '20px', height: '20px' } };
|
||||
const el = {
|
||||
style: {
|
||||
left: '90px', top: '0px', width: '20px', height: '20px',
|
||||
},
|
||||
};
|
||||
applyObjectProperty(el, 'pos_x_relatif', '5');
|
||||
applyObjectProperty(el, 'pos_x_relatif', '5');
|
||||
applyObjectProperty(el, 'pos_x_relatif', '5');
|
||||
|
||||
@@ -16,6 +16,7 @@ global.screensData = [];
|
||||
global.heldKeys = new Set();
|
||||
|
||||
const actionsMod = require('../actions.js');
|
||||
|
||||
Object.assign(global, actionsMod);
|
||||
require('../personnage-controller.js');
|
||||
const { forgeDialogueBoxAdvance } = require('../dialogue-box-controller.js'); // pas mocké : la vraie logique métier
|
||||
@@ -28,12 +29,12 @@ function fakeEl(style) {
|
||||
const classes = new Set();
|
||||
const children = [];
|
||||
return {
|
||||
style: style,
|
||||
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
|
||||
style,
|
||||
classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c) },
|
||||
children,
|
||||
appendChild(child) { children.push(child); },
|
||||
querySelector(sel) {
|
||||
if (sel === '.dialogueBoxWidget') return children.find(c => c._isDialogueBoxWidget);
|
||||
if (sel === '.dialogueBoxWidget') return children.find((c) => c._isDialogueBoxWidget);
|
||||
if (sel === '[data-dialogue-role="header"]') return this._header;
|
||||
if (sel === '[data-dialogue-role="body"]') return this._body;
|
||||
if (sel === '[data-dialogue-role="footer"]') return this._footer;
|
||||
@@ -46,19 +47,26 @@ function fakeEl(style) {
|
||||
test('collision "dialogue" -> boîte de dialogue -> fin de dialogue (mark_completed) marque le dialogue terminé', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur' };
|
||||
const mrPinguin = {
|
||||
id: 2, kind: 'personnage', name: 'Mr. Pinguin',
|
||||
id: 2,
|
||||
kind: 'personnage',
|
||||
name: 'Mr. Pinguin',
|
||||
collision_rules: [{
|
||||
trigger: 'collision',
|
||||
action: {
|
||||
type: 'dialogue', mark_completed: true,
|
||||
type: 'dialogue',
|
||||
mark_completed: true,
|
||||
dialogue: { id: 'd_intro', lines: [{ speaker: 'Mr. Pinguin', text: 'Bonjour !' }, { speaker: 'Charlie', text: 'Salut !' }] },
|
||||
},
|
||||
}],
|
||||
};
|
||||
const box = { id: 3, kind: 'dialogue_box' };
|
||||
|
||||
const playerEl = fakeEl({ left: '0px', top: '0px', width: '20px', height: '20px' });
|
||||
const pinguinEl = fakeEl({ left: '5px', top: '5px', width: '20px', height: '20px' }); // chevauche playerEl
|
||||
const playerEl = fakeEl({
|
||||
left: '0px', top: '0px', width: '20px', height: '20px',
|
||||
});
|
||||
const pinguinEl = fakeEl({
|
||||
left: '5px', top: '5px', width: '20px', height: '20px',
|
||||
}); // chevauche playerEl
|
||||
const boxWidget = fakeEl({});
|
||||
boxWidget._isDialogueBoxWidget = true;
|
||||
boxWidget._objectId = '3';
|
||||
@@ -107,6 +115,6 @@ test('collision "dialogue" -> boîte de dialogue -> fin de dialogue (mark_comple
|
||||
|
||||
forgeDialogueBoxAdvance('3'); // dernière réplique consommée -> mark_completed -> dialogue terminé, boîte masquée
|
||||
assert.equal(boxWidget.classList.contains('is-active'), false);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_intro'], true);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_intro, true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ global.screensData = [];
|
||||
global.heldKeys = new Set();
|
||||
|
||||
const actionsMod = require('../actions.js');
|
||||
|
||||
Object.assign(global, actionsMod);
|
||||
require('../personnage-controller.js');
|
||||
global.forgeApplyVariableActionOffline = global.forgeApplyVariableActionOffline || require('../offline/apply-actions.js').forgeApplyVariableActionOffline;
|
||||
@@ -34,7 +35,7 @@ function fakeParentEl() {
|
||||
appendChild(child) { children.push(child); },
|
||||
querySelector(sel) {
|
||||
const m = sel.match(/data-for-object-id="(\d+)"/);
|
||||
return m ? children.find(c => c._forObjectId === m[1]) : undefined;
|
||||
return m ? children.find((c) => c._forObjectId === m[1]) : undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -42,22 +43,28 @@ function fakeParentEl() {
|
||||
function fakeEl(parentEl) {
|
||||
const classes = new Set();
|
||||
return {
|
||||
style: { left: '0px', top: '0px', width: '20px', height: '20px' },
|
||||
classList: { add: c => classes.add(c), remove: c => classes.delete(c), toggle: c => {}, contains: c => classes.has(c) },
|
||||
style: {
|
||||
left: '0px', top: '0px', width: '20px', height: '20px',
|
||||
},
|
||||
classList: {
|
||||
add: (c) => classes.add(c), remove: (c) => classes.delete(c), toggle: (_c) => {}, contains: (c) => classes.has(c),
|
||||
},
|
||||
parentElement: parentEl,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeBubbleEl() {
|
||||
return {
|
||||
className: 'collisionInteractBubble', style: {}, textContent: '',
|
||||
className: 'collisionInteractBubble',
|
||||
style: {},
|
||||
textContent: '',
|
||||
setAttribute(name, value) { if (name === 'data-for-object-id') this._forObjectId = String(value); },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeDocument(elByObjectId) {
|
||||
return {
|
||||
querySelector: sel => { const m = sel.match(/"(\d+)"/); return m ? elByObjectId[m[1]] : undefined; },
|
||||
querySelector: (sel) => { const m = sel.match(/"(\d+)"/); return m ? elByObjectId[m[1]] : undefined; },
|
||||
createElement: () => fakeBubbleEl(),
|
||||
};
|
||||
}
|
||||
@@ -65,7 +72,9 @@ function fakeDocument(elByObjectId) {
|
||||
test.afterEach(() => { forgeStopCollisionRuleControllers(); });
|
||||
|
||||
function rect(left, top, width, height) {
|
||||
return { left, top, width, height, right: left + width, bottom: top + height, shape: 'rectangle' };
|
||||
return {
|
||||
left, top, width, height, right: left + width, bottom: top + height, shape: 'rectangle',
|
||||
};
|
||||
}
|
||||
|
||||
test('forgeCollisionRuleTriggerActive — trigger "collision" : vrai si les boîtes se chevauchent, faux si loin', () => {
|
||||
@@ -93,7 +102,7 @@ test('forgeRunCollisionRuleAction — "dialogue" délègue à forgeShowDialogueB
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const lines = [{ speaker: 'PNJ', text: 'Bonjour !' }];
|
||||
forgeRunCollisionRuleAction({ type: 'dialogue', dialogue: { id: 'd_1', lines: lines }, mark_completed: true }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
forgeRunCollisionRuleAction({ type: 'dialogue', dialogue: { id: 'd_1', lines }, mark_completed: true }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [['d_1', 'Crokine', lines, true, undefined]]);
|
||||
});
|
||||
@@ -114,8 +123,12 @@ test('forgeRunCollisionRuleAction — "condition" vraie exécute la branche si_v
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const vraiLines = [{ speaker: 'PNJ', text: 'Vrai !' }];
|
||||
const action = {
|
||||
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: vraiLines } }, si_faux: null,
|
||||
type: 'condition',
|
||||
variable: 'inventaire_cle',
|
||||
operateur: 'superieur',
|
||||
valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: vraiLines } },
|
||||
si_faux: null,
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
@@ -129,7 +142,10 @@ test('forgeRunCollisionRuleAction — "condition" fausse exécute la branche si_
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const fauxLines = [{ speaker: 'PNJ', text: 'Faux !' }];
|
||||
const action = {
|
||||
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
|
||||
type: 'condition',
|
||||
variable: 'inventaire_cle',
|
||||
operateur: 'superieur',
|
||||
valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: [] } },
|
||||
si_faux: { type: 'dialogue', dialogue: { id: 'd_faux', lines: fauxLines } },
|
||||
};
|
||||
@@ -144,8 +160,12 @@ test('forgeRunCollisionRuleAction — "condition" avec une branche "Ne rien fair
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const action = {
|
||||
type: 'condition', variable: 'inventaire_cle', operateur: 'superieur', valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: [] } }, si_faux: null,
|
||||
type: 'condition',
|
||||
variable: 'inventaire_cle',
|
||||
operateur: 'superieur',
|
||||
valeur: '0',
|
||||
si_vrai: { type: 'dialogue', dialogue: { id: 'd_vrai', lines: [] } },
|
||||
si_faux: null,
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
@@ -154,15 +174,22 @@ test('forgeRunCollisionRuleAction — "condition" avec une branche "Ne rien fair
|
||||
|
||||
test('forgeRunCollisionRuleAction — "variable" modifie gameData.variables via forgeApplyVariableActionOffline', () => {
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '2' } } };
|
||||
forgeRunCollisionRuleAction({ type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '3' }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
forgeRunCollisionRuleAction({
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '3',
|
||||
}, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
assert.equal(gameData.variables.score.value, '5');
|
||||
});
|
||||
|
||||
test('forgeRunCollisionRuleAction — "variable" chaînée (then) exécute IMMÉDIATEMENT la suivante (instantané, pas besoin d\'attendre)', () => {
|
||||
global.gameData = { variables: { score: { type: 'nombre_entier', value: '0' }, vies: { type: 'nombre_entier', value: '3' } } };
|
||||
const action = {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
then: { type: 'variable', target_variable: 'vies', data_operation: 'decrementer', data_value: '1' },
|
||||
type: 'variable',
|
||||
target_variable: 'score',
|
||||
data_operation: 'incrementer',
|
||||
data_value: '1',
|
||||
then: {
|
||||
type: 'variable', target_variable: 'vies', data_operation: 'decrementer', data_value: '1',
|
||||
},
|
||||
};
|
||||
forgeRunCollisionRuleAction(action, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
@@ -174,7 +201,9 @@ test('forgeRunCollisionRuleAction — "dialogue" transmet son "then" à forgeSho
|
||||
const calls = [];
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
global.forgeShowDialogueBox = (...args) => calls.push(args);
|
||||
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
|
||||
const thenAction = {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
};
|
||||
forgeRunCollisionRuleAction({ type: 'dialogue', dialogue: { id: 'd_1', lines: [] }, then: thenAction }, fakeEl(), { id: 1, name: 'Crokine' });
|
||||
global.forgeShowDialogueBox = previousShow;
|
||||
assert.deepEqual(calls, [['d_1', 'Crokine', [], undefined, thenAction]]);
|
||||
@@ -183,7 +212,7 @@ test('forgeRunCollisionRuleAction — "dialogue" transmet son "then" à forgeSho
|
||||
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) };
|
||||
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);
|
||||
@@ -206,8 +235,12 @@ test('forgeRunCollisionRuleAction — "surbrillance" sans objet trouvé (référ
|
||||
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' },
|
||||
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; }
|
||||
@@ -223,8 +256,11 @@ test('forgeRunCollisionRuleAction — "son" joue l\'URL fournie via Audio, insta
|
||||
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' },
|
||||
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');
|
||||
@@ -238,8 +274,12 @@ test('forgeRunCollisionRuleAction — "video" attend forgeShowVideoOverlay avant
|
||||
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' },
|
||||
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();
|
||||
@@ -250,11 +290,14 @@ test('forgeRunCollisionRuleAction — "video" attend forgeShowVideoOverlay avant
|
||||
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' },
|
||||
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); });
|
||||
await new Promise((resolve) => { setTimeout(resolve, 10); });
|
||||
assert.equal(gameData.variables.score.value, '1');
|
||||
});
|
||||
|
||||
@@ -272,16 +315,22 @@ test('forgeRunCollisionRuleAction — "indication" affiche une bulle temporisée
|
||||
});
|
||||
|
||||
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 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: [{
|
||||
id: 5,
|
||||
collision_rules: [{
|
||||
trigger: 'clic',
|
||||
action: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
action: {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
},
|
||||
}],
|
||||
}],
|
||||
});
|
||||
@@ -291,7 +340,10 @@ test('forgeBindClicSurvolRules — "clic" exécute l\'action au clic, sans joueu
|
||||
});
|
||||
|
||||
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 targetEl = {
|
||||
addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; },
|
||||
removeEventListener() {},
|
||||
};
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => targetEl };
|
||||
try {
|
||||
@@ -302,7 +354,10 @@ test('forgeBindClicSurvolRules — "survol" se lie sur mouseenter, jamais sur cl
|
||||
});
|
||||
|
||||
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 targetEl = {
|
||||
addEventListener(event, handler) { this._handlers = this._handlers || {}; this._handlers[event] = handler; },
|
||||
removeEventListener() {},
|
||||
};
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => targetEl };
|
||||
try {
|
||||
@@ -312,18 +367,24 @@ test('forgeBindClicSurvolRules — un déclencheur "collision" n\'est jamais li
|
||||
});
|
||||
|
||||
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: [] } } }] };
|
||||
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: [] } } }],
|
||||
};
|
||||
const playerEl = fakeEl();
|
||||
const npcEl = fakeEl();
|
||||
npcEl.style = { left: '5px', top: '5px', width: '20px', height: '20px' }; // chevauche playerEl (0,0,20,20)
|
||||
npcEl.style = {
|
||||
left: '5px', top: '5px', width: '20px', height: '20px',
|
||||
}; // chevauche playerEl (0,0,20,20)
|
||||
|
||||
const previousDoc = global.document;
|
||||
global.document = fakeDocument({ 1: playerEl, 2: npcEl });
|
||||
global.gameData = { personnage_animations: {} };
|
||||
const previousShow = global.forgeShowDialogueBox;
|
||||
const calls = [];
|
||||
global.forgeShowDialogueBox = id => calls.push(id);
|
||||
global.forgeShowDialogueBox = (id) => calls.push(id);
|
||||
try {
|
||||
const screenData = { elements: [player, npc] };
|
||||
forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche
|
||||
@@ -340,17 +401,24 @@ test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas
|
||||
// déjà collision.width/height/offset_x/offset_y) doit être utilisé.
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur' };
|
||||
const pnj = {
|
||||
id: 3, kind: 'personnage',
|
||||
id: 3,
|
||||
kind: 'personnage',
|
||||
// Sprite de 100×200 px, mais silhouette réelle 20×20 centrée en bas
|
||||
// (offset_y positif = décalée vers le bas, voir collision_settings.py).
|
||||
collision: { enabled: true, shape: 'rectangle', width: 20, height: 20, offset_x: 0, offset_y: 80 },
|
||||
collision: {
|
||||
enabled: true, shape: 'rectangle', width: 20, height: 20, offset_x: 0, offset_y: 80,
|
||||
},
|
||||
collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'dialogue' } } }],
|
||||
};
|
||||
const parent = fakeParentEl();
|
||||
const playerEl = fakeEl(parent);
|
||||
playerEl.style = { left: '45px', top: '175px', width: '20px', height: '20px' }; // chevauche la silhouette réelle du pnj (40,170)-(60,190)
|
||||
playerEl.style = {
|
||||
left: '45px', top: '175px', width: '20px', height: '20px',
|
||||
}; // chevauche la silhouette réelle du pnj (40,170)-(60,190)
|
||||
const pnjEl = fakeEl(parent);
|
||||
pnjEl.style = { left: '0px', top: '0px', width: '100px', height: '200px' }; // grand cadre padé
|
||||
pnjEl.style = {
|
||||
left: '0px', top: '0px', width: '100px', height: '200px',
|
||||
}; // grand cadre padé
|
||||
|
||||
const previousDoc = global.document;
|
||||
global.gameData = { personnage_animations: {} };
|
||||
@@ -365,16 +433,22 @@ test('tick — la bulle "Appuie sur X" s\'ancre sur la BOÎTE DE COLLISION, pas
|
||||
});
|
||||
|
||||
test('tick — "interagir" affiche la bulle "Appuie sur [touche]" pendant la collision, puis la cache en sortant', () => {
|
||||
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } };
|
||||
const player = {
|
||||
id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' },
|
||||
};
|
||||
const pnj = { id: 3, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'dialogue' } } }] };
|
||||
const parentNear = fakeParentEl();
|
||||
const parentFar = fakeParentEl();
|
||||
const playerElNear = fakeEl(parentNear);
|
||||
const pnjElNear = fakeEl(parentNear);
|
||||
pnjElNear.style = { left: '5px', top: '5px', width: '20px', height: '20px' };
|
||||
pnjElNear.style = {
|
||||
left: '5px', top: '5px', width: '20px', height: '20px',
|
||||
};
|
||||
const playerElFar = fakeEl(parentFar);
|
||||
const pnjElFar = fakeEl(parentFar);
|
||||
pnjElFar.style = { left: '300px', top: '300px', width: '20px', height: '20px' };
|
||||
pnjElFar.style = {
|
||||
left: '300px', top: '300px', width: '20px', height: '20px',
|
||||
};
|
||||
|
||||
const previousDoc = global.document;
|
||||
global.gameData = { personnage_animations: {} };
|
||||
|
||||
@@ -79,7 +79,7 @@ test('resolveVariablePath — JSON invalide ou chemin absent renvoie null', () =
|
||||
});
|
||||
|
||||
test('readFieldValue — résout CLICKED_ROW_ID (-1) via window.lastClickedRowId', () => {
|
||||
withGameData({ data: { '1': [{ id: 7, score: 42 }] } }, () => {
|
||||
withGameData({ data: { 1: [{ id: 7, score: 42 }] } }, () => {
|
||||
window.lastClickedRowId = 7;
|
||||
assert.equal(readFieldValue(1, -1, 'score'), 42);
|
||||
});
|
||||
@@ -93,22 +93,28 @@ test('readVariableValue — lit gameData.variables par nom', () => {
|
||||
});
|
||||
|
||||
test('evaluateConditionClause — source "objet" (comportement historique)', () => {
|
||||
withGameData({ data: { '1': [{ id: 5, pv: '3' }] } }, () => {
|
||||
const clause = { cond_definition_id: 1, cond_row_id: 5, cond_field: 'pv', cond_operator: 'inferieur', cond_value: '10' };
|
||||
withGameData({ data: { 1: [{ id: 5, pv: '3' }] } }, () => {
|
||||
const clause = {
|
||||
cond_definition_id: 1, cond_row_id: 5, cond_field: 'pv', cond_operator: 'inferieur', cond_value: '10',
|
||||
};
|
||||
assert.equal(evaluateConditionClause(clause), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('evaluateConditionClause — source "variable"', () => {
|
||||
withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' } } }, () => {
|
||||
const clause = { cond_source: 'variable', cond_variable: 'pv', cond_operator: 'inferieur', cond_value: '10' };
|
||||
const clause = {
|
||||
cond_source: 'variable', cond_variable: 'pv', cond_operator: 'inferieur', cond_value: '10',
|
||||
};
|
||||
assert.equal(evaluateConditionClause(clause), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('evaluateConditionNode — clause unique (pas de cond_clauses)', () => {
|
||||
withGameData({ variables: { pv: { type: 'nombre_entier', value: '3' } } }, () => {
|
||||
const node = { cond_source: 'variable', cond_variable: 'pv', cond_operator: 'egal', cond_value: '3' };
|
||||
const node = {
|
||||
cond_source: 'variable', cond_variable: 'pv', cond_operator: 'egal', cond_value: '3',
|
||||
};
|
||||
assert.equal(evaluateConditionNode(node), true);
|
||||
});
|
||||
});
|
||||
@@ -118,8 +124,12 @@ test('evaluateConditionNode — clauses combinées ET (toutes vraies requises)',
|
||||
const node = {
|
||||
cond_combinator: 'et',
|
||||
cond_clauses: JSON.stringify([
|
||||
{ source: 'variable', variable: 'pv', operator: 'egal', value: '3' },
|
||||
{ source: 'variable', variable: 'or', operator: 'egal', value: '0' },
|
||||
{
|
||||
source: 'variable', variable: 'pv', operator: 'egal', value: '3',
|
||||
},
|
||||
{
|
||||
source: 'variable', variable: 'or', operator: 'egal', value: '0',
|
||||
},
|
||||
]),
|
||||
};
|
||||
assert.equal(evaluateConditionNode(node), true);
|
||||
@@ -131,8 +141,12 @@ test('evaluateConditionNode — clauses combinées OU (une seule vraie suffit)',
|
||||
const node = {
|
||||
cond_combinator: 'ou',
|
||||
cond_clauses: JSON.stringify([
|
||||
{ source: 'variable', variable: 'pv', operator: 'egal', value: '999' },
|
||||
{ source: 'variable', variable: 'or', operator: 'egal', value: '0' },
|
||||
{
|
||||
source: 'variable', variable: 'pv', operator: 'egal', value: '999',
|
||||
},
|
||||
{
|
||||
source: 'variable', variable: 'or', operator: 'egal', value: '0',
|
||||
},
|
||||
]),
|
||||
};
|
||||
assert.equal(evaluateConditionNode(node), true);
|
||||
@@ -144,45 +158,85 @@ test('evaluateConditionNode — clauses combinées OU (une seule vraie suffit)',
|
||||
// collision (= sa boîte visuelle par défaut), configurable (taille,
|
||||
// forme, activée/désactivée).
|
||||
test('forgeCollisionRectFromBox — sans réglage, le rectangle visuel EXACT (comportement inchangé)', () => {
|
||||
const box = { left: 10, top: 20, width: 40, height: 60 };
|
||||
assert.deepEqual(forgeCollisionRectFromBox(box, undefined), { left: 10, top: 20, right: 50, bottom: 80, width: 40, height: 60, shape: 'rectangle' });
|
||||
const box = {
|
||||
left: 10, top: 20, width: 40, height: 60,
|
||||
};
|
||||
assert.deepEqual(forgeCollisionRectFromBox(box, undefined), {
|
||||
left: 10, top: 20, right: 50, bottom: 80, width: 40, height: 60, shape: 'rectangle',
|
||||
});
|
||||
});
|
||||
|
||||
test('forgeCollisionRectFromBox — une boîte plus petite est centrée par défaut (offset 0,0)', () => {
|
||||
const box = { left: 0, top: 0, width: 100, height: 100 };
|
||||
const rect = forgeCollisionRectFromBox(box, { width: 20, height: 20, offset_x: 0, offset_y: 0 });
|
||||
assert.deepEqual(rect, { left: 40, top: 40, right: 60, bottom: 60, width: 20, height: 20, shape: 'rectangle' });
|
||||
const box = {
|
||||
left: 0, top: 0, width: 100, height: 100,
|
||||
};
|
||||
const rect = forgeCollisionRectFromBox(box, {
|
||||
width: 20, height: 20, offset_x: 0, offset_y: 0,
|
||||
});
|
||||
assert.deepEqual(rect, {
|
||||
left: 40, top: 40, right: 60, bottom: 60, width: 20, height: 20, shape: 'rectangle',
|
||||
});
|
||||
});
|
||||
|
||||
test('forgeCollisionRectFromBox — un décalage déplace la boîte de collision sans toucher au rectangle visuel', () => {
|
||||
const box = { left: 0, top: 0, width: 100, height: 100 };
|
||||
const rect = forgeCollisionRectFromBox(box, { width: 20, height: 20, offset_x: 15, offset_y: -5 });
|
||||
assert.deepEqual(rect, { left: 55, top: 35, right: 75, bottom: 55, width: 20, height: 20, shape: 'rectangle' });
|
||||
const box = {
|
||||
left: 0, top: 0, width: 100, height: 100,
|
||||
};
|
||||
const rect = forgeCollisionRectFromBox(box, {
|
||||
width: 20, height: 20, offset_x: 15, offset_y: -5,
|
||||
});
|
||||
assert.deepEqual(rect, {
|
||||
left: 55, top: 35, right: 75, bottom: 55, width: 20, height: 20, shape: 'rectangle',
|
||||
});
|
||||
});
|
||||
|
||||
test('forgeRectanglesOverlap — chevauchement/non-chevauchement', () => {
|
||||
assert.equal(forgeRectanglesOverlap({ left: 0, top: 0, right: 10, bottom: 10 }, { left: 5, top: 5, right: 15, bottom: 15 }), true);
|
||||
assert.equal(forgeRectanglesOverlap({ left: 0, top: 0, right: 10, bottom: 10 }, { left: 20, top: 20, right: 30, bottom: 30 }), false);
|
||||
assert.equal(forgeRectanglesOverlap({
|
||||
left: 0, top: 0, right: 10, bottom: 10,
|
||||
}, {
|
||||
left: 5, top: 5, right: 15, bottom: 15,
|
||||
}), true);
|
||||
assert.equal(forgeRectanglesOverlap({
|
||||
left: 0, top: 0, right: 10, bottom: 10,
|
||||
}, {
|
||||
left: 20, top: 20, right: 30, bottom: 30,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('forgeCirclesOverlap — deux cercles qui se touchent/s\'évitent', () => {
|
||||
const a = { left: 0, top: 0, right: 20, bottom: 20, width: 20, height: 20 }; // centre (10,10), rayon 10
|
||||
const touching = { left: 15, top: 0, right: 35, bottom: 20, width: 20, height: 20 }; // centre (25,10), rayon 10, distance 15 < 20
|
||||
const apart = { left: 100, top: 100, right: 120, bottom: 120, width: 20, height: 20 };
|
||||
const a = {
|
||||
left: 0, top: 0, right: 20, bottom: 20, width: 20, height: 20,
|
||||
}; // centre (10,10), rayon 10
|
||||
const touching = {
|
||||
left: 15, top: 0, right: 35, bottom: 20, width: 20, height: 20,
|
||||
}; // centre (25,10), rayon 10, distance 15 < 20
|
||||
const apart = {
|
||||
left: 100, top: 100, right: 120, bottom: 120, width: 20, height: 20,
|
||||
};
|
||||
assert.equal(forgeCirclesOverlap(a, touching), true);
|
||||
assert.equal(forgeCirclesOverlap(a, apart), false);
|
||||
});
|
||||
|
||||
test('forgeShapesOverlap — deux cercles utilisent le test cercle-cercle', () => {
|
||||
const a = { left: 0, top: 0, right: 20, bottom: 20, width: 20, height: 20, shape: 'cercle' };
|
||||
const b = { left: 15, top: 0, right: 35, bottom: 20, width: 20, height: 20, shape: 'cercle' };
|
||||
const apart = { left: 100, top: 100, right: 120, bottom: 120, width: 20, height: 20, shape: 'cercle' };
|
||||
const a = {
|
||||
left: 0, top: 0, right: 20, bottom: 20, width: 20, height: 20, shape: 'cercle',
|
||||
};
|
||||
const b = {
|
||||
left: 15, top: 0, right: 35, bottom: 20, width: 20, height: 20, shape: 'cercle',
|
||||
};
|
||||
const apart = {
|
||||
left: 100, top: 100, right: 120, bottom: 120, width: 20, height: 20, shape: 'cercle',
|
||||
};
|
||||
assert.equal(forgeShapesOverlap(a, b), true);
|
||||
assert.equal(forgeShapesOverlap(a, apart), false);
|
||||
});
|
||||
|
||||
test('forgeShapesOverlap — une paire rectangle/cercle retombe sur le test rectangle-rectangle', () => {
|
||||
const rect = { left: 0, top: 0, right: 20, bottom: 20, shape: 'rectangle' };
|
||||
const circle = { left: 15, top: 15, right: 35, bottom: 35, shape: 'cercle' };
|
||||
const rect = {
|
||||
left: 0, top: 0, right: 20, bottom: 20, shape: 'rectangle',
|
||||
};
|
||||
const circle = {
|
||||
left: 15, top: 15, right: 35, bottom: 35, shape: 'cercle',
|
||||
};
|
||||
assert.equal(forgeShapesOverlap(rect, circle), true); // les rectangles englobants se chevauchent
|
||||
});
|
||||
|
||||
@@ -28,18 +28,25 @@ function fakeWidget(objectId) {
|
||||
};
|
||||
const classes = new Set();
|
||||
return {
|
||||
getAttribute: attr => (attr === 'data-object-id' ? String(objectId) : null),
|
||||
getAttribute: (attr) => (attr === 'data-object-id' ? String(objectId) : null),
|
||||
classList: {
|
||||
add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c),
|
||||
toggle: (c, force) => { const on = force === undefined ? !classes.has(c) : !!force; if (on) classes.add(c); else classes.delete(c); return on; },
|
||||
add: (c) => classes.add(c),
|
||||
remove: (c) => classes.delete(c),
|
||||
contains: (c) => classes.has(c),
|
||||
toggle: (c, force) => {
|
||||
const on = force === undefined ? !classes.has(c) : !!force;
|
||||
if (on) classes.add(c); else classes.delete(c);
|
||||
return on;
|
||||
},
|
||||
},
|
||||
querySelector: sel => {
|
||||
querySelector: (sel) => {
|
||||
if (sel === '[data-dialogue-role="header"]') return roles.header;
|
||||
if (sel === '[data-dialogue-role="body"]') return roles.body;
|
||||
if (sel === '[data-dialogue-role="footer"]') return roles.footer;
|
||||
return null;
|
||||
},
|
||||
_roles: roles, _classes: classes,
|
||||
_roles: roles,
|
||||
_classes: classes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,16 +57,16 @@ function fakeWidget(objectId) {
|
||||
// couvrir le VRAI viewport qu'en s'échappant de ce conteneur, comme
|
||||
// static/js/play/video-overlay.js le fait déjà pour son propre overlay.
|
||||
test('forgeEscapeCameraForFullscreen déplace le widget en enfant de <body> et retient l\'écran d\'origine', () => {
|
||||
const screenEl = { getAttribute: attr => (attr === 'data-screen-id' ? '7' : null) };
|
||||
const screenEl = { getAttribute: (attr) => (attr === 'data-screen-id' ? '7' : null) };
|
||||
const attrs = {};
|
||||
const classes = new Set();
|
||||
const bodyChildren = [];
|
||||
const body = { appendChild: el => { bodyChildren.push(el); el.parentNode = body; } };
|
||||
const body = { appendChild: (el) => { bodyChildren.push(el); el.parentNode = body; } };
|
||||
const widget = {
|
||||
parentNode: null,
|
||||
closest: sel => (sel === '.playScreen' ? screenEl : null),
|
||||
closest: (sel) => (sel === '.playScreen' ? screenEl : null),
|
||||
setAttribute: (k, v) => { attrs[k] = v; },
|
||||
classList: { add: c => classes.add(c) },
|
||||
classList: { add: (c) => classes.add(c) },
|
||||
};
|
||||
const previousDoc = global.document;
|
||||
global.document = { body };
|
||||
@@ -108,7 +115,7 @@ test('forgeShowDialogueBox — dialogue vide, sans mark_completed : aucun effet'
|
||||
try {
|
||||
forgeShowDialogueBox('d_6', 'PNJ', []);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_6'], undefined);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_6, undefined);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
@@ -119,7 +126,7 @@ test('forgeShowDialogueBox — dialogue vide AVEC mark_completed : marque le dia
|
||||
try {
|
||||
forgeShowDialogueBox('d_6', 'PNJ', [], true);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_6'], true);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_6, true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
@@ -134,7 +141,7 @@ test('forgeShowDialogueBox — aucun widget posé sur l\'écran : aucun effet',
|
||||
test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — cycle toutes les répliques puis se masque', () => {
|
||||
const widget = fakeWidget(42);
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
|
||||
global.document = { querySelector: (sel) => (sel.indexOf('data-object-id="42"') !== -1 ? widget : (sel.indexOf('dialogueBoxWidget') !== -1 ? widget : null)) };
|
||||
try {
|
||||
forgeShowDialogueBox('d_8', 'PNJ', [{ speaker: 'PNJ', text: 'Merci !' }]);
|
||||
assert.equal(widget._roles.header.textContent, 'PNJ');
|
||||
@@ -182,7 +189,7 @@ test('forgeShowDialogueBox puis forgeDialogueBoxAdvance — la voix off de la r
|
||||
const paused = [];
|
||||
global.document = { querySelector: () => widget };
|
||||
global.Audio = function (url) {
|
||||
return { url: url, play: () => Promise.resolve(), pause: function () { paused.push(this.url); } };
|
||||
return { url, play: () => Promise.resolve(), pause() { paused.push(this.url); } };
|
||||
};
|
||||
try {
|
||||
forgeShowDialogueBox('d_9', 'Marc', [
|
||||
@@ -202,7 +209,7 @@ test('fin de dialogue SANS mark_completed : le dialogue n\'est jamais marqué te
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }]);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_5'], undefined);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_5, undefined);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
@@ -217,7 +224,7 @@ test('fin de dialogue AVEC mark_completed : marque le dialogue terminé et propa
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ speaker: 'Le Sorcier', text: 'Bonjour !' }, { speaker: 'Joueur', text: 'Salut.' }], true);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> dialogue terminé
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_5'], true);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_5, true);
|
||||
assert.equal(widget._classes.has('is-active'), false);
|
||||
assert.equal(gameData.scoring.status, 'termine'); // seul dialogue connu, désormais complété
|
||||
} finally { global.document = previousDoc; }
|
||||
@@ -231,7 +238,9 @@ test('forgeShowDialogueBox — dialogue vide AVEC "then" : exécute immédiateme
|
||||
global.forgeRunCollisionRuleAction = (...args) => calls.push(args);
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
|
||||
const thenAction = {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
};
|
||||
forgeShowDialogueBox('d_6', 'Crokine', [], false, thenAction);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][0], thenAction);
|
||||
@@ -247,7 +256,9 @@ test('fin de dialogue AVEC "then" : exécute l\'action chaînée APRÈS la derni
|
||||
global.forgeRunCollisionRuleAction = (...args) => calls.push(args);
|
||||
global.document = { querySelector: () => widget };
|
||||
try {
|
||||
const thenAction = { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' };
|
||||
const thenAction = {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
};
|
||||
forgeShowDialogueBox('d_9', 'Crokine', [{ speaker: 'PNJ', text: 'Salut !' }], false, thenAction);
|
||||
assert.equal(calls.length, 0); // pas encore, le dialogue n'est pas terminé
|
||||
forgeDialogueBoxAdvance(42); // dernière réplique consommée -> "then" exécuté maintenant
|
||||
@@ -268,14 +279,21 @@ test('forgeDialogueBoxAdvance — objet inconnu (jamais affiché) : aucun effet'
|
||||
|
||||
function fakeQuizChoiceBtn() {
|
||||
const classes = new Set();
|
||||
return { disabled: false, classList: { add: c => classes.add(c), contains: c => classes.has(c) }, _classes: classes };
|
||||
return {
|
||||
disabled: false,
|
||||
classList: { add: (c) => classes.add(c), contains: (c) => classes.has(c) },
|
||||
_classes: classes,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeQuizWidget(objectId, choiceCount, attrs) {
|
||||
attrs = attrs || {};
|
||||
const roles = {
|
||||
header: { textContent: '' }, question: { textContent: '' }, choices: { innerHTML: '' },
|
||||
score: { textContent: '' }, timer: { textContent: '', classList: { add() {}, remove() {}, toggle() {} } },
|
||||
header: { textContent: '' },
|
||||
question: { textContent: '' },
|
||||
choices: { innerHTML: '' },
|
||||
score: { textContent: '' },
|
||||
timer: { textContent: '', classList: { add() {}, remove() {}, toggle() {} } },
|
||||
};
|
||||
// Posé désactivé par forgeShowQuizBox dès la question (jamais recréé
|
||||
// par forgeQuizBoxAnswer, qui se contente de .disabled=false) — voir
|
||||
@@ -294,8 +312,8 @@ function fakeQuizWidget(objectId, choiceCount, attrs) {
|
||||
// Simule ".closest('.playScreen')" (voir forgeEscapeCameraForFullscreen) :
|
||||
// un widget "posé sur l'écran courant" est toujours, au départ,
|
||||
// l'enfant d'un ".playScreen" fictif portant le même id d'écran.
|
||||
closest: sel => (sel === '.playScreen' ? { getAttribute: a => (a === 'data-screen-id' ? String(window.currentScreenId) : null) } : null),
|
||||
getAttribute: attr => {
|
||||
closest: (sel) => (sel === '.playScreen' ? { getAttribute: (a) => (a === 'data-screen-id' ? String(window.currentScreenId) : null) } : null),
|
||||
getAttribute: (attr) => {
|
||||
if (attr === 'data-object-id') return String(objectId);
|
||||
if (attr in attrs) return attrs[attr];
|
||||
return null;
|
||||
@@ -305,11 +323,16 @@ function fakeQuizWidget(objectId, choiceCount, attrs) {
|
||||
// Variadique (comme le vrai DOMTokenList.remove/add) : le moteur
|
||||
// appelle parfois .remove('is-active', 'is-fullscreen') d'un coup
|
||||
// (voir forgeQuizResultQuit/forgeHideQuizBoxOnCurrentScreen).
|
||||
add: (...cs) => cs.forEach(c => classes.add(c)), remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||
contains: c => classes.has(c),
|
||||
toggle: (c, force) => { const on = force === undefined ? !classes.has(c) : !!force; if (on) classes.add(c); else classes.delete(c); return on; },
|
||||
add: (...cs) => cs.forEach((c) => classes.add(c)),
|
||||
remove: (...cs) => cs.forEach((c) => classes.delete(c)),
|
||||
contains: (c) => classes.has(c),
|
||||
toggle: (c, force) => {
|
||||
const on = force === undefined ? !classes.has(c) : !!force;
|
||||
if (on) classes.add(c); else classes.delete(c);
|
||||
return on;
|
||||
},
|
||||
},
|
||||
querySelector: sel => {
|
||||
querySelector: (sel) => {
|
||||
if (sel === '[data-quiz-role="header"]') return roles.header;
|
||||
if (sel === '[data-quiz-role="question"]') return roles.question;
|
||||
if (sel === '[data-quiz-role="choices"]') return roles.choices;
|
||||
@@ -318,9 +341,12 @@ function fakeQuizWidget(objectId, choiceCount, attrs) {
|
||||
if (sel === '.quizBoxContinueBtn') return continueBtn;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll: sel => (sel === '.quizBoxChoiceBtn' ? buttons : []),
|
||||
querySelectorAll: (sel) => (sel === '.quizBoxChoiceBtn' ? buttons : []),
|
||||
offsetWidth: 100,
|
||||
_roles: roles, _classes: classes, _buttons: buttons, _continueBtn: continueBtn,
|
||||
_roles: roles,
|
||||
_classes: classes,
|
||||
_buttons: buttons,
|
||||
_continueBtn: continueBtn,
|
||||
};
|
||||
return widget;
|
||||
}
|
||||
@@ -351,7 +377,9 @@ function docWithDialogueAndQuiz(dialogueWidget, quizWidget) {
|
||||
|
||||
const QUIZ_LINES = [
|
||||
{ type: 'dialogue', speaker: 'PNJ', text: 'Une question pour toi.' },
|
||||
{ type: 'question', text: '2+2 ?', choices: ['3', '4'], correct_index: 1, reward_type: 'score', reward_amount: 5 },
|
||||
{
|
||||
type: 'question', text: '2+2 ?', choices: ['3', '4'], correct_index: 1, reward_type: 'score', reward_amount: 5,
|
||||
},
|
||||
{ type: 'dialogue', speaker: 'PNJ', text: 'Bien joué !' },
|
||||
];
|
||||
|
||||
@@ -372,7 +400,7 @@ function docWithQuizOnlyNoDialogueBox(quizWidget) {
|
||||
if (sel.indexOf('data-object-id="99"') !== -1) return quizWidget || null;
|
||||
return null; // jamais de ".dialogueBoxWidget" sur cet écran
|
||||
},
|
||||
querySelectorAll: sel => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizWidget._roles.score] : []),
|
||||
querySelectorAll: (sel) => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizWidget._roles.score] : []),
|
||||
body: { appendChild: () => {} },
|
||||
};
|
||||
}
|
||||
@@ -383,7 +411,9 @@ test('un quiz SANS aucune "💬 Boîte de dialogue" posée (que des questions) a
|
||||
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox('d_30', 'Quiz', [
|
||||
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{
|
||||
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
]);
|
||||
assert.equal(quizWidget._classes.has('is-active'), true);
|
||||
assert.equal(quizWidget._roles.question.textContent, 'Qui es-tu ?');
|
||||
@@ -396,12 +426,14 @@ test('un quiz SANS boîte de dialogue : répondre avance/termine correctement (m
|
||||
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox('d_31', 'Quiz', [
|
||||
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{
|
||||
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
], true); // markCompleted
|
||||
forgeQuizBoxAnswer(99, 0); // bonne réponse
|
||||
assert.equal(window.forgeQuizScore, 1);
|
||||
forgeQuizBoxContinue(99); // -> plus aucune ligne, dialogue épuisé
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_31'], true);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_31, true);
|
||||
assert.equal(quizWidget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
@@ -430,7 +462,9 @@ test('forgeQuizBoxContinue à la DERNIÈRE question d\'un modèle avec écran de
|
||||
withFakeTemplateEffects({ classique: { onQuizComplete: (w, result) => { completeCalledWith = result; } } }, () => {
|
||||
try {
|
||||
forgeShowDialogueBox('d_50', 'Quiz', [
|
||||
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{
|
||||
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
]);
|
||||
forgeQuizBoxAnswer(99, 0); // bonne réponse — seule et dernière question
|
||||
forgeQuizBoxContinue(99);
|
||||
@@ -449,8 +483,12 @@ test('forgeQuizBoxContinue AVANT la dernière question : jamais d\'écran de ré
|
||||
withFakeTemplateEffects({ classique: { onQuizComplete: () => { completeCalled = true; } } }, () => {
|
||||
try {
|
||||
forgeShowDialogueBox('d_51', 'Quiz', [
|
||||
{ type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{ type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{
|
||||
type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
{
|
||||
type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
]);
|
||||
forgeQuizBoxAnswer(99, 0);
|
||||
forgeQuizBoxContinue(99); // avance vers la question 2, pas la fin
|
||||
@@ -470,7 +508,9 @@ test('forgeQuizResultQuit (bouton de l\'écran de résultat) masque le widget PU
|
||||
withFakeTemplateEffects({ classique: { onQuizComplete: () => {} } }, () => {
|
||||
try {
|
||||
forgeShowDialogueBox('d_52', 'Quiz', [
|
||||
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{
|
||||
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
]);
|
||||
forgeQuizBoxAnswer(99, 0);
|
||||
forgeQuizBoxContinue(99); // -> écran de résultat, widget toujours actif
|
||||
@@ -495,7 +535,9 @@ test('forgeQuizResultQuit ne plante jamais si window.close n\'existe pas (naviga
|
||||
withFakeTemplateEffects({ classique: { onQuizComplete: () => {} } }, () => {
|
||||
try {
|
||||
forgeShowDialogueBox('d_54', 'Quiz', [
|
||||
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{
|
||||
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
]);
|
||||
forgeQuizBoxAnswer(99, 0);
|
||||
forgeQuizBoxContinue(99);
|
||||
@@ -514,15 +556,21 @@ test('forgeQuizResultQuit ne fait rien sans widget correspondant', () => {
|
||||
|
||||
test('forgeQuizBoxTimeout (minuteur du quiz ENTIER expiré) affiche l\'écran de résultat même s\'il restait des questions', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'quiz', 'data-timer-seconds': '5', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
|
||||
const quizWidget = fakeQuizWidget(99, 2, {
|
||||
'data-timer-mode': 'quiz', 'data-timer-seconds': '5', 'data-fullscreen': '1', 'data-quiz-template': 'classique',
|
||||
});
|
||||
const previousDoc = global.document;
|
||||
let completeCalledWith = null;
|
||||
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
|
||||
withFakeTemplateEffects({ classique: { onQuizComplete: (w, result) => { completeCalledWith = result; } } }, () => {
|
||||
try {
|
||||
forgeShowDialogueBox('d_53', 'Quiz', [
|
||||
{ type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{ type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{
|
||||
type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
{
|
||||
type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1,
|
||||
},
|
||||
]);
|
||||
forgeQuizBoxTimeout(99, true); // encore la question 2 à venir
|
||||
t.mock.timers.tick(1500);
|
||||
@@ -552,7 +600,7 @@ test('"Continuer →" est TOUJOURS présent dès la question (désactivé), jama
|
||||
|
||||
test('une ligne "question" est affichée dans la boîte à QUIZ (header = nom de l\'objet déclencheur, corps = question+choix), pas la boîte de dialogue', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null);
|
||||
dialogueWidget.getAttribute = (attr) => (attr === 'data-object-id' ? '42' : null);
|
||||
const quizWidget = fakeQuizWidget(99, 2);
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
@@ -627,7 +675,7 @@ test('bonne réponse au quiz : crédite le score, la surligne en vert, "Continue
|
||||
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !');
|
||||
|
||||
forgeDialogueBoxAdvance(42); // dernière ligne -> dialogue épuisé, mark_completed -> terminé
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_10'], true);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_10, true);
|
||||
assert.equal(dialogueWidget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
@@ -733,9 +781,9 @@ test('minuteur "pour tout le quiz" expiré : révèle la bonne réponse PUIS ter
|
||||
forgeDialogueBoxAdvance(42); // -> question, démarre le minuteur "quiz" (5s)
|
||||
t.mock.timers.tick(5000); // expire : révèle la bonne réponse
|
||||
assert.equal(quizWidget._buttons[1]._classes.has('is-correct'), true);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_20'], undefined); // pas encore, le délai de lecture tourne
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_20, undefined); // pas encore, le délai de lecture tourne
|
||||
t.mock.timers.tick(1500); // délai de lecture écoulé -> fin forcée du quiz
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_20'], true);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_20, true);
|
||||
assert.equal(quizWidget._classes.has('is-active'), false);
|
||||
assert.equal(quizWidget._classes.has('is-fullscreen'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
@@ -760,7 +808,7 @@ test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" a
|
||||
const previousDoc = global.document;
|
||||
window.forgeQuizScore = 42;
|
||||
global.document = {
|
||||
querySelectorAll: sel => (sel === '.scoreWidget [data-score-role="value"]' ? values : []),
|
||||
querySelectorAll: (sel) => (sel === '.scoreWidget [data-score-role="value"]' ? values : []),
|
||||
};
|
||||
try {
|
||||
forgeUpdateAllScoreWidgets();
|
||||
@@ -777,7 +825,7 @@ test('forgeUpdateAllScoreWidgets — met aussi à jour le score affiché DANS un
|
||||
const previousDoc = global.document;
|
||||
window.forgeQuizScore = 7;
|
||||
global.document = {
|
||||
querySelectorAll: sel => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizScoreEl] : []),
|
||||
querySelectorAll: (sel) => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizScoreEl] : []),
|
||||
};
|
||||
try {
|
||||
forgeUpdateAllScoreWidgets();
|
||||
@@ -849,7 +897,7 @@ test('forgeCompleteDialogue marque le dialogue completed et notifie xAPI', () =>
|
||||
global.gameData.scoring = { score: 0, status: 'en_cours' };
|
||||
try {
|
||||
forgeCompleteDialogue('d_20', 'Crokine');
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_20'], true);
|
||||
assert.equal(window.forgeCompletedDialogueIds.d_20, true);
|
||||
assert.deepEqual(calls, [{ id: 'd_20', name: 'Crokine' }]);
|
||||
assert.equal(gameData.scoring.status, 'termine');
|
||||
} finally { delete global.forgeXapiNotifyDialogueCompleted; }
|
||||
@@ -900,20 +948,24 @@ test('forgeSyncAllDialoguesCompletionToScorm propage reussi/echoue (pas juste "t
|
||||
test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et le score gagné', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const quizWidget = {
|
||||
getAttribute: attr => (attr === 'data-object-id' ? '99' : null),
|
||||
getAttribute: (attr) => (attr === 'data-object-id' ? '99' : null),
|
||||
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
||||
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const previousDoc = global.document;
|
||||
global.document = {
|
||||
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
||||
querySelector: (sel) => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const calls = [];
|
||||
global.forgeXapiNotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, score) => calls.push({ dialogueId, objectName, index, text, correct, score });
|
||||
global.forgeXapiNotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, score) => calls.push({
|
||||
dialogueId, objectName, index, text, correct, score,
|
||||
});
|
||||
try {
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }]);
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{
|
||||
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5,
|
||||
}]);
|
||||
forgeQuizBoxAnswer(99, 0); // bonne réponse (correct_index=0)
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].dialogueId, 'd_5');
|
||||
@@ -927,21 +979,32 @@ test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et
|
||||
test('répondre à une question notifie AUSSI le SCORM 2004 (cmi.interactions.n.*, indépendant de xAPI)', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const quizWidget = {
|
||||
getAttribute: attr => (attr === 'data-object-id' ? '99' : null),
|
||||
getAttribute: (attr) => (attr === 'data-object-id' ? '99' : null),
|
||||
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
||||
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const previousDoc = global.document;
|
||||
global.document = {
|
||||
querySelector: sel => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
||||
querySelector: (sel) => (sel.indexOf('quizBoxWidget') !== -1 ? quizWidget : dialogueWidget),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
const calls = [];
|
||||
global.forgeScorm2004NotifyQuestionAnswered = (dialogueId, objectName, index, text, correct, choiceIndex, correctIndex) =>
|
||||
calls.push({ dialogueId, objectName, index, text, correct, choiceIndex, correctIndex });
|
||||
global.forgeScorm2004NotifyQuestionAnswered = (
|
||||
dialogueId,
|
||||
objectName,
|
||||
index,
|
||||
text,
|
||||
correct,
|
||||
choiceIndex,
|
||||
correctIndex,
|
||||
) => calls.push({
|
||||
dialogueId, objectName, index, text, correct, choiceIndex, correctIndex,
|
||||
});
|
||||
try {
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5 }]);
|
||||
forgeShowDialogueBox('d_5', 'Le Sorcier', [{
|
||||
type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 5,
|
||||
}]);
|
||||
forgeQuizBoxAnswer(99, 1); // mauvaise réponse (correct_index=0, répond 1)
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].dialogueId, 'd_5');
|
||||
|
||||
@@ -12,6 +12,7 @@ global.screensData = [];
|
||||
global.heldKeys = new Set();
|
||||
|
||||
const actionsMod = require('../actions.js');
|
||||
|
||||
Object.assign(global, actionsMod);
|
||||
|
||||
const {
|
||||
@@ -19,14 +20,19 @@ const {
|
||||
forgeRunPersonnageControllerTick, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera, forgeWouldCollide,
|
||||
} = require('../personnage-controller.js');
|
||||
|
||||
const { stopAllSpriteAnimations } = actionsMod;
|
||||
|
||||
function fakeSceneEl() {
|
||||
const classes = new Set();
|
||||
return {
|
||||
style: { left: '100px', top: '100px', width: '20px', height: '20px' },
|
||||
style: {
|
||||
left: '100px', top: '100px', width: '20px', height: '20px',
|
||||
},
|
||||
classList: {
|
||||
add: c => classes.add(c), remove: c => classes.delete(c),
|
||||
toggle: c => (classes.has(c) ? classes.delete(c) : classes.add(c)),
|
||||
contains: c => classes.has(c),
|
||||
add: (c) => classes.add(c),
|
||||
remove: (c) => classes.delete(c),
|
||||
toggle: (c) => (classes.has(c) ? classes.delete(c) : classes.add(c)),
|
||||
contains: (c) => classes.has(c),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -37,13 +43,12 @@ function withPersonnageObject(obj, elByObjectId, fn) {
|
||||
// (cherche ".sceneWorld", jamais présente dans elByObjectId ici) — un
|
||||
// sélecteur qui ne matche aucun id numérique renvoie undefined plutôt
|
||||
// que de lever, comme un vrai document.querySelector sans résultat.
|
||||
global.document = { querySelector: sel => { const m = sel.match(/"(\d+)"/); return m ? elByObjectId[m[1]] : undefined; } };
|
||||
global.document = { querySelector: (sel) => { const m = sel.match(/"(\d+)"/); return m ? elByObjectId[m[1]] : undefined; } };
|
||||
global.screensData = [{ id: 1, scene_width: 500, scene_height: 500 }];
|
||||
global.window.currentScreenId = 1;
|
||||
global.gameData = { personnage_animations: { [obj.id]: { walk: ['/w0.png'], idle: ['/i0.png'] } } };
|
||||
try { fn(); } finally { global.document = previousDoc; stopAllSpriteAnimations(); }
|
||||
}
|
||||
const { stopAllSpriteAnimations } = actionsMod;
|
||||
|
||||
test('forgePersonnageMovementAnim/forgePersonnageIdleAnim — repli silencieux si la pose est absente', () => {
|
||||
global.gameData = { personnage_animations: { 5: { idle: ['/i0.png'] } } };
|
||||
@@ -53,7 +58,14 @@ test('forgePersonnageMovementAnim/forgePersonnageIdleAnim — repli silencieux s
|
||||
});
|
||||
|
||||
test('tick — touche "droite" enfoncée : déplace vers la droite et retire le miroir', () => {
|
||||
const obj = { id: 10, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
|
||||
const obj = {
|
||||
id: 10,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'joueur',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
|
||||
},
|
||||
};
|
||||
const el = fakeSceneEl();
|
||||
withPersonnageObject(obj, { 10: el }, () => {
|
||||
global.heldKeys = new Set(['d']);
|
||||
@@ -64,7 +76,14 @@ test('tick — touche "droite" enfoncée : déplace vers la droite et retire le
|
||||
});
|
||||
|
||||
test('tick — touche "gauche" enfoncée : déplace vers la gauche et applique le miroir', () => {
|
||||
const obj = { id: 11, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
|
||||
const obj = {
|
||||
id: 11,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'joueur',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
|
||||
},
|
||||
};
|
||||
const el = fakeSceneEl();
|
||||
withPersonnageObject(obj, { 11: el }, () => {
|
||||
global.heldKeys = new Set(['q']);
|
||||
@@ -75,7 +94,14 @@ test('tick — touche "gauche" enfoncée : déplace vers la gauche et applique l
|
||||
});
|
||||
|
||||
test('tick — axe "horizontal" : ignore haut/bas même si la touche est enfoncée', () => {
|
||||
const obj = { id: 12, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'horizontal', vitesse: 4 } };
|
||||
const obj = {
|
||||
id: 12,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'joueur',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'horizontal', vitesse: 4,
|
||||
},
|
||||
};
|
||||
const el = fakeSceneEl();
|
||||
withPersonnageObject(obj, { 12: el }, () => {
|
||||
global.heldKeys = new Set(['z']); // "haut", bloqué par l'axe
|
||||
@@ -85,7 +111,14 @@ test('tick — axe "horizontal" : ignore haut/bas même si la touche est enfonc
|
||||
});
|
||||
|
||||
test('tick — axe "vertical" : ignore gauche/droite même si la touche est enfoncée', () => {
|
||||
const obj = { id: 13, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'vertical', vitesse: 4 } };
|
||||
const obj = {
|
||||
id: 13,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'joueur',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'vertical', vitesse: 4,
|
||||
},
|
||||
};
|
||||
const el = fakeSceneEl();
|
||||
withPersonnageObject(obj, { 13: el }, () => {
|
||||
global.heldKeys = new Set(['d']); // "droite", bloqué par l'axe
|
||||
@@ -95,7 +128,14 @@ test('tick — axe "vertical" : ignore gauche/droite même si la touche est enfo
|
||||
});
|
||||
|
||||
test('tick — bornage aux limites de la scène (ne sort jamais du cadre)', () => {
|
||||
const obj = { id: 14, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 50 } };
|
||||
const obj = {
|
||||
id: 14,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'joueur',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 50,
|
||||
},
|
||||
};
|
||||
const el = fakeSceneEl();
|
||||
el.style.left = '490px'; // 500 (scène) - 20 (largeur) = 480 max
|
||||
withPersonnageObject(obj, { 14: el }, () => {
|
||||
@@ -106,7 +146,14 @@ test('tick — bornage aux limites de la scène (ne sort jamais du cadre)', () =
|
||||
});
|
||||
|
||||
test('tick — aucune touche enfoncée : ne bouge pas (l\'appel reste sans effet)', () => {
|
||||
const obj = { id: 15, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
|
||||
const obj = {
|
||||
id: 15,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'joueur',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
|
||||
},
|
||||
};
|
||||
const el = fakeSceneEl();
|
||||
withPersonnageObject(obj, { 15: el }, () => {
|
||||
global.heldKeys = new Set();
|
||||
@@ -131,7 +178,14 @@ test('tick — ignore tout objet qui n\'est pas kind="personnage" ou sans comman
|
||||
// clavier, "ennemie"/"pnj" doivent rester immobiles tant qu'aucune
|
||||
// logique de flow ne les pilote.
|
||||
test('tick — un personnage "ennemie"/"pnj" n\'est jamais déplacé au clavier', () => {
|
||||
const ennemi = { id: 17, kind: 'personnage', personnage_role: 'ennemie', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
|
||||
const ennemi = {
|
||||
id: 17,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'ennemie',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
|
||||
},
|
||||
};
|
||||
const el = fakeSceneEl();
|
||||
withPersonnageObject(ennemi, { 17: el }, () => {
|
||||
global.heldKeys = new Set(['d']);
|
||||
@@ -165,7 +219,14 @@ function fakeWorldEl() {
|
||||
}
|
||||
|
||||
test('forgeUpdateSceneWorldSize — le monde reste la scène tant qu\'aucun fond ne dépasse (comportement inchangé)', () => {
|
||||
const screenData = { id: 1, scene_width: 500, scene_height: 500, elements: [{ kind: 'decor', x: 10, y: 10, width: 50, height: 50 }] };
|
||||
const screenData = {
|
||||
id: 1,
|
||||
scene_width: 500,
|
||||
scene_height: 500,
|
||||
elements: [{
|
||||
kind: 'decor', x: 10, y: 10, width: 50, height: 50,
|
||||
}],
|
||||
};
|
||||
const world = forgeUpdateSceneWorldSize(screenData);
|
||||
assert.deepEqual(world, { width: 500, height: 500 });
|
||||
assert.equal(screenData.world_width, 500);
|
||||
@@ -173,7 +234,14 @@ test('forgeUpdateSceneWorldSize — le monde reste la scène tant qu\'aucun fond
|
||||
});
|
||||
|
||||
test('forgeUpdateSceneWorldSize — un fond plus grand agrandit le monde jusqu\'à ses propres bords', () => {
|
||||
const screenData = { id: 1, scene_width: 500, scene_height: 500, elements: [{ kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 }] };
|
||||
const screenData = {
|
||||
id: 1,
|
||||
scene_width: 500,
|
||||
scene_height: 500,
|
||||
elements: [{
|
||||
kind: 'fond', x: 0, y: 0, width: 3840, height: 2160,
|
||||
}],
|
||||
};
|
||||
const world = forgeUpdateSceneWorldSize(screenData);
|
||||
assert.deepEqual(world, { width: 3840, height: 2160 });
|
||||
});
|
||||
@@ -182,7 +250,9 @@ test('forgeUpdateSceneCamera — sans fond XXL, transform vidé (aucun effet, co
|
||||
const worldEl = fakeWorldEl();
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => worldEl };
|
||||
const screenData = { id: 1, scene_width: 500, scene_height: 500, elements: [] };
|
||||
const screenData = {
|
||||
id: 1, scene_width: 500, scene_height: 500, elements: [],
|
||||
};
|
||||
try {
|
||||
forgeUpdateSceneCamera(screenData);
|
||||
assert.equal(worldEl.style.width, '500px');
|
||||
@@ -193,15 +263,23 @@ test('forgeUpdateSceneCamera — sans fond XXL, transform vidé (aucun effet, co
|
||||
|
||||
test('forgeUpdateSceneCamera — centre le monde sur le personnage, borné aux bords du monde', () => {
|
||||
const worldEl = fakeWorldEl();
|
||||
const playerEl = { style: { left: '1900px', top: '1000px', width: '40px', height: '60px' } };
|
||||
const playerEl = {
|
||||
style: {
|
||||
left: '1900px', top: '1000px', width: '40px', height: '60px',
|
||||
},
|
||||
};
|
||||
const previousDoc = global.document;
|
||||
global.document = {
|
||||
querySelector: sel => (sel.indexOf('sceneWorld') !== -1 ? worldEl : playerEl),
|
||||
querySelector: (sel) => (sel.indexOf('sceneWorld') !== -1 ? worldEl : playerEl),
|
||||
};
|
||||
const screenData = {
|
||||
id: 1, scene_width: 500, scene_height: 500,
|
||||
id: 1,
|
||||
scene_width: 500,
|
||||
scene_height: 500,
|
||||
elements: [
|
||||
{ kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 },
|
||||
{
|
||||
kind: 'fond', x: 0, y: 0, width: 3840, height: 2160,
|
||||
},
|
||||
{ id: 42, kind: 'personnage', personnage_role: 'joueur' },
|
||||
],
|
||||
};
|
||||
@@ -217,15 +295,23 @@ test('forgeUpdateSceneCamera — centre le monde sur le personnage, borné aux b
|
||||
|
||||
test('forgeUpdateSceneCamera — ne montre jamais au-delà des bords du monde (bornage)', () => {
|
||||
const worldEl = fakeWorldEl();
|
||||
const playerEl = { style: { left: '3820px', top: '0px', width: '20px', height: '20px' } }; // tout au bord droit
|
||||
const playerEl = {
|
||||
style: {
|
||||
left: '3820px', top: '0px', width: '20px', height: '20px',
|
||||
},
|
||||
}; // tout au bord droit
|
||||
const previousDoc = global.document;
|
||||
global.document = {
|
||||
querySelector: sel => (sel.indexOf('sceneWorld') !== -1 ? worldEl : playerEl),
|
||||
querySelector: (sel) => (sel.indexOf('sceneWorld') !== -1 ? worldEl : playerEl),
|
||||
};
|
||||
const screenData = {
|
||||
id: 1, scene_width: 500, scene_height: 500,
|
||||
id: 1,
|
||||
scene_width: 500,
|
||||
scene_height: 500,
|
||||
elements: [
|
||||
{ kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 },
|
||||
{
|
||||
kind: 'fond', x: 0, y: 0, width: 3840, height: 2160,
|
||||
},
|
||||
{ id: 42, kind: 'personnage', personnage_role: 'joueur' },
|
||||
],
|
||||
};
|
||||
@@ -244,7 +330,7 @@ test('forgeUpdateSceneCamera — ne montre jamais au-delà des bords du monde (b
|
||||
// chargement) — un test document mock dédié, avec plusieurs éléments.
|
||||
function withScene(objectsById, screenData, fn) {
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => { const m = sel.match(/"(\d+)"/); return m ? objectsById[m[1]] : undefined; } };
|
||||
global.document = { querySelector: (sel) => { const m = sel.match(/"(\d+)"/); return m ? objectsById[m[1]] : undefined; } };
|
||||
global.screensData = [{ id: 1, scene_width: 500, scene_height: 500 }];
|
||||
global.window.currentScreenId = 1;
|
||||
try { fn(); } finally { global.document = previousDoc; }
|
||||
@@ -252,7 +338,11 @@ function withScene(objectsById, screenData, fn) {
|
||||
|
||||
test('forgeWouldCollide — un décor solide bloque le personnage qui l\'approche', () => {
|
||||
const player = { id: 20, kind: 'personnage' };
|
||||
const wallEl = { style: { left: '140px', top: '100px', width: '20px', height: '20px' } };
|
||||
const wallEl = {
|
||||
style: {
|
||||
left: '140px', top: '100px', width: '20px', height: '20px',
|
||||
},
|
||||
};
|
||||
const screenData = { elements: [player, { id: 21, kind: 'decor' }] };
|
||||
withScene({ 21: wallEl }, screenData, () => {
|
||||
// Le joueur (20x20) tente d'aller en (130,100) -> chevauche le mur (140..160, 100..120).
|
||||
@@ -264,8 +354,16 @@ test('forgeWouldCollide — un décor solide bloque le personnage qui l\'approch
|
||||
|
||||
test('forgeWouldCollide — ignore un "fond" (jamais un obstacle) et un décor à collision désactivée', () => {
|
||||
const player = { id: 22, kind: 'personnage' };
|
||||
const bgEl = { style: { left: '0px', top: '0px', width: '3840px', height: '2160px' } };
|
||||
const disabledEl = { style: { left: '130px', top: '100px', width: '20px', height: '20px' } };
|
||||
const bgEl = {
|
||||
style: {
|
||||
left: '0px', top: '0px', width: '3840px', height: '2160px',
|
||||
},
|
||||
};
|
||||
const disabledEl = {
|
||||
style: {
|
||||
left: '130px', top: '100px', width: '20px', height: '20px',
|
||||
},
|
||||
};
|
||||
const screenData = {
|
||||
elements: [
|
||||
player,
|
||||
@@ -279,10 +377,21 @@ test('forgeWouldCollide — ignore un "fond" (jamais un obstacle) et un décor
|
||||
});
|
||||
|
||||
test('tick — un décor solide empêche d\'avancer (bloqué à son bord, jamais dans le mur)', () => {
|
||||
const player = { id: 25, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
|
||||
const player = {
|
||||
id: 25,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'joueur',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
|
||||
},
|
||||
};
|
||||
const wall = { id: 26, kind: 'decor' };
|
||||
const playerEl = fakeSceneEl(); // left/top 100px, 20x20
|
||||
const wallEl = { style: { left: '122px', top: '100px', width: '20px', height: '20px' } };
|
||||
const wallEl = {
|
||||
style: {
|
||||
left: '122px', top: '100px', width: '20px', height: '20px',
|
||||
},
|
||||
};
|
||||
withPersonnageObject(player, { 25: playerEl, 26: wallEl }, () => {
|
||||
global.heldKeys = new Set(['d']); // vers la droite, vers le mur juste à côté
|
||||
forgeRunPersonnageControllerTick({ elements: [player, wall] });
|
||||
@@ -291,14 +400,25 @@ test('tick — un décor solide empêche d\'avancer (bloqué à son bord, jamais
|
||||
});
|
||||
|
||||
test('tick — glisse le long d\'un mur en diagonale (un seul axe bloqué)', () => {
|
||||
const player = { id: 27, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4 } };
|
||||
const player = {
|
||||
id: 27,
|
||||
kind: 'personnage',
|
||||
personnage_role: 'joueur',
|
||||
personnage_commandes: {
|
||||
haut: 'z', bas: 's', gauche: 'q', droite: 'd', interagir: 'e', axe: 'libre', vitesse: 4,
|
||||
},
|
||||
};
|
||||
const wall = { id: 28, kind: 'decor' };
|
||||
const playerEl = fakeSceneEl(); // left/top 100px, 20x20
|
||||
const wallEl = { style: { left: '122px', top: '100px', width: '20px', height: '20px' } }; // bloque seulement à droite
|
||||
const wallEl = {
|
||||
style: {
|
||||
left: '122px', top: '100px', width: '20px', height: '20px',
|
||||
},
|
||||
}; // bloque seulement à droite
|
||||
withPersonnageObject(player, { 27: playerEl, 28: wallEl }, () => {
|
||||
global.heldKeys = new Set(['d', 's']); // droite (bloquée) + bas (libre)
|
||||
forgeRunPersonnageControllerTick({ elements: [player, wall] });
|
||||
assert.equal(playerEl.style.left, '100px'); // bloqué
|
||||
assert.equal(playerEl.style.top, '104px'); // continue de descendre
|
||||
assert.equal(playerEl.style.top, '104px'); // continue de descendre
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,8 +40,11 @@ function fakeMangaWidget() {
|
||||
if (sel === '[data-manga-role="result"]') return resultZone;
|
||||
return null;
|
||||
},
|
||||
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
|
||||
_header: header, _progress: progress, _resultZone: resultZone, _classes: classes,
|
||||
classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c) },
|
||||
_header: header,
|
||||
_progress: progress,
|
||||
_resultZone: resultZone,
|
||||
_classes: classes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,7 +126,9 @@ test('forgeQuizMangaOnQuizComplete affiche le badge "不合格" (échoué) sous
|
||||
|
||||
test('forgeQuizMangaOnQuizComplete ne plante jamais sans widget/sans le conteneur de résultat', () => {
|
||||
assert.doesNotThrow(() => forgeQuizMangaOnQuizComplete(null, { correctCount: 1, totalCount: 1 }));
|
||||
assert.doesNotThrow(() => forgeQuizMangaOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }));
|
||||
assert.doesNotThrow(
|
||||
() => forgeQuizMangaOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
// "classique" réutilise le même calcul générique (forgeQuizProgressOnShow)
|
||||
@@ -139,7 +144,8 @@ function fakeClassiqueWidget() {
|
||||
if (sel === '[data-classique-role="progress"]') return progress;
|
||||
return null;
|
||||
},
|
||||
_header: header, _progress: progress,
|
||||
_header: header,
|
||||
_progress: progress,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,7 +171,13 @@ test('forgeQuizClassiqueOnShow ne plante jamais sans widget/état', () => {
|
||||
|
||||
function fakeClassiqueWidgetForResult() {
|
||||
const contentClasses = new Set();
|
||||
const content = { classList: { add: c => contentClasses.add(c), remove: c => contentClasses.delete(c), contains: c => contentClasses.has(c) } };
|
||||
const content = {
|
||||
classList: {
|
||||
add: (c) => contentClasses.add(c),
|
||||
remove: (c) => contentClasses.delete(c),
|
||||
contains: (c) => contentClasses.has(c),
|
||||
},
|
||||
};
|
||||
const resultZone = { innerHTML: '' };
|
||||
const headerTitle = { textContent: 'Formation Sécurité Incendie' };
|
||||
return {
|
||||
@@ -176,7 +188,10 @@ function fakeClassiqueWidgetForResult() {
|
||||
if (sel === '.classiqueHeaderTitle') return headerTitle;
|
||||
return null;
|
||||
},
|
||||
_content: content, _contentClasses: contentClasses, _resultZone: resultZone, _headerTitle: headerTitle,
|
||||
_content: content,
|
||||
_contentClasses: contentClasses,
|
||||
_resultZone: resultZone,
|
||||
_headerTitle: headerTitle,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -211,7 +226,9 @@ test('forgeQuizClassiqueOnQuizComplete affiche le badge "échoué" sous le seuil
|
||||
|
||||
test('forgeQuizClassiqueOnQuizComplete ne plante jamais sans widget/sans les éléments attendus', () => {
|
||||
assert.doesNotThrow(() => forgeQuizClassiqueOnQuizComplete(null, { correctCount: 1, totalCount: 1 }));
|
||||
assert.doesNotThrow(() => forgeQuizClassiqueOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }));
|
||||
assert.doesNotThrow(
|
||||
() => forgeQuizClassiqueOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
test('forgeQuizClassiqueOnShow retire "is-showing-result" (défensif — un futur modèle "Recommencer" réutiliserait ce garde-fou)', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ 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;
|
||||
@@ -38,7 +39,9 @@ test('forgeRunScreenTriggers — ignore tout déclencheur autre que "affichage"
|
||||
id: 1,
|
||||
screen_triggers: [{
|
||||
trigger: 'collision',
|
||||
action: { type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1' },
|
||||
action: {
|
||||
type: 'variable', target_variable: 'score', data_operation: 'incrementer', data_value: '1',
|
||||
},
|
||||
}],
|
||||
});
|
||||
assert.equal(gameData.variables.score.value, '0');
|
||||
|
||||
@@ -14,11 +14,17 @@ const { isGameKey } = require('../triggers.js');
|
||||
test('isGameKey — vrai pour une touche écoutée par un déclencheur clavier/touche_maintenue/touche_relachee', () => {
|
||||
global.gameData = {
|
||||
flows: {
|
||||
'1': {
|
||||
1: {
|
||||
nodes: [
|
||||
{ id: 1, node_type: 'trigger', trigger_event: 'clavier', trigger_key: ' ' },
|
||||
{ id: 2, node_type: 'trigger', trigger_event: 'touche_maintenue', trigger_key: 'ArrowRight' },
|
||||
{ id: 3, node_type: 'trigger', trigger_event: 'touche_relachee', trigger_key: 'ArrowLeft' },
|
||||
{
|
||||
id: 1, node_type: 'trigger', trigger_event: 'clavier', trigger_key: ' ',
|
||||
},
|
||||
{
|
||||
id: 2, node_type: 'trigger', trigger_event: 'touche_maintenue', trigger_key: 'ArrowRight',
|
||||
},
|
||||
{
|
||||
id: 3, node_type: 'trigger', trigger_event: 'touche_relachee', trigger_key: 'ArrowLeft',
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
@@ -31,6 +37,15 @@ test('isGameKey — vrai pour une touche écoutée par un déclencheur clavier/t
|
||||
});
|
||||
|
||||
test('isGameKey — ignore les touches d\'un autre type de déclencheur (ex. "Au clic")', () => {
|
||||
global.gameData = { flows: { '1': { nodes: [{ id: 1, node_type: 'trigger', trigger_event: 'clic', trigger_element_id: 5 }], edges: [] } } };
|
||||
global.gameData = {
|
||||
flows: {
|
||||
1: {
|
||||
nodes: [{
|
||||
id: 1, node_type: 'trigger', trigger_event: 'clic', trigger_element_id: 5,
|
||||
}],
|
||||
edges: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
assert.equal(isGameKey('Enter'), false);
|
||||
});
|
||||
|
||||
+67
-62
@@ -7,7 +7,7 @@
|
||||
|
||||
// Propriété CSS réelle + éventuel suffixe (unité) pour chaque propriété
|
||||
// pilotable par une action "Modifier un élément".
|
||||
var ELEMENT_PROPERTY_CSS = {
|
||||
const ELEMENT_PROPERTY_CSS = {
|
||||
bg_color: { css: 'backgroundColor', suffix: '' },
|
||||
border_color: { css: 'borderColor', suffix: '' },
|
||||
width: { css: 'width', suffix: '%' },
|
||||
@@ -97,7 +97,7 @@ function applyElementProperty(targetEl, property, value) {
|
||||
// toujours au même endroit.
|
||||
const relSpec = ELEMENT_PROPERTY_CSS[property === 'pos_x_relatif' ? 'pos_x' : 'pos_y'];
|
||||
const delta = parseFloat(value);
|
||||
if (isNaN(delta)) return;
|
||||
if (Number.isNaN(delta)) return;
|
||||
const current = parseFloat(targetEl.style[relSpec.css]) || 0;
|
||||
targetEl.style[relSpec.css] = (current + delta) + relSpec.suffix;
|
||||
return;
|
||||
@@ -113,7 +113,7 @@ function applyElementProperty(targetEl, property, value) {
|
||||
// 1re valeur si l'écran est rechargé.
|
||||
if (typeof value === 'string' && value.indexOf('||') !== -1) {
|
||||
const parts = value.split('||');
|
||||
const key = 'toggle' + property;
|
||||
const key = `toggle${property}`;
|
||||
const atSecond = targetEl.dataset[key] === '1';
|
||||
const next = atSecond ? parts[0] : parts[1];
|
||||
targetEl.style[spec.css] = next + spec.suffix;
|
||||
@@ -137,7 +137,7 @@ function applyElementProperty(targetEl, property, value) {
|
||||
// pour un objet de scène), la valeur n'est pas bornée plutôt que de
|
||||
// risquer un blocage à 0.
|
||||
function clampSceneObjectPosition(targetEl, css, rawValue) {
|
||||
const screenData = screensData.find(function(s){ return s.id === window.currentScreenId; });
|
||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||
if (!screenData || screenData.scene_width == null) return rawValue;
|
||||
const size = parseFloat(targetEl.style[css === 'left' ? 'width' : 'height']) || 0;
|
||||
const boundWidth = screenData.world_width != null ? screenData.world_width : screenData.scene_width;
|
||||
@@ -156,15 +156,15 @@ function applyObjectProperty(targetEl, property, value) {
|
||||
if (property === 'pos_x_relatif' || property === 'pos_y_relatif') {
|
||||
const css = property === 'pos_x_relatif' ? 'left' : 'top';
|
||||
const delta = parseFloat(value);
|
||||
if (isNaN(delta)) return;
|
||||
if (Number.isNaN(delta)) return;
|
||||
const current = parseFloat(targetEl.style[css]) || 0;
|
||||
targetEl.style[css] = clampSceneObjectPosition(targetEl, css, current + delta) + 'px';
|
||||
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, current + delta)}px`;
|
||||
return;
|
||||
}
|
||||
if (property === 'pos_x' || property === 'pos_y') {
|
||||
if (value === undefined || value === null) return;
|
||||
const css = property === 'pos_x' ? 'left' : 'top';
|
||||
targetEl.style[css] = clampSceneObjectPosition(targetEl, css, parseFloat(value) || 0) + 'px';
|
||||
targetEl.style[css] = `${clampSceneObjectPosition(targetEl, css, parseFloat(value) || 0)}px`;
|
||||
return;
|
||||
}
|
||||
applyElementProperty(targetEl, property, value);
|
||||
@@ -175,14 +175,14 @@ function runActionNode(node) {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > -1 && idx < screensData.length - 1) showScreen(screensData[idx + 1].id);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'ecran_precedent') {
|
||||
} if (node.action_type === 'ecran_precedent') {
|
||||
const idx = screenIndexById(window.currentScreenId);
|
||||
if (idx > 0) showScreen(screensData[idx - 1].id);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'aller_a' && node.target_screen_id) {
|
||||
} if (node.action_type === 'aller_a' && node.target_screen_id) {
|
||||
showScreen(node.target_screen_id);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'ouvrir_ligne' && node.target_screen_id) {
|
||||
} if (node.action_type === 'ouvrir_ligne' && node.target_screen_id) {
|
||||
// Mémorise QUELLE ligne a été cliquée juste avant d'exécuter cette
|
||||
// action (voir bindClicks dans triggers.js) puis affiche l'écran de
|
||||
// détail avec ses placeholders "champ" résolus contre cette ligne
|
||||
@@ -205,27 +205,27 @@ function runActionNode(node) {
|
||||
bindHoverTexts();
|
||||
bindHoverTriggers();
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'modifier_element' && node.target_element_id) {
|
||||
const targetEl = document.querySelector('[data-element-id="' + node.target_element_id + '"]');
|
||||
} if (node.action_type === 'modifier_element' && node.target_element_id) {
|
||||
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id}"]`);
|
||||
if (targetEl) applyElementProperty(targetEl, node.element_property, node.element_value);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'modifier_objet_scene' && (node.target_element_id || node.target_object_id)) {
|
||||
} if (node.action_type === 'modifier_objet_scene' && (node.target_element_id || node.target_object_id)) {
|
||||
// Fondations multi-éditeurs — éditeur de scène 2D : équivalent de
|
||||
// "modifier_element" ci-dessus, mais en pixels (voir
|
||||
// applyObjectProperty() plus bas) plutôt qu'en %. data-element-id
|
||||
// est posé sur CHAQUE objet de scène en plus de data-object-id (voir
|
||||
// render_scene_object.py), donc le même sélecteur suffit.
|
||||
const targetEl = document.querySelector('[data-element-id="' + (node.target_element_id || node.target_object_id) + '"]');
|
||||
const targetEl = document.querySelector(`[data-element-id="${node.target_element_id || node.target_object_id}"]`);
|
||||
if (targetEl) applyObjectProperty(targetEl, node.element_property, node.element_value);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
|
||||
} if (node.action_type === 'declencher_evenement' && node.target_custom_event_id) {
|
||||
// Notification pure (voir screens/custom_events/) : ne fait que
|
||||
// signaler l'événement, jamais choisir un élément — c'est à
|
||||
// l'écouteur (déclencheur → condition → action) de décider quoi
|
||||
// faire, avec ses propres réglages habituels.
|
||||
window.dispatchGameEvent(node.target_custom_event_id);
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'activer_onglet' && node.target_element_id) {
|
||||
} if (node.action_type === 'activer_onglet' && node.target_element_id) {
|
||||
// 2.3 — panneau à onglets / visibilité mutuellement exclusive : montre
|
||||
// l'élément visé et masque tous ses FRÈRES DIRECTS (même parent_id,
|
||||
// y compris "aucun parent" = les autres éléments de premier niveau de
|
||||
@@ -234,31 +234,30 @@ function runActionNode(node) {
|
||||
// (imbriqués compris, voir list_elements côté serveur), donc ça
|
||||
// fonctionne aussi bien pour des onglets imbriqués dans un même
|
||||
// conteneur que pour des "vues" posées directement sur l'écran.
|
||||
const screenData = screensData.find(function(s){ return s.id === window.currentScreenId; });
|
||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||
const allElements = screenData ? screenData.elements : [];
|
||||
const target = allElements.find(function(e){ return e.id === node.target_element_id; });
|
||||
const target = allElements.find((e) => e.id === node.target_element_id);
|
||||
if (target) {
|
||||
const parentKey = target.parent_id || null;
|
||||
allElements
|
||||
.filter(function(e){ return (e.parent_id || null) === parentKey; })
|
||||
.forEach(function(e) {
|
||||
const el = document.querySelector('[data-element-id="' + e.id + '"]');
|
||||
.filter((e) => (e.parent_id || null) === parentKey)
|
||||
.forEach((e) => {
|
||||
const el = document.querySelector(`[data-element-id="${e.id}"]`);
|
||||
if (!el) return;
|
||||
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; }
|
||||
else { el.style.display = 'none'; }
|
||||
if (e.id === target.id) { el.style.display = ''; el.style.visibility = ''; } else { el.style.display = 'none'; }
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'attendre') {
|
||||
} if (node.action_type === 'attendre') {
|
||||
// 3.2 (Confort) — séquences temporisées : suspend la suite du graphe
|
||||
// pendant N secondes (node.data_value) avant de continuer sur le(s)
|
||||
// fil(s) de sortie — permet par ex. un mail qui "arrive" tout seul
|
||||
// quelques secondes après l'affichage de l'écran, sans action du
|
||||
// joueur (à combiner avec le déclencheur "À l'affichage de l'écran").
|
||||
const seconds = parseFloat(node.data_value);
|
||||
const ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
return new Promise(function(resolve) { setTimeout(resolve, ms); });
|
||||
} else if (node.action_type === 'modifier_donnee') {
|
||||
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
||||
} if (node.action_type === 'modifier_donnee') {
|
||||
// Export Web/SCORM (voir static/js/play/offline/apply-actions.js) :
|
||||
// aucun serveur disponible dans le paquet exporté — la même mutation
|
||||
// s'applique directement sur gameData, en mémoire, dans le navigateur.
|
||||
@@ -272,21 +271,22 @@ function runActionNode(node) {
|
||||
// LAST_INSERTED_ROW_ID (voir flow_node_run_data.py) — sans effet
|
||||
// sinon, donc toujours transmis sans condition ici.
|
||||
return fetch(runDataUrl, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clicked_row_id: window.lastClickedRowId, last_inserted_row_id: window.lastInsertedRowId })
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clicked_row_id: window.lastClickedRowId, last_inserted_row_id: window.lastInsertedRowId }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} else if (node.action_type === 'modifier_variable') {
|
||||
} if (node.action_type === 'modifier_variable') {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyVariableActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runVarUrl = FORGE_PLAY_URLS.flowNodeRunVariable.replace('999999', node.id);
|
||||
return fetch(runVarUrl, {method: 'POST'})
|
||||
.then(r => r.json())
|
||||
return fetch(runVarUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} else if (node.action_type === 'modifier_score') {
|
||||
} if (node.action_type === 'modifier_score') {
|
||||
// Score/progression (voir db/scoring/, screens/data_actions/
|
||||
// apply_score_action.py) : même patron que "Modifier une variable
|
||||
// globale" ci-dessus, calculé et écrit côté serveur EN LIGNE, ou
|
||||
@@ -296,19 +296,19 @@ function runActionNode(node) {
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runScoreUrl = FORGE_PLAY_URLS.flowNodeRunScore.replace('999999', node.id);
|
||||
return fetch(runScoreUrl, {method: 'POST'})
|
||||
.then(r => r.json())
|
||||
return fetch(runScoreUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} else if (node.action_type === 'definir_statut_partie') {
|
||||
} if (node.action_type === 'definir_statut_partie') {
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeApplyStatusActionOffline(gameData, node);
|
||||
return refreshRuntimeData();
|
||||
}
|
||||
const runStatusUrl = FORGE_PLAY_URLS.flowNodeRunStatus.replace('999999', node.id);
|
||||
return fetch(runStatusUrl, {method: 'POST'})
|
||||
.then(r => r.json())
|
||||
return fetch(runStatusUrl, { method: 'POST' })
|
||||
.then((r) => r.json())
|
||||
.then(() => refreshRuntimeData());
|
||||
} else if (node.action_type === 'ajouter_ligne') {
|
||||
} if (node.action_type === 'ajouter_ligne') {
|
||||
// Phase 4 — crée une ligne VIDE (côté serveur en ligne, en mémoire
|
||||
// hors ligne) ; son id devient window.lastInsertedRowId pour que les
|
||||
// nœuds "Modifier une donnée" enchaînés (ciblant LAST_INSERTED_ROW_ID,
|
||||
@@ -320,21 +320,21 @@ function runActionNode(node) {
|
||||
}
|
||||
const runAddRowUrl = FORGE_PLAY_URLS.flowNodeRunAddRow.replace('999999', node.id);
|
||||
return fetch(runAddRowUrl, { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(function(json) {
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
window.lastInsertedRowId = json.row_id;
|
||||
return refreshRuntimeData();
|
||||
});
|
||||
} else if (node.action_type === 'jouer_son') {
|
||||
} if (node.action_type === 'jouer_son') {
|
||||
// Phase 6 — effet sonore ponctuel : non bouclé, indépendant de la
|
||||
// musique de fond de l'écran (runScreenBackgroundMusic() dans
|
||||
// screens.js, qui elle boucle et s'arrête au changement d'écran).
|
||||
// Fire-and-forget : ne bloque jamais la suite du graphe.
|
||||
if (node.data_value) new Audio(node.data_value).play().catch(function() {});
|
||||
if (node.data_value) new Audio(node.data_value).play().catch(() => {});
|
||||
return Promise.resolve();
|
||||
} else if (node.action_type === 'jouer_animation_sprite' && (node.target_element_id || node.target_object_id)) {
|
||||
} if (node.action_type === 'jouer_animation_sprite' && (node.target_element_id || node.target_object_id)) {
|
||||
const spriteTargetId = node.target_element_id || node.target_object_id;
|
||||
const targetEl = document.querySelector('[data-element-id="' + spriteTargetId + '"]');
|
||||
const targetEl = document.querySelector(`[data-element-id="${spriteTargetId}"]`);
|
||||
if (targetEl) {
|
||||
let spriteData = {};
|
||||
try { spriteData = JSON.parse(node.data_value || '{}'); } catch (e) { /* data_value invalide : rien à jouer */ }
|
||||
@@ -359,7 +359,7 @@ function runActionNode(node) {
|
||||
// ci-dessus (événementiel) ET un clip de la Timeline d'animation de kind
|
||||
// "sprite" (déclaratif, voir applyAnimationClip() dans screens.js) — les
|
||||
// deux partagent ce même moteur, aucune logique dupliquée.
|
||||
var activeSpriteAnimations = new Map();
|
||||
const activeSpriteAnimations = new Map();
|
||||
|
||||
// Phase 8 — résout le NOM d'une animation (ex. "walk") en liste de frames à
|
||||
// partir du personnage ACTUELLEMENT assigné à cet élément
|
||||
@@ -373,7 +373,7 @@ var activeSpriteAnimations = new Map();
|
||||
// `spriteData.frames` (ancien format, Phase 7) reste géré en repli pour
|
||||
// les nœuds/clips déjà créés avant ce changement.
|
||||
function resolveSpriteFrames(elementId, spriteData) {
|
||||
var byAnimation = (gameData.personnage_animations || {})[elementId];
|
||||
const byAnimation = (gameData.personnage_animations || {})[elementId];
|
||||
if (byAnimation && spriteData.animation && byAnimation[spriteData.animation]) {
|
||||
return { frames: byAnimation[spriteData.animation], fps: spriteData.fps, loop: spriteData.loop };
|
||||
}
|
||||
@@ -404,11 +404,11 @@ function resolveSpriteFrames(elementId, spriteData) {
|
||||
// aussitôt posée si on ne la protégeait pas explicitement — un setTimeout
|
||||
// dédié la maintient "en cours" un court instant avant de la libérer.
|
||||
function runSpriteAnimation(targetEl, spriteData) {
|
||||
var frames = spriteData.frames || [];
|
||||
var fps = spriteData.fps > 0 ? spriteData.fps : 8;
|
||||
var loop = !!spriteData.loop;
|
||||
var signature = frames.join('|') + '@' + fps + (loop ? '+loop' : '');
|
||||
var active = activeSpriteAnimations.get(targetEl);
|
||||
const frames = spriteData.frames || [];
|
||||
const fps = spriteData.fps > 0 ? spriteData.fps : 8;
|
||||
const loop = !!spriteData.loop;
|
||||
const signature = `${frames.join('|')}@${fps}${loop ? '+loop' : ''}`;
|
||||
const active = activeSpriteAnimations.get(targetEl);
|
||||
if (active && active.signature === signature) return;
|
||||
if (active && !active.loop) return;
|
||||
if (active) {
|
||||
@@ -416,16 +416,16 @@ function runSpriteAnimation(targetEl, spriteData) {
|
||||
activeSpriteAnimations.delete(targetEl);
|
||||
}
|
||||
if (!frames.length) return;
|
||||
var frameIndex = 0;
|
||||
let frameIndex = 0;
|
||||
targetEl.src = frames[0];
|
||||
if (frames.length === 1) {
|
||||
if (loop) return;
|
||||
var holdMs = Math.max(1000 / fps, 150);
|
||||
var timeoutId = setTimeout(function() { activeSpriteAnimations.delete(targetEl); }, holdMs);
|
||||
activeSpriteAnimations.set(targetEl, { intervalId: timeoutId, signature: signature, loop: loop });
|
||||
const holdMs = Math.max(1000 / fps, 150);
|
||||
const timeoutId = setTimeout(() => { activeSpriteAnimations.delete(targetEl); }, holdMs);
|
||||
activeSpriteAnimations.set(targetEl, { intervalId: timeoutId, signature, loop });
|
||||
return;
|
||||
}
|
||||
var intervalId = setInterval(function() {
|
||||
const intervalId = setInterval(() => {
|
||||
frameIndex++;
|
||||
if (frameIndex >= frames.length) {
|
||||
if (!loop) { clearInterval(intervalId); activeSpriteAnimations.delete(targetEl); return; }
|
||||
@@ -433,7 +433,7 @@ function runSpriteAnimation(targetEl, spriteData) {
|
||||
}
|
||||
targetEl.src = frames[frameIndex];
|
||||
}, 1000 / fps);
|
||||
activeSpriteAnimations.set(targetEl, { intervalId: intervalId, signature: signature, loop: loop });
|
||||
activeSpriteAnimations.set(targetEl, { intervalId, signature, loop });
|
||||
}
|
||||
|
||||
// Appelée depuis showScreen() (screens.js) à chaque changement d'écran —
|
||||
@@ -441,14 +441,19 @@ function runSpriteAnimation(targetEl, spriteData) {
|
||||
// directement depuis screens.js, même patron que runScreenTimerTriggers()
|
||||
// dans triggers.js (l'appelant ne connaît jamais la structure interne).
|
||||
function stopAllSpriteAnimations() {
|
||||
activeSpriteAnimations.forEach(function(active) { clearInterval(active.intervalId); });
|
||||
activeSpriteAnimations.forEach((active) => { clearInterval(active.intervalId); });
|
||||
activeSpriteAnimations.clear();
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même patron que conditions.js.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations,
|
||||
applyObjectProperty, clampSceneObjectPosition, runActionNode,
|
||||
resolveSpriteFrames,
|
||||
runSpriteAnimation,
|
||||
stopAllSpriteAnimations,
|
||||
activeSpriteAnimations,
|
||||
applyObjectProperty,
|
||||
clampSceneObjectPosition,
|
||||
runActionNode,
|
||||
};
|
||||
}
|
||||
|
||||
+168
-167
@@ -17,15 +17,15 @@
|
||||
// statique jamais passé par Jinja, ce n'est plus nécessaire, mais la
|
||||
// construction est conservée pour rester visuellement cohérente avec le
|
||||
// reste du moteur et éviter toute confusion en relisant le code.
|
||||
var PLACEHOLDER_OPEN = '{' + '{';
|
||||
var PLACEHOLDER_CLOSE = '}' + '}';
|
||||
const PLACEHOLDER_OPEN = '{' + '{';
|
||||
const PLACEHOLDER_CLOSE = '}' + '}';
|
||||
|
||||
function applyOpenRowBindings() {
|
||||
if (!window.currentOpenRow) return;
|
||||
const rows = gameData.data[String(window.currentOpenRow.definitionId)] || [];
|
||||
const row = rows.find(r => r.id === window.currentOpenRow.rowId);
|
||||
const screenData = screensData.find(s => s.id === window.currentScreenId);
|
||||
const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]');
|
||||
const row = rows.find((r) => r.id === window.currentOpenRow.rowId);
|
||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||
const screenDiv = document.querySelector(`.playScreen[data-screen-id="${window.currentScreenId}"]`);
|
||||
if (!row || !screenData || !screenDiv) return;
|
||||
|
||||
// Cible directement, à N'IMPORTE QUEL niveau d'imbrication, le ou les
|
||||
@@ -51,7 +51,7 @@ function applyOpenRowBindings() {
|
||||
// le contient), ce filtre ne gardait en pratique QUE l'ancêtre racine
|
||||
// de l'écran, annulant complètement l'effet recherché par ce correctif.
|
||||
const byId = {};
|
||||
screenData.elements.forEach(function(e){ byId[e.id] = e; });
|
||||
screenData.elements.forEach((e) => { byId[e.id] = e; });
|
||||
function hasUnresolvedPlaceholder(e) {
|
||||
return !!(e.rendered_html && e.rendered_html.indexOf(PLACEHOLDER_OPEN) !== -1);
|
||||
}
|
||||
@@ -64,9 +64,9 @@ function applyOpenRowBindings() {
|
||||
return false;
|
||||
}
|
||||
const candidates = screenData.elements.filter(hasUnresolvedPlaceholder);
|
||||
const targets = candidates.filter(function(e) {
|
||||
return !candidates.some(function(other) { return other.id !== e.id && isDescendantOf(other, e.id); });
|
||||
});
|
||||
const targets = candidates.filter(
|
||||
(e) => !candidates.some((other) => other.id !== e.id && isDescendantOf(other, e.id)),
|
||||
);
|
||||
|
||||
// Résout {{champ}} EN PLACE dans le DOM déjà affiché, sans jamais
|
||||
// détruire/recréer le moindre nœud — un peu comme un diff React ciblé,
|
||||
@@ -85,7 +85,7 @@ function applyOpenRowBindings() {
|
||||
// élément", donnant l'impression trompeuse d'un rechargement de page).
|
||||
// Avec la résolution en place, plus aucun nœud du sous-arbre ne bouge :
|
||||
// rien à sauvegarder ni à restaurer.
|
||||
const placeholderRe = new RegExp(PLACEHOLDER_OPEN + '([^{}]+)' + PLACEHOLDER_CLOSE, 'g');
|
||||
const placeholderRe = new RegExp(`${PLACEHOLDER_OPEN}([^{}]+)${PLACEHOLDER_CLOSE}`, 'g');
|
||||
|
||||
function resolveTextNodes(root) {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
@@ -94,11 +94,12 @@ function applyOpenRowBindings() {
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.nodeValue.indexOf(PLACEHOLDER_OPEN) !== -1) toSplit.push(node);
|
||||
}
|
||||
toSplit.forEach(function(textNode) {
|
||||
toSplit.forEach((textNode) => {
|
||||
const raw = textNode.nodeValue;
|
||||
placeholderRe.lastIndex = 0;
|
||||
const frag = document.createDocumentFragment();
|
||||
let lastIndex = 0, match;
|
||||
let lastIndex = 0; let
|
||||
match;
|
||||
while ((match = placeholderRe.exec(raw))) {
|
||||
if (match.index > lastIndex) frag.appendChild(document.createTextNode(raw.slice(lastIndex, match.index)));
|
||||
const span = document.createElement('span');
|
||||
@@ -113,31 +114,31 @@ function applyOpenRowBindings() {
|
||||
}
|
||||
|
||||
function updateTextSlots(root) {
|
||||
root.querySelectorAll('[data-bind-field]').forEach(function(slot) {
|
||||
root.querySelectorAll('[data-bind-field]').forEach((slot) => {
|
||||
const value = row[slot.dataset.bindField] == null ? '' : String(row[slot.dataset.bindField]);
|
||||
if (slot.textContent !== value) slot.textContent = value;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAttributes(root) {
|
||||
[root].concat(Array.from(root.querySelectorAll('*'))).forEach(function(node) {
|
||||
Array.from(node.attributes).forEach(function(attr) {
|
||||
[root].concat(Array.from(root.querySelectorAll('*'))).forEach((node) => {
|
||||
Array.from(node.attributes).forEach((attr) => {
|
||||
if (attr.name.indexOf('data-bind-attr-') === 0) return; // gabarit déjà mémorisé, pas un attribut à lire
|
||||
const bindKey = 'data-bind-attr-' + attr.name;
|
||||
const template = node.hasAttribute(bindKey) ? node.getAttribute(bindKey) : (attr.value.indexOf(PLACEHOLDER_OPEN) !== -1 ? attr.value : null);
|
||||
const bindKey = `data-bind-attr-${attr.name}`;
|
||||
const template = node.hasAttribute(bindKey)
|
||||
? node.getAttribute(bindKey)
|
||||
: (attr.value.indexOf(PLACEHOLDER_OPEN) !== -1 ? attr.value : null);
|
||||
if (template === null) return;
|
||||
if (!node.hasAttribute(bindKey)) node.setAttribute(bindKey, template);
|
||||
placeholderRe.lastIndex = 0;
|
||||
const resolved = template.replace(placeholderRe, function(_, fieldName) {
|
||||
return row[fieldName] == null ? '' : String(row[fieldName]);
|
||||
});
|
||||
const resolved = template.replace(placeholderRe, (_, fieldName) => (row[fieldName] == null ? '' : String(row[fieldName])));
|
||||
if (attr.value !== resolved) node.setAttribute(attr.name, resolved);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
targets.forEach(function(el) {
|
||||
const target = screenDiv.querySelector('[data-element-id="' + el.id + '"]');
|
||||
targets.forEach((el) => {
|
||||
const target = screenDiv.querySelector(`[data-element-id="${el.id}"]`);
|
||||
if (!target) return;
|
||||
if (target.querySelector('[data-bind-field]')) {
|
||||
updateTextSlots(target);
|
||||
@@ -167,156 +168,156 @@ function refreshRuntimeData() {
|
||||
// en ligne comme hors ligne.
|
||||
if (window.FORGE_OFFLINE) {
|
||||
forgeRecomputeFullPayloadOffline(gameData);
|
||||
return Promise.resolve().then(function () { return _forgeApplyFreshRuntimeData(gameData); });
|
||||
return Promise.resolve().then(() => _forgeApplyFreshRuntimeData(gameData));
|
||||
}
|
||||
return fetch(FORGE_PLAY_URLS.runtimePayload)
|
||||
.then(r => r.json())
|
||||
.then((r) => r.json())
|
||||
.then(_forgeApplyFreshRuntimeData);
|
||||
}
|
||||
|
||||
function _forgeApplyFreshRuntimeData(newData) {
|
||||
gameData = newData;
|
||||
screensData = gameData.screens;
|
||||
const screenData = screensData.find(s => s.id === window.currentScreenId);
|
||||
const screenDiv = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"]');
|
||||
// Le bloc ci-dessous (régénération du contenu de l'écran courant)
|
||||
// est sauté si l'écran n'est pas trouvé, mais bindClicks() et les
|
||||
// autres réattachements juste après ne doivent JAMAIS dépendre de
|
||||
// cette condition : un retour anticipé ici a déjà laissé, dans la
|
||||
// pratique, un Répéteur fraîchement régénéré sans AUCUN gestionnaire
|
||||
// de clic pour le reste de la partie (plus aucune ligne ne
|
||||
// répondait, silencieusement, sans erreur) — ces fonctions sont sans
|
||||
// risque à rappeler même quand rien n'a changé.
|
||||
if (screenData && screenDiv) {
|
||||
// try/catch : une erreur inattendue pendant la régénération (ex.
|
||||
// structure de données imprévue) ne doit jamais empêcher les
|
||||
// réattachements ci-dessous de s'exécuter — sinon le Répéteur (ou
|
||||
// tout autre élément cliquable) reste silencieusement inerte pour
|
||||
// le reste de la partie, sans le moindre message d'erreur visible.
|
||||
try {
|
||||
// "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée
|
||||
// à une donnée), "visibilityGated" (condition de visibilité) et
|
||||
// "dataBound" ("Donnée liée" d'un Texte/Titre — voir
|
||||
// render_element_html.py) sont les types d'éléments dont le
|
||||
// contenu/l'affichage dépend de la donnée elle-même plutôt que
|
||||
// d'une action "Modifier un élément" posée à la main dans le
|
||||
// graphe — eux seuls doivent être régénérés après un changement
|
||||
// de donnée ou de variable.
|
||||
//
|
||||
// On cible ces éléments à N'IMPORTE QUEL niveau d'imbrication
|
||||
// (pas seulement le premier niveau de l'écran), via leur
|
||||
// data-element-id — disponible sur chaque élément rendu, pas
|
||||
// seulement les éléments de premier niveau. Avant ce correctif,
|
||||
// un Répéteur niché dans un conteneur (comme ici) n'était JAMAIS
|
||||
// du premier niveau, donc c'est son ANCÊTRE de premier niveau
|
||||
// qui portait le marqueur (le Répéteur imbriqué à l'intérieur)
|
||||
// et se retrouvait régénéré en entier à sa place — potentiellement
|
||||
// l'écran entier (jauges, onglets... compris) s'il n'y avait
|
||||
// qu'un seul gros conteneur racine.
|
||||
//
|
||||
// Parmi les éléments marqués, on ne garde que les plus
|
||||
// SPÉCIFIQUES (les plus profonds) : un ancêtre est presque
|
||||
// toujours "marqué" lui aussi dès qu'un descendant l'est, puisque
|
||||
// son rendered_html contient celui de ses descendants — le
|
||||
// garder EN PLUS régénérerait tout son sous-arbre pour rien.
|
||||
// (Une version antérieure de ce filtre vérifiait l'inverse —
|
||||
// exclure un élément si un ANCÊTRE est marqué — ce qui ne
|
||||
// gardait en pratique que l'ancêtre racine de l'écran et
|
||||
// annulait complètement l'effet recherché.)
|
||||
const byId = {};
|
||||
screenData.elements.forEach(function(e){ byId[e.id] = e; });
|
||||
function hasMarker(e) {
|
||||
return !!(e.rendered_html && (
|
||||
e.rendered_html.indexOf('repeaterItem') !== -1 ||
|
||||
e.rendered_html.indexOf('jaugeBar') !== -1 ||
|
||||
e.rendered_html.indexOf('visibilityGated') !== -1 ||
|
||||
e.rendered_html.indexOf('dataBound') !== -1
|
||||
));
|
||||
}
|
||||
function isDescendantOf(e, ancestorId) {
|
||||
let p = e.parent_id ? byId[e.parent_id] : null;
|
||||
while (p) {
|
||||
if (p.id === ancestorId) return true;
|
||||
p = p.parent_id ? byId[p.parent_id] : null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// "visibilityGated"/"dataBound" sont posés comme un COMMENTAIRE
|
||||
// HTML juste après la balise concernée (voir _mark() dans
|
||||
// render_element_html.py) — jamais comme une classe CSS, à la
|
||||
// différence de "repeaterItem"/"jaugeBar" (qui, eux, désignent
|
||||
// un widget à régénérer EN BLOC, lignes/valeur comprises, un
|
||||
// comportement déjà correct pour ces deux-là). Repérer ce
|
||||
// commentaire permet de retrouver, à l'intérieur d'un fragment
|
||||
// fraîchement rendu, l'élément PRÉCIS qui a changé — même s'il
|
||||
// est niché dans un exemplaire d'élément de jeu réutilisable
|
||||
// (ex. un Texte "Donnée liée" à l'intérieur d'une boîte de
|
||||
// dialogue), où lui seul porte l'attribut data-element-id qui
|
||||
// l'identifie, son marqueur ne remontant jusqu'au SEUL élément
|
||||
// de premier niveau existant (l'exemplaire) que parce que son
|
||||
// HTML est imbriqué dans le sien.
|
||||
const COMMENT_MARKERS = ['visibilityGated', 'dataBound'];
|
||||
function findCommentMarkedDescendants(root) {
|
||||
const found = [];
|
||||
root.querySelectorAll('[data-element-id]').forEach(function(node) {
|
||||
const sib = node.nextSibling;
|
||||
const hasCommentMarker = !!(sib && sib.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.indexOf(sib.data) !== -1);
|
||||
// Un élément actuellement CACHÉ par une condition de
|
||||
// visibilité (voir render_element_html.py) n'a pas de
|
||||
// commentaire marqueur à sa suite — sa balise porte
|
||||
// directement la classe "visibilityGated" à la place. Sans
|
||||
// ce second cas, un élément qui passe de VISIBLE à CACHÉ
|
||||
// n'était jamais retrouvé ici (seul le sens caché->visible
|
||||
// l'était, via le commentaire) : son ancien nœud visible
|
||||
// restait donc affiché indéfiniment à côté du nouveau,
|
||||
// maintenant visible, qui le remplace normalement (ex. une
|
||||
// icône "enveloppe fermée" jamais masquée en même temps que
|
||||
// "enveloppe ouverte" qui apparaît après avoir cliqué).
|
||||
const isHiddenPlaceholder = node.classList.contains('visibilityGated');
|
||||
if (hasCommentMarker || isHiddenPlaceholder) {
|
||||
found.push(node);
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
const markedCandidates = screenData.elements.filter(hasMarker);
|
||||
markedCandidates.filter(function(e) {
|
||||
return !markedCandidates.some(function(other) { return other.id !== e.id && isDescendantOf(other, e.id); });
|
||||
}).forEach(function(el) {
|
||||
const target = screenDiv.querySelector('[data-element-id="' + el.id + '"]');
|
||||
if (!target) return;
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = el.rendered_html;
|
||||
const newNode = container.firstElementChild;
|
||||
if (!newNode) return;
|
||||
const innerMarked = findCommentMarkedDescendants(newNode);
|
||||
if (innerMarked.length) {
|
||||
// Des éléments plus précis existent À L'INTÉRIEUR (ex. un
|
||||
// Texte "Donnée liée" posé dans un élément de jeu
|
||||
// réutilisable) : on ne patche QUE ceux-là, en préservant
|
||||
// tout le reste — une boîte de dialogue ne doit ni
|
||||
// disparaître ni réapparaître juste parce qu'un texte à
|
||||
// l'intérieur a changé.
|
||||
innerMarked.forEach(function(innerNode) {
|
||||
const innerId = innerNode.getAttribute('data-element-id');
|
||||
const liveInner = target.querySelector('[data-element-id="' + innerId + '"]');
|
||||
if (liveInner) liveInner.replaceWith(innerNode);
|
||||
});
|
||||
} else {
|
||||
target.replaceWith(newNode);
|
||||
}
|
||||
});
|
||||
applyOpenRowBindings();
|
||||
} catch (err) {
|
||||
console.error('refreshRuntimeData: erreur pendant la régénération de l\'écran', err);
|
||||
gameData = newData;
|
||||
screensData = gameData.screens;
|
||||
const screenData = screensData.find((s) => s.id === window.currentScreenId);
|
||||
const screenDiv = document.querySelector(`.playScreen[data-screen-id="${window.currentScreenId}"]`);
|
||||
// Le bloc ci-dessous (régénération du contenu de l'écran courant)
|
||||
// est sauté si l'écran n'est pas trouvé, mais bindClicks() et les
|
||||
// autres réattachements juste après ne doivent JAMAIS dépendre de
|
||||
// cette condition : un retour anticipé ici a déjà laissé, dans la
|
||||
// pratique, un Répéteur fraîchement régénéré sans AUCUN gestionnaire
|
||||
// de clic pour le reste de la partie (plus aucune ligne ne
|
||||
// répondait, silencieusement, sans erreur) — ces fonctions sont sans
|
||||
// risque à rappeler même quand rien n'a changé.
|
||||
if (screenData && screenDiv) {
|
||||
// try/catch : une erreur inattendue pendant la régénération (ex.
|
||||
// structure de données imprévue) ne doit jamais empêcher les
|
||||
// réattachements ci-dessous de s'exécuter — sinon le Répéteur (ou
|
||||
// tout autre élément cliquable) reste silencieusement inerte pour
|
||||
// le reste de la partie, sans le moindre message d'erreur visible.
|
||||
try {
|
||||
// "repeaterItem" (liste de données), "jaugeBar" (2.1 — jauge liée
|
||||
// à une donnée), "visibilityGated" (condition de visibilité) et
|
||||
// "dataBound" ("Donnée liée" d'un Texte/Titre — voir
|
||||
// render_element_html.py) sont les types d'éléments dont le
|
||||
// contenu/l'affichage dépend de la donnée elle-même plutôt que
|
||||
// d'une action "Modifier un élément" posée à la main dans le
|
||||
// graphe — eux seuls doivent être régénérés après un changement
|
||||
// de donnée ou de variable.
|
||||
//
|
||||
// On cible ces éléments à N'IMPORTE QUEL niveau d'imbrication
|
||||
// (pas seulement le premier niveau de l'écran), via leur
|
||||
// data-element-id — disponible sur chaque élément rendu, pas
|
||||
// seulement les éléments de premier niveau. Avant ce correctif,
|
||||
// un Répéteur niché dans un conteneur (comme ici) n'était JAMAIS
|
||||
// du premier niveau, donc c'est son ANCÊTRE de premier niveau
|
||||
// qui portait le marqueur (le Répéteur imbriqué à l'intérieur)
|
||||
// et se retrouvait régénéré en entier à sa place — potentiellement
|
||||
// l'écran entier (jauges, onglets... compris) s'il n'y avait
|
||||
// qu'un seul gros conteneur racine.
|
||||
//
|
||||
// Parmi les éléments marqués, on ne garde que les plus
|
||||
// SPÉCIFIQUES (les plus profonds) : un ancêtre est presque
|
||||
// toujours "marqué" lui aussi dès qu'un descendant l'est, puisque
|
||||
// son rendered_html contient celui de ses descendants — le
|
||||
// garder EN PLUS régénérerait tout son sous-arbre pour rien.
|
||||
// (Une version antérieure de ce filtre vérifiait l'inverse —
|
||||
// exclure un élément si un ANCÊTRE est marqué — ce qui ne
|
||||
// gardait en pratique que l'ancêtre racine de l'écran et
|
||||
// annulait complètement l'effet recherché.)
|
||||
const byId = {};
|
||||
screenData.elements.forEach((e) => { byId[e.id] = e; });
|
||||
const hasMarker = (e) => !!(e.rendered_html && (
|
||||
e.rendered_html.indexOf('repeaterItem') !== -1
|
||||
|| e.rendered_html.indexOf('jaugeBar') !== -1
|
||||
|| e.rendered_html.indexOf('visibilityGated') !== -1
|
||||
|| e.rendered_html.indexOf('dataBound') !== -1
|
||||
));
|
||||
const isDescendantOf = (e, ancestorId) => {
|
||||
let p = e.parent_id ? byId[e.parent_id] : null;
|
||||
while (p) {
|
||||
if (p.id === ancestorId) return true;
|
||||
p = p.parent_id ? byId[p.parent_id] : null;
|
||||
}
|
||||
}
|
||||
bindClicks();
|
||||
bindHoverTexts();
|
||||
bindHoverTriggers();
|
||||
// Réévalue aussi les nœuds "À l'affichage de l'écran" de l'écran où
|
||||
// l'on se trouve déjà : une donnée vient de changer (ex: un outil
|
||||
// qu'on vient de débloquer), donc l'affichage doit se mettre à jour
|
||||
// tout de suite, sans attendre un aller-retour d'écran.
|
||||
runScreenShowTriggers(window.currentScreenId);
|
||||
return false;
|
||||
};
|
||||
// "visibilityGated"/"dataBound" sont posés comme un COMMENTAIRE
|
||||
// HTML juste après la balise concernée (voir _mark() dans
|
||||
// render_element_html.py) — jamais comme une classe CSS, à la
|
||||
// différence de "repeaterItem"/"jaugeBar" (qui, eux, désignent
|
||||
// un widget à régénérer EN BLOC, lignes/valeur comprises, un
|
||||
// comportement déjà correct pour ces deux-là). Repérer ce
|
||||
// commentaire permet de retrouver, à l'intérieur d'un fragment
|
||||
// fraîchement rendu, l'élément PRÉCIS qui a changé — même s'il
|
||||
// est niché dans un exemplaire d'élément de jeu réutilisable
|
||||
// (ex. un Texte "Donnée liée" à l'intérieur d'une boîte de
|
||||
// dialogue), où lui seul porte l'attribut data-element-id qui
|
||||
// l'identifie, son marqueur ne remontant jusqu'au SEUL élément
|
||||
// de premier niveau existant (l'exemplaire) que parce que son
|
||||
// HTML est imbriqué dans le sien.
|
||||
const COMMENT_MARKERS = ['visibilityGated', 'dataBound'];
|
||||
const findCommentMarkedDescendants = (root) => {
|
||||
const found = [];
|
||||
root.querySelectorAll('[data-element-id]').forEach((node) => {
|
||||
const sib = node.nextSibling;
|
||||
const hasCommentMarker = !!(
|
||||
sib && sib.nodeType === Node.COMMENT_NODE && COMMENT_MARKERS.indexOf(sib.data) !== -1
|
||||
);
|
||||
// Un élément actuellement CACHÉ par une condition de
|
||||
// visibilité (voir render_element_html.py) n'a pas de
|
||||
// commentaire marqueur à sa suite — sa balise porte
|
||||
// directement la classe "visibilityGated" à la place. Sans
|
||||
// ce second cas, un élément qui passe de VISIBLE à CACHÉ
|
||||
// n'était jamais retrouvé ici (seul le sens caché->visible
|
||||
// l'était, via le commentaire) : son ancien nœud visible
|
||||
// restait donc affiché indéfiniment à côté du nouveau,
|
||||
// maintenant visible, qui le remplace normalement (ex. une
|
||||
// icône "enveloppe fermée" jamais masquée en même temps que
|
||||
// "enveloppe ouverte" qui apparaît après avoir cliqué).
|
||||
const isHiddenPlaceholder = node.classList.contains('visibilityGated');
|
||||
if (hasCommentMarker || isHiddenPlaceholder) {
|
||||
found.push(node);
|
||||
}
|
||||
});
|
||||
return found;
|
||||
};
|
||||
const markedCandidates = screenData.elements.filter(hasMarker);
|
||||
markedCandidates
|
||||
.filter((e) => !markedCandidates.some((other) => other.id !== e.id && isDescendantOf(other, e.id)))
|
||||
.forEach((el) => {
|
||||
const target = screenDiv.querySelector(`[data-element-id="${el.id}"]`);
|
||||
if (!target) return;
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = el.rendered_html;
|
||||
const newNode = container.firstElementChild;
|
||||
if (!newNode) return;
|
||||
const innerMarked = findCommentMarkedDescendants(newNode);
|
||||
if (innerMarked.length) {
|
||||
// Des éléments plus précis existent À L'INTÉRIEUR (ex. un
|
||||
// Texte "Donnée liée" posé dans un élément de jeu
|
||||
// réutilisable) : on ne patche QUE ceux-là, en préservant
|
||||
// tout le reste — une boîte de dialogue ne doit ni
|
||||
// disparaître ni réapparaître juste parce qu'un texte à
|
||||
// l'intérieur a changé.
|
||||
innerMarked.forEach((innerNode) => {
|
||||
const innerId = innerNode.getAttribute('data-element-id');
|
||||
const liveInner = target.querySelector(`[data-element-id="${innerId}"]`);
|
||||
if (liveInner) liveInner.replaceWith(innerNode);
|
||||
});
|
||||
} else {
|
||||
target.replaceWith(newNode);
|
||||
}
|
||||
});
|
||||
applyOpenRowBindings();
|
||||
} catch (err) {
|
||||
console.error('refreshRuntimeData: erreur pendant la régénération de l\'écran', err);
|
||||
}
|
||||
}
|
||||
bindClicks();
|
||||
bindHoverTexts();
|
||||
bindHoverTriggers();
|
||||
// Réévalue aussi les nœuds "À l'affichage de l'écran" de l'écran où
|
||||
// l'on se trouve déjà : une donnée vient de changer (ex: un outil
|
||||
// qu'on vient de débloquer), donc l'affichage doit se mettre à jour
|
||||
// tout de suite, sans attendre un aller-retour d'écran.
|
||||
runScreenShowTriggers(window.currentScreenId);
|
||||
}
|
||||
|
||||
@@ -12,24 +12,26 @@
|
||||
// en ligne et dans un export SCORM (voir static/js/play/offline/), aucun
|
||||
// branchement FORGE_OFFLINE nécessaire ici.
|
||||
|
||||
var activeCollisionRuleInterval = null;
|
||||
var activeCollisionRuleInteractListener = null;
|
||||
let activeCollisionRuleInterval = null;
|
||||
let activeCollisionRuleInteractListener = null;
|
||||
// État "actif" par (objectId, ruleIndex) — pour ne déclencher une action
|
||||
// qu'à l'ENTRÉE dans la collision/le périmètre, jamais à chaque tick tant
|
||||
// que le joueur y reste (le comportement attendu d'un déclencheur, pas
|
||||
// d'un effet répété 20 fois/seconde).
|
||||
var forgeCollisionRuleActiveState = {};
|
||||
let forgeCollisionRuleActiveState = {};
|
||||
|
||||
function forgeCollisionRuleStateKey(objectId, ruleIndex) {
|
||||
return objectId + ':' + ruleIndex;
|
||||
return `${objectId}:${ruleIndex}`;
|
||||
}
|
||||
|
||||
function forgeElementBoxRect(el, collision) {
|
||||
var left = parseFloat(el.style.left) || 0;
|
||||
var top = parseFloat(el.style.top) || 0;
|
||||
var width = parseFloat(el.style.width) || 0;
|
||||
var height = parseFloat(el.style.height) || 0;
|
||||
return forgeCollisionRectFromBox({ left: left, top: top, width: width, height: height }, collision);
|
||||
const left = parseFloat(el.style.left) || 0;
|
||||
const top = parseFloat(el.style.top) || 0;
|
||||
const width = parseFloat(el.style.width) || 0;
|
||||
const height = parseFloat(el.style.height) || 0;
|
||||
return forgeCollisionRectFromBox({
|
||||
left, top, width, height,
|
||||
}, collision);
|
||||
}
|
||||
|
||||
// Une marge (px) appliquée SEULEMENT à la détection du déclencheur
|
||||
@@ -49,13 +51,16 @@ function forgeElementBoxRect(el, collision) {
|
||||
// restant petit face à la taille d'un sprite (souvent 64-128px) pour ne
|
||||
// jamais déclencher "à la collision" alors que les objets sont encore
|
||||
// visiblement séparés.
|
||||
var FORGE_COLLISION_RULE_TOUCH_MARGIN = 24;
|
||||
const FORGE_COLLISION_RULE_TOUCH_MARGIN = 24;
|
||||
|
||||
function forgeExpandRect(rect, margin) {
|
||||
return {
|
||||
left: rect.left - margin, top: rect.top - margin,
|
||||
right: rect.right + margin, bottom: rect.bottom + margin,
|
||||
width: rect.width + margin * 2, height: rect.height + margin * 2,
|
||||
left: rect.left - margin,
|
||||
top: rect.top - margin,
|
||||
right: rect.right + margin,
|
||||
bottom: rect.bottom + margin,
|
||||
width: rect.width + margin * 2,
|
||||
height: rect.height + margin * 2,
|
||||
shape: rect.shape,
|
||||
};
|
||||
}
|
||||
@@ -77,7 +82,7 @@ function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) {
|
||||
// déjà position:relative — .sceneWorld/.sceneUI, voir static/style.css),
|
||||
// positionnée en JS aux mêmes coordonnées que l'objet.
|
||||
function forgeCollisionInteractBubbleEl(objectId, parentEl) {
|
||||
var bubble = parentEl.querySelector('.collisionInteractBubble[data-for-object-id="' + objectId + '"]');
|
||||
let bubble = parentEl.querySelector(`.collisionInteractBubble[data-for-object-id="${objectId}"]`);
|
||||
if (!bubble) {
|
||||
bubble = document.createElement('div');
|
||||
bubble.className = 'collisionInteractBubble';
|
||||
@@ -97,18 +102,18 @@ function forgeCollisionInteractBubbleEl(objectId, parentEl) {
|
||||
// "c'est trop haut"). La boîte de collision, que l'auteur ajuste déjà
|
||||
// pour épouser la silhouette réelle, donne une ancre bien plus fidèle.
|
||||
function forgeShowCollisionInteractBubble(objectEl, objectId, objRect, key) {
|
||||
var parentEl = objectEl.parentElement;
|
||||
const parentEl = objectEl.parentElement;
|
||||
if (!parentEl) return;
|
||||
var bubble = forgeCollisionInteractBubbleEl(objectId, parentEl);
|
||||
bubble.textContent = 'Appuie sur ' + key;
|
||||
const bubble = forgeCollisionInteractBubbleEl(objectId, parentEl);
|
||||
bubble.textContent = `Appuie sur ${key}`;
|
||||
bubble.style.display = 'block';
|
||||
bubble.style.left = (objRect.left + objRect.width / 2) + 'px'; // centrée horizontalement (voir transform:translateX(-50%), static/style.css)
|
||||
bubble.style.top = objRect.top + 'px'; // AU-DESSUS de la boîte de collision (voir transform:translateY(-100%), static/style.css)
|
||||
bubble.style.left = `${objRect.left + objRect.width / 2}px`; // centrée horizontalement (voir transform:translateX(-50%), static/style.css)
|
||||
bubble.style.top = `${objRect.top}px`; // AU-DESSUS de la boîte de collision (voir transform:translateY(-100%), static/style.css)
|
||||
}
|
||||
|
||||
function forgeHideCollisionInteractBubble(objectId, objectEl) {
|
||||
var parentEl = objectEl && objectEl.parentElement;
|
||||
var bubble = parentEl && parentEl.querySelector('.collisionInteractBubble[data-for-object-id="' + objectId + '"]');
|
||||
const parentEl = objectEl && objectEl.parentElement;
|
||||
const bubble = parentEl && parentEl.querySelector(`.collisionInteractBubble[data-for-object-id="${objectId}"]`);
|
||||
if (bubble) bubble.style.display = 'none';
|
||||
}
|
||||
|
||||
@@ -118,11 +123,11 @@ function forgeHideCollisionInteractBubble(objectId, objectEl) {
|
||||
// collision dure" d'un déclencheur "interagir", peut être posée sur
|
||||
// N'IMPORTE QUEL objet de l'écran (voir action.object_id, pas
|
||||
// nécessairement l'objet qui porte le déclencheur).
|
||||
var FORGE_INDICATION_DEFAULT_DURATION_MS = 3000;
|
||||
var forgeIndicationTimeouts = {};
|
||||
const FORGE_INDICATION_DEFAULT_DURATION_MS = 3000;
|
||||
const forgeIndicationTimeouts = {};
|
||||
|
||||
function forgeIndicationBubbleEl(objectId, parentEl) {
|
||||
var bubble = parentEl.querySelector('.collisionIndicationBubble[data-for-object-id="' + objectId + '"]');
|
||||
let bubble = parentEl.querySelector(`.collisionIndicationBubble[data-for-object-id="${objectId}"]`);
|
||||
if (!bubble) {
|
||||
bubble = document.createElement('div');
|
||||
bubble.className = 'collisionIndicationBubble';
|
||||
@@ -133,17 +138,17 @@ function forgeIndicationBubbleEl(objectId, parentEl) {
|
||||
}
|
||||
|
||||
function forgeShowIndicationBubble(objectId, texte, dureeMs) {
|
||||
var targetEl = document.querySelector('[data-object-id="' + objectId + '"]');
|
||||
var parentEl = targetEl && targetEl.parentElement;
|
||||
const targetEl = document.querySelector(`[data-object-id="${objectId}"]`);
|
||||
const parentEl = targetEl && targetEl.parentElement;
|
||||
if (!targetEl || !parentEl || !texte) return;
|
||||
var objRect = forgeElementBoxRect(targetEl);
|
||||
var bubble = forgeIndicationBubbleEl(objectId, parentEl);
|
||||
const objRect = forgeElementBoxRect(targetEl);
|
||||
const bubble = forgeIndicationBubbleEl(objectId, parentEl);
|
||||
bubble.textContent = texte;
|
||||
bubble.style.display = 'block';
|
||||
bubble.style.left = (objRect.left + objRect.width / 2) + 'px';
|
||||
bubble.style.top = objRect.top + 'px';
|
||||
bubble.style.left = `${objRect.left + objRect.width / 2}px`;
|
||||
bubble.style.top = `${objRect.top}px`;
|
||||
if (forgeIndicationTimeouts[objectId]) clearTimeout(forgeIndicationTimeouts[objectId]);
|
||||
forgeIndicationTimeouts[objectId] = setTimeout(function () {
|
||||
forgeIndicationTimeouts[objectId] = setTimeout(() => {
|
||||
bubble.style.display = 'none';
|
||||
}, dureeMs || FORGE_INDICATION_DEFAULT_DURATION_MS);
|
||||
}
|
||||
@@ -168,8 +173,8 @@ function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
||||
// s'exécuter immédiatement — un dialogue attend des clics du joueur
|
||||
// — transmis à forgeShowDialogueBox, exécuté par dialogue-box-
|
||||
// controller.js une fois le dialogue épuisé.
|
||||
var lines = (action.dialogue && action.dialogue.lines) || [];
|
||||
var dialogueId = action.dialogue && action.dialogue.id;
|
||||
const lines = (action.dialogue && action.dialogue.lines) || [];
|
||||
const dialogueId = action.dialogue && action.dialogue.id;
|
||||
forgeShowDialogueBox(dialogueId, obj.name || 'PNJ', lines, action.mark_completed, action.then);
|
||||
} else if (action.type === 'variable') {
|
||||
// Modifie une variable globale — même vocabulaire/calcul que l'action
|
||||
@@ -187,30 +192,30 @@ function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
||||
// même comparaison qu'un nœud Condition de l'éditeur de logique, sur
|
||||
// une VARIABLE GLOBALE (gameData.variables) — jamais un champ d'objet
|
||||
// ici (voir maquette validée : le losange ne teste qu'une variable).
|
||||
var isTrue = evaluateConditionClause({
|
||||
const isTrue = evaluateConditionClause({
|
||||
source: 'variable', variable: action.variable, operator: action.operateur, value: action.valeur,
|
||||
});
|
||||
var branch = isTrue ? action.si_vrai : action.si_faux;
|
||||
const branch = isTrue ? action.si_vrai : action.si_faux;
|
||||
if (branch) forgeRunCollisionRuleAction(branch, targetEl, obj);
|
||||
} else if (action.type === 'surbrillance' || action.type === 'visibilite') {
|
||||
// Cible N'IMPORTE QUEL objet de l'écran (action.object_id, pas
|
||||
// forcément celui qui porte le déclencheur) — réutilise TEL QUEL
|
||||
// applyObjectProperty (static/js/play/actions.js, équivalent scène 2D
|
||||
// d'applyElementProperty), plutôt que de dupliquer cette logique.
|
||||
var targetObjEl = document.querySelector('[data-object-id="' + action.object_id + '"]');
|
||||
const targetObjEl = document.querySelector(`[data-object-id="${action.object_id}"]`);
|
||||
if (targetObjEl) applyObjectProperty(targetObjEl, action.type, action.valeur);
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'son') {
|
||||
// Effet sonore ponctuel, non bouclé — même comportement que l'action
|
||||
// "Jouer un son" du Flow (static/js/play/actions.js), instantané :
|
||||
// `then` s'exécute tout de suite, sans attendre la fin du son.
|
||||
if (action.asset_url) new Audio(action.asset_url).play().catch(function () {});
|
||||
if (action.asset_url) new Audio(action.asset_url).play().catch(() => {});
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
} else if (action.type === 'video') {
|
||||
// Bloque la suite de la chaîne "then" jusqu'à la fin de la vidéo (ou
|
||||
// "Passer") — voir static/js/play/video-overlay.js, même esprit
|
||||
// qu'un dialogue ci-dessus.
|
||||
forgeShowVideoOverlay(action.asset_url, action.mode, function () {
|
||||
forgeShowVideoOverlay(action.asset_url, action.mode, () => {
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
});
|
||||
} else if (action.type === 'indication') {
|
||||
@@ -221,37 +226,37 @@ function forgeRunCollisionRuleAction(action, targetEl, obj) {
|
||||
// vocabulaire que l'action "attendre" du Flow (static/js/play/
|
||||
// actions.js), utile ex. pour laisser un temps de lecture après une
|
||||
// narration à l'affichage de l'écran.
|
||||
var seconds = parseFloat(action.data_value);
|
||||
var ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
setTimeout(function () {
|
||||
const seconds = parseFloat(action.data_value);
|
||||
const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000;
|
||||
setTimeout(() => {
|
||||
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
|
||||
}, ms);
|
||||
}
|
||||
}
|
||||
|
||||
function forgeCollisionRuleTick(screenData) {
|
||||
var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
|
||||
var playerEl = player && document.querySelector('[data-object-id="' + player.id + '"]');
|
||||
const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||
const playerEl = player && document.querySelector(`[data-object-id="${player.id}"]`);
|
||||
if (!player || !playerEl) return;
|
||||
var playerRect = forgeElementBoxRect(playerEl, player.collision);
|
||||
const playerRect = forgeElementBoxRect(playerEl, player.collision);
|
||||
|
||||
(screenData.elements || []).forEach(function (obj) {
|
||||
var rules = obj.collision_rules;
|
||||
(screenData.elements || []).forEach((obj) => {
|
||||
const rules = obj.collision_rules;
|
||||
if (!rules || !rules.length) return;
|
||||
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
var objRect = forgeElementBoxRect(targetEl, obj.collision);
|
||||
const objRect = forgeElementBoxRect(targetEl, obj.collision);
|
||||
|
||||
var anyInteractActive = false;
|
||||
rules.forEach(function (rule, ruleIndex) {
|
||||
let anyInteractActive = false;
|
||||
rules.forEach((rule, ruleIndex) => {
|
||||
// "clic"/"survol" sont liés à part (voir forgeBindClicSurvolRules,
|
||||
// écouteurs DOM directs) — jamais évalués ici, sans quoi ils
|
||||
// s'exécuteraient EN PLUS, à la proximité du joueur, en plus de
|
||||
// leur propre déclencheur.
|
||||
if (rule.trigger !== 'collision') return;
|
||||
var stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex);
|
||||
var isActive = forgeCollisionRuleTriggerActive(rule, objRect, playerRect);
|
||||
var wasActive = !!forgeCollisionRuleActiveState[stateKey];
|
||||
const stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex);
|
||||
const isActive = forgeCollisionRuleTriggerActive(rule, objRect, playerRect);
|
||||
const wasActive = !!forgeCollisionRuleActiveState[stateKey];
|
||||
|
||||
if (rule.action.type === 'interagir') {
|
||||
if (isActive) {
|
||||
@@ -274,16 +279,16 @@ function forgeCollisionRuleTick(screenData) {
|
||||
// qu'elle reste enfoncée.
|
||||
function forgeStartCollisionRuleInteractListener(screenData) {
|
||||
activeCollisionRuleInteractListener = function (ev) {
|
||||
var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
|
||||
const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||
if (!player || !player.personnage_commandes || ev.key !== player.personnage_commandes.interagir) return;
|
||||
(screenData.elements || []).forEach(function (obj) {
|
||||
var rules = obj.collision_rules;
|
||||
(screenData.elements || []).forEach((obj) => {
|
||||
const rules = obj.collision_rules;
|
||||
if (!rules || !rules.length) return;
|
||||
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
rules.forEach(function (rule, ruleIndex) {
|
||||
rules.forEach((rule, ruleIndex) => {
|
||||
if (rule.action.type !== 'interagir') return;
|
||||
var stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex);
|
||||
const stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex);
|
||||
if (!forgeCollisionRuleActiveState[stateKey]) return;
|
||||
forgeRunCollisionRuleAction(rule.action.sub_action, targetEl, obj);
|
||||
});
|
||||
@@ -298,26 +303,26 @@ function forgeStartCollisionRuleInteractListener(screenData) {
|
||||
// posé une fois par objet suffit (voir screens/rendering/
|
||||
// collision_rules.py). Re-attachés à chaque affichage d'écran, comme le
|
||||
// reste de ce fichier.
|
||||
var activeClicSurvolListeners = [];
|
||||
let activeClicSurvolListeners = [];
|
||||
|
||||
function forgeUnbindClicSurvolRules() {
|
||||
activeClicSurvolListeners.forEach(function (l) { l.el.removeEventListener(l.event, l.handler); });
|
||||
activeClicSurvolListeners.forEach((l) => { l.el.removeEventListener(l.event, l.handler); });
|
||||
activeClicSurvolListeners = [];
|
||||
}
|
||||
|
||||
function forgeBindClicSurvolRules(screenData) {
|
||||
forgeUnbindClicSurvolRules();
|
||||
(screenData.elements || []).forEach(function (obj) {
|
||||
var rules = obj.collision_rules;
|
||||
(screenData.elements || []).forEach((obj) => {
|
||||
const rules = obj.collision_rules;
|
||||
if (!rules || !rules.length) return;
|
||||
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
rules.forEach(function (rule) {
|
||||
rules.forEach((rule) => {
|
||||
if (rule.trigger !== 'clic' && rule.trigger !== 'survol') return;
|
||||
var eventName = rule.trigger === 'clic' ? 'click' : 'mouseenter';
|
||||
var handler = function () { forgeRunCollisionRuleAction(rule.action, targetEl, obj); };
|
||||
const eventName = rule.trigger === 'clic' ? 'click' : 'mouseenter';
|
||||
const handler = function () { forgeRunCollisionRuleAction(rule.action, targetEl, obj); };
|
||||
targetEl.addEventListener(eventName, handler);
|
||||
activeClicSurvolListeners.push({ el: targetEl, event: eventName, handler: handler });
|
||||
activeClicSurvolListeners.push({ el: targetEl, event: eventName, handler });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -343,22 +348,29 @@ function forgeStopCollisionRuleControllers() {
|
||||
function forgeStartCollisionRuleControllers(screenData) {
|
||||
forgeStopCollisionRuleControllers();
|
||||
if (!screenData || screenData.kind !== 'jeu_2d') return;
|
||||
var elements = screenData.elements || [];
|
||||
const elements = screenData.elements || [];
|
||||
forgeBindClicSurvolRules(screenData);
|
||||
var hasPlayer = elements.some(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
|
||||
var hasRules = elements.some(function (o) { return o.collision_rules && o.collision_rules.length; });
|
||||
const hasPlayer = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||
const hasRules = elements.some((o) => o.collision_rules && o.collision_rules.length);
|
||||
if (!hasPlayer || !hasRules) return;
|
||||
activeCollisionRuleInterval = setInterval(function () { forgeCollisionRuleTick(screenData); }, 50);
|
||||
activeCollisionRuleInterval = setInterval(() => { forgeCollisionRuleTick(screenData); }, 50);
|
||||
forgeStartCollisionRuleInteractListener(screenData);
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même garde que personnage-controller.js.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick,
|
||||
forgeStartCollisionRuleControllers, forgeStopCollisionRuleControllers,
|
||||
forgeCollisionRuleStateKey, forgeElementBoxRect, forgeExpandRect,
|
||||
forgeBindClicSurvolRules, forgeUnbindClicSurvolRules, forgeShowIndicationBubble,
|
||||
forgeCollisionRuleTriggerActive,
|
||||
forgeRunCollisionRuleAction,
|
||||
forgeCollisionRuleTick,
|
||||
forgeStartCollisionRuleControllers,
|
||||
forgeStopCollisionRuleControllers,
|
||||
forgeCollisionRuleStateKey,
|
||||
forgeElementBoxRect,
|
||||
forgeExpandRect,
|
||||
forgeBindClicSurvolRules,
|
||||
forgeUnbindClicSurvolRules,
|
||||
forgeShowIndicationBubble,
|
||||
};
|
||||
const conditionsMod = require('./conditions.js');
|
||||
global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox;
|
||||
|
||||
@@ -19,7 +19,7 @@ function readFieldValue(definitionId, rowId, fieldName) {
|
||||
// capturé (voir bindClicks() dans triggers.js).
|
||||
if (rowId === -1) rowId = window.lastClickedRowId;
|
||||
const rows = gameData.data[String(definitionId)] || [];
|
||||
const row = rows.find(r => r.id === rowId);
|
||||
const row = rows.find((r) => r.id === rowId);
|
||||
return row ? row[fieldName] : undefined;
|
||||
}
|
||||
|
||||
@@ -43,8 +43,9 @@ function compareValues(actual, operator, expected, fieldType) {
|
||||
const e = normalizeBool(expected);
|
||||
return operator === 'different' ? a !== e : a === e;
|
||||
}
|
||||
const an = parseFloat(actual), en = parseFloat(expected);
|
||||
const numeric = !isNaN(an) && !isNaN(en);
|
||||
const an = parseFloat(actual); const
|
||||
en = parseFloat(expected);
|
||||
const numeric = !Number.isNaN(an) && !Number.isNaN(en);
|
||||
if (numeric) {
|
||||
switch (operator) {
|
||||
case 'egal': return an === en;
|
||||
@@ -53,6 +54,7 @@ function compareValues(actual, operator, expected, fieldType) {
|
||||
case 'inferieur': return an < en;
|
||||
case 'superieur_egal': return an >= en;
|
||||
case 'inferieur_egal': return an <= en;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
const as = (actual === undefined || actual === null) ? '' : String(actual);
|
||||
@@ -63,7 +65,8 @@ function compareValues(actual, operator, expected, fieldType) {
|
||||
// créateur/l'IA n'a aucun moyen de connaître la casse EXACTE attendue).
|
||||
// superieur/inferieur restent une comparaison lexicographique brute
|
||||
// (l'ordre alphabétique dépend de la casse, hors du champ de ce bug).
|
||||
const asLower = as.toLowerCase(), esLower = es.toLowerCase();
|
||||
const asLower = as.toLowerCase(); const
|
||||
esLower = es.toLowerCase();
|
||||
switch (operator) {
|
||||
case 'egal': return asLower === esLower;
|
||||
case 'different': return asLower !== esLower;
|
||||
@@ -98,7 +101,7 @@ function resolveVariablePath(rawValue, path) {
|
||||
if (!path) return rawValue;
|
||||
let current;
|
||||
try { current = rawValue ? JSON.parse(rawValue) : null; } catch (e) { return null; }
|
||||
const segmentRe = /\.([^.\[\]]+)|\[(\d+)\]/g;
|
||||
const segmentRe = /\.([^.[\]]+)|\[(\d+)\]/g;
|
||||
let m;
|
||||
while ((m = segmentRe.exec(path)) !== null) {
|
||||
if (current === null || current === undefined) return null;
|
||||
@@ -133,7 +136,9 @@ function forgeCollisionRectFromBox(box, collision) {
|
||||
const left = box.left + (box.width - width) / 2 + offsetX;
|
||||
const top = box.top + (box.height - height) / 2 + offsetY;
|
||||
const shape = (collision && collision.shape) || 'rectangle';
|
||||
return { left, top, right: left + width, bottom: top + height, width, height, shape };
|
||||
return {
|
||||
left, top, right: left + width, bottom: top + height, width, height, shape,
|
||||
};
|
||||
}
|
||||
|
||||
function forgeRectanglesOverlap(a, b) {
|
||||
@@ -145,9 +150,12 @@ function forgeRectanglesOverlap(a, b) {
|
||||
// volontairement approximatif plutôt qu'une vraie intersection cercle-
|
||||
// rectangle, hors scope de ce premier réglage).
|
||||
function forgeCirclesOverlap(a, b) {
|
||||
const acx = a.left + a.width / 2, acy = a.top + a.height / 2, ar = Math.min(a.width, a.height) / 2;
|
||||
const bcx = b.left + b.width / 2, bcy = b.top + b.height / 2, br = Math.min(b.width, b.height) / 2;
|
||||
const dx = acx - bcx, dy = acy - bcy;
|
||||
const acx = a.left + a.width / 2; const acy = a.top + a.height / 2; const
|
||||
ar = Math.min(a.width, a.height) / 2;
|
||||
const bcx = b.left + b.width / 2; const bcy = b.top + b.height / 2; const
|
||||
br = Math.min(b.width, b.height) / 2;
|
||||
const dx = acx - bcx; const
|
||||
dy = acy - bcy;
|
||||
return Math.sqrt(dx * dx + dy * dy) < (ar + br);
|
||||
}
|
||||
|
||||
@@ -174,14 +182,17 @@ function forgeShapesOverlap(a, b) {
|
||||
// nulles), ou collision explicitement désactivée => pas de chevauchement
|
||||
// plutôt que de planter.
|
||||
function elementsOverlap(elementIdA, elementIdB) {
|
||||
const a = document.querySelector('[data-element-id="' + elementIdA + '"]');
|
||||
const b = document.querySelector('[data-element-id="' + elementIdB + '"]');
|
||||
const a = document.querySelector(`[data-element-id="${elementIdA}"]`);
|
||||
const b = document.querySelector(`[data-element-id="${elementIdB}"]`);
|
||||
if (!a || !b) return false;
|
||||
const screenData = (typeof screensData !== 'undefined' && typeof window !== 'undefined')
|
||||
&& screensData.find(function (s) { return s.id === window.currentScreenId; });
|
||||
const objA = screenData && (screenData.elements || []).find(function (o) { return o.id === elementIdA; });
|
||||
const objB = screenData && (screenData.elements || []).find(function (o) { return o.id === elementIdB; });
|
||||
if ((objA && objA.collision && objA.collision.enabled === false) || (objB && objB.collision && objB.collision.enabled === false)) {
|
||||
&& screensData.find((s) => s.id === window.currentScreenId);
|
||||
const objA = screenData && (screenData.elements || []).find((o) => o.id === elementIdA);
|
||||
const objB = screenData && (screenData.elements || []).find((o) => o.id === elementIdB);
|
||||
if (
|
||||
(objA && objA.collision && objA.collision.enabled === false)
|
||||
|| (objB && objB.collision && objB.collision.enabled === false)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const rectA = forgeCollisionRectFromBox(a.getBoundingClientRect(), objA && objA.collision);
|
||||
@@ -209,7 +220,11 @@ function evaluateConditionClause(clause) {
|
||||
const fieldType = varInfo && varInfo.type === 'booleen' ? 'booleen' : '';
|
||||
return compareValues(actual, operator, expected, fieldType);
|
||||
}
|
||||
const actual = readFieldValue(clause.definition_id ?? clause.cond_definition_id, clause.row_id ?? clause.cond_row_id, clause.field ?? clause.cond_field);
|
||||
const actual = readFieldValue(
|
||||
clause.definition_id ?? clause.cond_definition_id,
|
||||
clause.row_id ?? clause.cond_row_id,
|
||||
clause.field ?? clause.cond_field,
|
||||
);
|
||||
return compareValues(actual, operator, expected, clause.field_type ?? clause.cond_field_type);
|
||||
}
|
||||
|
||||
@@ -230,7 +245,15 @@ function evaluateConditionNode(node) {
|
||||
// cette branche ne s'exécute jamais côté client.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
readFieldValue, compareValues, readVariableValue, resolveVariablePath, evaluateConditionClause, evaluateConditionNode,
|
||||
forgeCollisionRectFromBox, forgeRectanglesOverlap, forgeCirclesOverlap, forgeShapesOverlap,
|
||||
readFieldValue,
|
||||
compareValues,
|
||||
readVariableValue,
|
||||
resolveVariablePath,
|
||||
evaluateConditionClause,
|
||||
evaluateConditionNode,
|
||||
forgeCollisionRectFromBox,
|
||||
forgeRectanglesOverlap,
|
||||
forgeCirclesOverlap,
|
||||
forgeShapesOverlap,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@
|
||||
// Silencieux (aucun effet) si le widget requis n'est pas posé sur
|
||||
// l'écran — même esprit que le reste du moteur.
|
||||
|
||||
var forgeDialogueBoxState = {};
|
||||
var forgeQuizBoxState = {};
|
||||
const forgeDialogueBoxState = {};
|
||||
const forgeQuizBoxState = {};
|
||||
window.forgeQuizScore = window.forgeQuizScore || 0;
|
||||
// Compte TOUTE réponse (bonne ou mauvaise) au fil de la partie — sert
|
||||
// uniquement à calculer un taux de réussite global (voir
|
||||
@@ -56,17 +56,18 @@ window.forgeCompletedDialogueIds = window.forgeCompletedDialogueIds || {};
|
||||
|
||||
function forgeEscapeHtml(str) {
|
||||
return String(str == null ? '' : str)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ---------- Boîte de dialogue ----------
|
||||
|
||||
function forgeDialogueBoxWidgetOnCurrentScreen() {
|
||||
return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .dialogueBoxWidget');
|
||||
return document.querySelector(`.playScreen[data-screen-id="${window.currentScreenId}"] .dialogueBoxWidget`);
|
||||
}
|
||||
|
||||
function forgeDialogueBoxWidgetById(objectId) {
|
||||
return document.querySelector('.dialogueBoxWidget[data-object-id="' + objectId + '"]');
|
||||
return document.querySelector(`.dialogueBoxWidget[data-object-id="${objectId}"]`);
|
||||
}
|
||||
|
||||
function forgeDialogueBoxFooterEl(widget) {
|
||||
@@ -78,7 +79,7 @@ function forgeDialogueBoxFooterEl(widget) {
|
||||
// que la bulle s'affiche, arrêtée dès qu'une AUTRE ligne (avec ou sans
|
||||
// sa propre voix off) prend sa place, pour ne jamais superposer deux
|
||||
// voix off en même temps si le joueur avance vite dans la conversation.
|
||||
var forgeDialogueLineAudio = null;
|
||||
let forgeDialogueLineAudio = null;
|
||||
|
||||
function forgeStopDialogueLineAudio() {
|
||||
if (forgeDialogueLineAudio) {
|
||||
@@ -88,14 +89,16 @@ function forgeStopDialogueLineAudio() {
|
||||
}
|
||||
|
||||
function forgeRenderDialogueBoxLine(widget, line) {
|
||||
var header = widget.querySelector('[data-dialogue-role="header"]');
|
||||
var body = widget.querySelector('[data-dialogue-role="body"]');
|
||||
const header = widget.querySelector('[data-dialogue-role="header"]');
|
||||
const body = widget.querySelector('[data-dialogue-role="body"]');
|
||||
if (header) header.textContent = line.speaker;
|
||||
if (body) body.textContent = line.text;
|
||||
forgeStopDialogueLineAudio();
|
||||
if (line.audio_url) {
|
||||
forgeDialogueLineAudio = new Audio(line.audio_url);
|
||||
forgeDialogueLineAudio.play().catch(function () {}); // lecture bloquée par le navigateur (pas d'interaction récente) : la bulle reste lisible sans voix off plutôt que de planter.
|
||||
// lecture bloquée par le navigateur (pas d'interaction récente) : la
|
||||
// bulle reste lisible sans voix off plutôt que de planter.
|
||||
forgeDialogueLineAudio.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,24 +106,24 @@ function forgeRenderDialogueBoxLine(widget, line) {
|
||||
// "Suivant →", voir render_scene_object.py) — appelé avant CHAQUE ligne
|
||||
// affichée (voir forgeShowCurrentConversationLine).
|
||||
function forgeResetDialogueBoxFooter(widget, objectId) {
|
||||
var footer = forgeDialogueBoxFooterEl(widget);
|
||||
if (footer) footer.innerHTML = '<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(' + objectId + ')">Suivant →</button>';
|
||||
const footer = forgeDialogueBoxFooterEl(widget);
|
||||
if (footer) footer.innerHTML = `<button type="button" class="dialogueBoxNextBtn" data-dialogue-role="next-btn" onclick="forgeDialogueBoxAdvance(${objectId})">Suivant →</button>`;
|
||||
}
|
||||
|
||||
// ---------- Boîte à quiz ----------
|
||||
|
||||
function forgeQuizBoxWidgetOnCurrentScreen() {
|
||||
var scoped = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .quizBoxWidget');
|
||||
const scoped = document.querySelector(`.playScreen[data-screen-id="${window.currentScreenId}"] .quizBoxWidget`);
|
||||
if (scoped) return scoped;
|
||||
// Un quiz PLEIN ÉCRAN a été déplacé hors de ".playScreen" (voir
|
||||
// forgeEscapeCameraForFullscreen ci-dessous) : ne s'y trouve donc plus
|
||||
// une fois la première question affichée — retrouvé via l'attribut
|
||||
// posé au déplacement plutôt que par sa position dans le DOM.
|
||||
return document.querySelector('.quizBoxWidget[data-owner-screen-id="' + window.currentScreenId + '"]');
|
||||
return document.querySelector(`.quizBoxWidget[data-owner-screen-id="${window.currentScreenId}"]`);
|
||||
}
|
||||
|
||||
function forgeQuizBoxWidgetById(objectId) {
|
||||
return document.querySelector('.quizBoxWidget[data-object-id="' + objectId + '"]');
|
||||
return document.querySelector(`.quizBoxWidget[data-object-id="${objectId}"]`);
|
||||
}
|
||||
|
||||
// Bug signalé : "aucune différence, la taille de la fenêtre de jeu est
|
||||
@@ -137,7 +140,7 @@ function forgeQuizBoxWidgetById(objectId) {
|
||||
// dessus de le retrouver malgré le déplacement.
|
||||
function forgeEscapeCameraForFullscreen(widget) {
|
||||
if (widget.parentNode === document.body) return;
|
||||
var screenEl = widget.closest('.playScreen');
|
||||
const screenEl = widget.closest('.playScreen');
|
||||
if (screenEl) widget.setAttribute('data-owner-screen-id', screenEl.getAttribute('data-screen-id'));
|
||||
widget.classList.add('quizBoxWidget--detachedForFullscreen');
|
||||
document.body.appendChild(widget);
|
||||
@@ -149,10 +152,10 @@ function forgeEscapeCameraForFullscreen(widget) {
|
||||
// n'est jamais laissé actif par erreur, quel que soit le chemin de
|
||||
// sortie (voir forgeShowCurrentConversationLine/forgeAdvanceConversation).
|
||||
function forgeHideQuizBoxOnCurrentScreen() {
|
||||
var widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
if (!widget) return;
|
||||
widget.classList.remove('is-active', 'is-fullscreen');
|
||||
var objectId = widget.getAttribute('data-object-id');
|
||||
const objectId = widget.getAttribute('data-object-id');
|
||||
forgeStopQuizTimer(objectId);
|
||||
}
|
||||
|
||||
@@ -164,10 +167,10 @@ function forgeHideQuizBoxOnCurrentScreen() {
|
||||
// quiz_box_templates.py), lus ici plutôt que par un nouveau canal de
|
||||
// payload séparé — cohérent avec le reste de ce moteur (data-* posés au
|
||||
// rendu, jamais un aller-retour serveur pour du pur affichage).
|
||||
var forgeQuizTimerState = {};
|
||||
const forgeQuizTimerState = {};
|
||||
|
||||
function forgeStopQuizTimer(objectId) {
|
||||
var t = forgeQuizTimerState[objectId];
|
||||
const t = forgeQuizTimerState[objectId];
|
||||
if (t && t.intervalId) clearInterval(t.intervalId);
|
||||
delete forgeQuizTimerState[objectId];
|
||||
}
|
||||
@@ -178,18 +181,19 @@ function forgeStopQuizTimer(objectId) {
|
||||
// d'une question à l'autre sans se réinitialiser.
|
||||
function forgeStartQuizTimer(objectId, widget, seconds, onExpire) {
|
||||
forgeStopQuizTimer(objectId);
|
||||
var state = { remaining: seconds };
|
||||
const state = { remaining: seconds };
|
||||
forgeQuizTimerState[objectId] = state;
|
||||
var timerEl = widget.querySelector('[data-quiz-role="timer"]');
|
||||
const timerEl = widget.querySelector('[data-quiz-role="timer"]');
|
||||
function render() {
|
||||
if (!timerEl) return;
|
||||
var remaining = Math.max(0, state.remaining);
|
||||
var m = Math.floor(remaining / 60), s = remaining % 60;
|
||||
timerEl.textContent = '⏱ ' + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
const remaining = Math.max(0, state.remaining);
|
||||
const m = Math.floor(remaining / 60); const
|
||||
s = remaining % 60;
|
||||
timerEl.textContent = `⏱ ${m}:${s < 10 ? '0' : ''}${s}`;
|
||||
timerEl.classList.toggle('is-urgent', remaining <= 5);
|
||||
}
|
||||
render();
|
||||
state.intervalId = setInterval(function () {
|
||||
state.intervalId = setInterval(() => {
|
||||
state.remaining -= 1;
|
||||
render();
|
||||
if (state.remaining <= 0) {
|
||||
@@ -202,8 +206,8 @@ function forgeStartQuizTimer(objectId, widget, seconds, onExpire) {
|
||||
// ---------- Score ----------
|
||||
|
||||
function forgeUpdateAllScoreWidgets() {
|
||||
var values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
|
||||
for (var i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore;
|
||||
const values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
|
||||
for (let i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore;
|
||||
forgeUpdateQuizBoxScoreDisplays();
|
||||
}
|
||||
|
||||
@@ -213,8 +217,8 @@ function forgeUpdateAllScoreWidgets() {
|
||||
// .quizBoxTopBar, static/style.css), donc sans effet visuel pour un quiz
|
||||
// non-plein-écran, mais tenu à jour dans tous les cas (coûte rien).
|
||||
function forgeUpdateQuizBoxScoreDisplays() {
|
||||
var values = document.querySelectorAll('.quizBoxWidget [data-quiz-role="score"]');
|
||||
for (var i = 0; i < values.length; i++) values[i].textContent = 'Score : ' + window.forgeQuizScore;
|
||||
const values = document.querySelectorAll('.quizBoxWidget [data-quiz-role="score"]');
|
||||
for (let i = 0; i < values.length; i++) values[i].textContent = `Score : ${window.forgeQuizScore}`;
|
||||
}
|
||||
|
||||
// Alimente aussi gameData.scoring (voir static/js/play/offline/
|
||||
@@ -237,7 +241,7 @@ function forgeSyncQuizScoreToScorm() {
|
||||
// (aucune action de flow "Modifier un score" ici), donc jamais notifié
|
||||
// sans cet appel direct — même bug que le commentaire ci-dessus pour
|
||||
// SCORM.
|
||||
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(gameData.scoring.score);
|
||||
if (typeof forgeXapiNotifyScoreChanged === 'function') forgeXapiNotifyScoreChanged(gameData.scoring.score);
|
||||
}
|
||||
|
||||
// Un dialogue affiché = la partie a commencé, même sans le moindre point
|
||||
@@ -264,21 +268,21 @@ function forgeSyncDialogueStartedToScorm() {
|
||||
// partie. Aucune question posée du tout (jeu fait uniquement de
|
||||
// dialogues) : reste "termine" (ni réussi ni échoué, rien à évaluer).
|
||||
function forgeComputeFinalScormStatus() {
|
||||
var total = window.forgeQuizTotalAnswered || 0;
|
||||
const total = window.forgeQuizTotalAnswered || 0;
|
||||
if (total === 0) return 'termine';
|
||||
var percent = (window.forgeQuizCorrectAnswered / total) * 100;
|
||||
var threshold = typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number' ? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||
const percent = (window.forgeQuizCorrectAnswered / total) * 100;
|
||||
const threshold = typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number' ? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||
return percent >= threshold ? 'reussi' : 'echoue';
|
||||
}
|
||||
|
||||
function forgeSyncAllDialoguesCompletionToScorm() {
|
||||
var completableIds = (gameData.completable_dialogue_ids || []);
|
||||
var allDone = completableIds.length > 0 && completableIds.every(function (id) { return !!window.forgeCompletedDialogueIds[id]; });
|
||||
const completableIds = (gameData.completable_dialogue_ids || []);
|
||||
const allDone = completableIds.length > 0 && completableIds.every((id) => !!window.forgeCompletedDialogueIds[id]);
|
||||
if (!allDone) return;
|
||||
gameData.scoring = gameData.scoring || { score: 0, status: 'non_commence' };
|
||||
var finalStatus = forgeComputeFinalScormStatus();
|
||||
const finalStatus = forgeComputeFinalScormStatus();
|
||||
gameData.scoring.status = finalStatus;
|
||||
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged(finalStatus);
|
||||
if (typeof forgeXapiNotifyStatusChanged === 'function') forgeXapiNotifyStatusChanged(finalStatus);
|
||||
}
|
||||
|
||||
function forgeAddQuizScore(amount) {
|
||||
@@ -295,10 +299,10 @@ function forgeAddQuizScore(amount) {
|
||||
// forgeShowQuizBox ci-dessous, qui avance seule si aucune boîte à quiz
|
||||
// n'est posée : impossible de bloquer la conversation indéfiniment).
|
||||
function forgeShowCurrentConversationLine(dialogueObjectId) {
|
||||
var state = forgeDialogueBoxState[dialogueObjectId];
|
||||
const state = forgeDialogueBoxState[dialogueObjectId];
|
||||
if (!state) return;
|
||||
var line = state.lines[state.index];
|
||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
const line = state.lines[state.index];
|
||||
const widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
if (line.type === 'question') {
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
forgeShowQuizBox(dialogueObjectId, state.dialogueId, state.objectName, line, state.index);
|
||||
@@ -336,8 +340,8 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then
|
||||
// sans boîte de dialogue posée reste ignorée plus loin (voir
|
||||
// forgeShowCurrentConversationLine), même logique symétrique qu'une
|
||||
// question sans boîte à quiz posée (voir forgeShowQuizBox).
|
||||
var widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
||||
var stateKey = widget ? widget.getAttribute('data-object-id') : ('_sansBoiteDialogue_' + dialogueId);
|
||||
const widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
||||
const stateKey = widget ? widget.getAttribute('data-object-id') : (`_sansBoiteDialogue_${dialogueId}`);
|
||||
forgeSyncDialogueStartedToScorm();
|
||||
if (!lines.length) {
|
||||
// Rien à jouer : applique quand même markCompleted PUIS thenAction
|
||||
@@ -347,8 +351,12 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then
|
||||
return;
|
||||
}
|
||||
forgeDialogueBoxState[stateKey] = {
|
||||
dialogueId: dialogueId, objectName: objectName, lines: lines, index: 0,
|
||||
markCompleted: !!markCompleted, thenAction: thenAction,
|
||||
dialogueId,
|
||||
objectName,
|
||||
lines,
|
||||
index: 0,
|
||||
markCompleted: !!markCompleted,
|
||||
thenAction,
|
||||
};
|
||||
forgeShowCurrentConversationLine(stateKey);
|
||||
}
|
||||
@@ -358,7 +366,7 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then
|
||||
// comme terminé" de l'assistant) et propage vers le SCORM/xAPI.
|
||||
function forgeCompleteDialogue(dialogueId, objectName) {
|
||||
window.forgeCompletedDialogueIds[dialogueId] = true;
|
||||
if (typeof forgeXapiNotifyDialogueCompleted === "function") forgeXapiNotifyDialogueCompleted(dialogueId, objectName);
|
||||
if (typeof forgeXapiNotifyDialogueCompleted === 'function') forgeXapiNotifyDialogueCompleted(dialogueId, objectName);
|
||||
forgeSyncAllDialoguesCompletionToScorm();
|
||||
}
|
||||
|
||||
@@ -370,13 +378,13 @@ function forgeCompleteDialogue(dialogueId, objectName) {
|
||||
// déclencheur pour lui ajouter des actions") — MÊME point de sortie "ce
|
||||
// dialogue est fini", jamais avant.
|
||||
function forgeAdvanceConversation(dialogueObjectId) {
|
||||
var state = forgeDialogueBoxState[dialogueObjectId];
|
||||
const state = forgeDialogueBoxState[dialogueObjectId];
|
||||
if (!state) return;
|
||||
state.index += 1;
|
||||
if (state.index >= state.lines.length) {
|
||||
forgeStopDialogueLineAudio();
|
||||
forgeHideQuizBoxOnCurrentScreen();
|
||||
var widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
const widget = forgeDialogueBoxWidgetById(dialogueObjectId);
|
||||
if (state.markCompleted) forgeCompleteDialogue(state.dialogueId, state.objectName);
|
||||
if (state.thenAction) forgeRunCollisionRuleAction(state.thenAction, null, { name: state.objectName });
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
@@ -391,7 +399,7 @@ function forgeAdvanceConversation(dialogueObjectId) {
|
||||
// effet pendant une question en cours (le "Suivant" n'existe pas alors,
|
||||
// voir forgeShowCurrentConversationLine, mais gardé strict par sécurité).
|
||||
function forgeDialogueBoxAdvance(objectId) {
|
||||
var state = forgeDialogueBoxState[objectId];
|
||||
const state = forgeDialogueBoxState[objectId];
|
||||
if (!state || (state.lines[state.index] && state.lines[state.index].type === 'question')) return;
|
||||
forgeAdvanceConversation(objectId);
|
||||
}
|
||||
@@ -403,13 +411,19 @@ function forgeDialogueBoxAdvance(objectId) {
|
||||
// plutôt que de bloquer la conversation pour toujours en attendant une
|
||||
// réponse qu'aucun widget ne peut recueillir.
|
||||
function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questionIndex) {
|
||||
var widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
const widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
|
||||
var quizObjectId = widget.getAttribute('data-object-id');
|
||||
var previousState = forgeQuizBoxState[quizObjectId];
|
||||
const quizObjectId = widget.getAttribute('data-object-id');
|
||||
const previousState = forgeQuizBoxState[quizObjectId];
|
||||
forgeQuizBoxState[quizObjectId] = {
|
||||
dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount, resolved: false,
|
||||
dialogueId: dialogueId, objectName: objectName, questionIndex: questionIndex, questionText: line.text,
|
||||
dialogueObjectId,
|
||||
correctIndex: line.correct_index,
|
||||
rewardAmount: line.reward_amount,
|
||||
resolved: false,
|
||||
dialogueId,
|
||||
objectName,
|
||||
questionIndex,
|
||||
questionText: line.text,
|
||||
// Minuteur "pour tout le quiz" (voir forgeStartQuizTimer plus bas) :
|
||||
// ne démarre qu'à la PREMIÈRE question, continue de tourner ensuite
|
||||
// sans se réinitialiser — ce drapeau survit d'une question à l'autre
|
||||
@@ -423,40 +437,38 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
||||
correctCount: previousState ? previousState.correctCount : 0,
|
||||
answeredCount: previousState ? previousState.answeredCount : 0,
|
||||
};
|
||||
var header = widget.querySelector('[data-quiz-role="header"]');
|
||||
var questionEl = widget.querySelector('[data-quiz-role="question"]');
|
||||
var choicesEl = widget.querySelector('[data-quiz-role="choices"]');
|
||||
const header = widget.querySelector('[data-quiz-role="header"]');
|
||||
const questionEl = widget.querySelector('[data-quiz-role="question"]');
|
||||
const choicesEl = widget.querySelector('[data-quiz-role="choices"]');
|
||||
if (header) header.textContent = objectName || '';
|
||||
if (questionEl) questionEl.textContent = line.text;
|
||||
if (choicesEl) {
|
||||
choicesEl.innerHTML = line.choices.map(function (choice, i) {
|
||||
return '<button type="button" class="quizBoxChoiceBtn" onclick="forgeQuizBoxAnswer(' + quizObjectId + ', ' + i + ')">' + forgeEscapeHtml(choice) + '</button>';
|
||||
}).join('');
|
||||
choicesEl.innerHTML = line.choices.map((choice, i) => `<button type="button" class="quizBoxChoiceBtn" onclick="forgeQuizBoxAnswer(${quizObjectId}, ${i})">${forgeEscapeHtml(choice)}</button>`).join('');
|
||||
// "Continuer →" TOUJOURS présent dès la question (demande explicite :
|
||||
// "le bouton continuer est toujours visible mais activé seulement
|
||||
// quand un choix est fait", comme la maquette manga validée) —
|
||||
// désactivé jusqu'à ce que forgeQuizBoxAnswer le réactive (jamais
|
||||
// recréé à ce moment-là, juste .disabled=false, voir plus bas).
|
||||
choicesEl.innerHTML += '<button type="button" class="quizBoxContinueBtn" onclick="forgeQuizBoxContinue(' + quizObjectId + ')" disabled>Continuer →</button>';
|
||||
choicesEl.innerHTML += `<button type="button" class="quizBoxContinueBtn" onclick="forgeQuizBoxContinue(${quizObjectId})" disabled>Continuer →</button>`;
|
||||
}
|
||||
// "Plein écran" (demande explicite) — reposé à CHAQUE question,
|
||||
// sans effet si déjà présent (voir .quizBoxWidget.is-fullscreen,
|
||||
// static/style.css). Échappe d'abord au conteneur de scène transformé
|
||||
// (voir forgeEscapeCameraForFullscreen) — sinon position:fixed reste
|
||||
// borné à sa taille "caméra" au lieu du vrai écran du joueur.
|
||||
var isFullscreen = widget.getAttribute('data-fullscreen') === '1';
|
||||
const isFullscreen = widget.getAttribute('data-fullscreen') === '1';
|
||||
if (isFullscreen) forgeEscapeCameraForFullscreen(widget);
|
||||
widget.classList.toggle('is-fullscreen', isFullscreen);
|
||||
forgeUpdateQuizBoxScoreDisplays();
|
||||
// Minuteur (optionnel — "aucun" reste le réglage par défaut, jamais
|
||||
// imposé, voir screens/rendering/quiz_box_config.py).
|
||||
var timerMode = widget.getAttribute('data-timer-mode');
|
||||
var timerSeconds = parseInt(widget.getAttribute('data-timer-seconds'), 10) || 0;
|
||||
const timerMode = widget.getAttribute('data-timer-mode');
|
||||
const timerSeconds = parseInt(widget.getAttribute('data-timer-seconds'), 10) || 0;
|
||||
if (timerMode === 'question') {
|
||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, function () { forgeQuizBoxTimeout(quizObjectId, false); });
|
||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { forgeQuizBoxTimeout(quizObjectId, false); });
|
||||
} else if (timerMode === 'quiz' && !forgeQuizBoxState[quizObjectId].quizTimerStarted) {
|
||||
forgeQuizBoxState[quizObjectId].quizTimerStarted = true;
|
||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, function () { forgeQuizBoxTimeout(quizObjectId, true); });
|
||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, () => { forgeQuizBoxTimeout(quizObjectId, true); });
|
||||
} else if (timerMode !== 'quiz') {
|
||||
forgeStopQuizTimer(quizObjectId);
|
||||
}
|
||||
@@ -469,7 +481,7 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
||||
widget.classList.add('is-active');
|
||||
// Effet d'entrée propre au modèle (voir static/js/play/
|
||||
// quiz-box-templates.js) — optionnel, "defaut" n'en a aucun.
|
||||
var showFx = forgeQuizTemplateEffectsFor(widget).onShow;
|
||||
const showFx = forgeQuizTemplateEffectsFor(widget).onShow;
|
||||
if (showFx) showFx(widget);
|
||||
}
|
||||
|
||||
@@ -477,7 +489,7 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
||||
// templates.js, chargé AVANT ce fichier) — absent en environnement de
|
||||
// test qui ne charge pas ce script, d'où la garde `typeof`.
|
||||
function forgeQuizTemplateEffectsFor(widget) {
|
||||
var template = (widget && widget.getAttribute('data-quiz-template')) || 'defaut';
|
||||
const template = (widget && widget.getAttribute('data-quiz-template')) || 'defaut';
|
||||
return (typeof forgeQuizTemplateEffects !== 'undefined' && forgeQuizTemplateEffects[template]) || {};
|
||||
}
|
||||
|
||||
@@ -491,11 +503,11 @@ function forgeQuizTemplateEffectsFor(widget) {
|
||||
// en rouge s'il était faux) le temps de la voir avant d'avancer. Un
|
||||
// second clic pendant cet affichage est ignoré (`resolved`).
|
||||
function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
var state = forgeQuizBoxState[quizObjectId];
|
||||
const state = forgeQuizBoxState[quizObjectId];
|
||||
if (!state || state.resolved) return;
|
||||
state.resolved = true;
|
||||
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
var correct = choiceIndex === state.correctIndex;
|
||||
const widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
const correct = choiceIndex === state.correctIndex;
|
||||
// Minuteur "par question" : cette question est résolue (bonne réponse,
|
||||
// mauvaise réponse, OU minuteur expiré via forgeQuizBoxTimeout, qui
|
||||
// appelle CETTE fonction avec choiceIndex=-1) — jamais le minuteur
|
||||
@@ -503,8 +515,8 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
// d'une question à l'autre.
|
||||
if (widget && widget.getAttribute('data-timer-mode') === 'question') forgeStopQuizTimer(quizObjectId);
|
||||
if (widget) {
|
||||
var buttons = widget.querySelectorAll('.quizBoxChoiceBtn');
|
||||
for (var i = 0; i < buttons.length; i++) {
|
||||
const buttons = widget.querySelectorAll('.quizBoxChoiceBtn');
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
buttons[i].disabled = true;
|
||||
if (i === state.correctIndex) buttons[i].classList.add('is-correct');
|
||||
else if (i === choiceIndex) buttons[i].classList.add('is-wrong');
|
||||
@@ -512,7 +524,7 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
}
|
||||
window.forgeQuizTotalAnswered += 1;
|
||||
state.answeredCount += 1;
|
||||
var fx = forgeQuizTemplateEffectsFor(widget);
|
||||
const fx = forgeQuizTemplateEffectsFor(widget);
|
||||
if (correct) {
|
||||
window.forgeQuizCorrectAnswered += 1;
|
||||
state.correctCount += 1;
|
||||
@@ -521,13 +533,28 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
} else if (fx.onWrong) {
|
||||
fx.onWrong(widget);
|
||||
}
|
||||
if (typeof forgeXapiNotifyQuestionAnswered === "function") {
|
||||
forgeXapiNotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0);
|
||||
if (typeof forgeXapiNotifyQuestionAnswered === 'function') {
|
||||
forgeXapiNotifyQuestionAnswered(
|
||||
state.dialogueId,
|
||||
state.objectName,
|
||||
state.questionIndex,
|
||||
state.questionText,
|
||||
correct,
|
||||
correct ? state.rewardAmount : 0,
|
||||
);
|
||||
}
|
||||
// cmi.interactions.n.* (SCORM 2004 uniquement, voir scorm2004-api.js) :
|
||||
// no-op en SCORM 1.2/hors LMS (forgeScorm2004Api y reste null).
|
||||
if (typeof forgeScorm2004NotifyQuestionAnswered === "function") {
|
||||
forgeScorm2004NotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, choiceIndex, state.correctIndex);
|
||||
if (typeof forgeScorm2004NotifyQuestionAnswered === 'function') {
|
||||
forgeScorm2004NotifyQuestionAnswered(
|
||||
state.dialogueId,
|
||||
state.objectName,
|
||||
state.questionIndex,
|
||||
state.questionText,
|
||||
correct,
|
||||
choiceIndex,
|
||||
state.correctIndex,
|
||||
);
|
||||
}
|
||||
// RGAA/WCAG 2.2.1 (délai réglable) : avancait auparavant tout seul
|
||||
// après un délai fixe (700ms/1400ms), jamais assez pour un lecteur
|
||||
@@ -539,7 +566,7 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
// effacerait le surlignage vert/rouge qu'on vient de poser sur les
|
||||
// choix juste au-dessus).
|
||||
if (widget) {
|
||||
var continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn');
|
||||
const continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn');
|
||||
if (continueBtnToEnable) continueBtnToEnable.disabled = false;
|
||||
}
|
||||
}
|
||||
@@ -558,18 +585,18 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
// les lignes.
|
||||
function forgeQuizPrepareResultScreen(quizObjectId, widget, state, forceEnd) {
|
||||
if (!widget) return null;
|
||||
var dialogueState = forgeDialogueBoxState[state.dialogueObjectId];
|
||||
const dialogueState = forgeDialogueBoxState[state.dialogueObjectId];
|
||||
if (!dialogueState) return null;
|
||||
var isEnding = forceEnd || (dialogueState.index + 1 >= dialogueState.lines.length);
|
||||
const isEnding = forceEnd || (dialogueState.index + 1 >= dialogueState.lines.length);
|
||||
if (!isEnding) return null;
|
||||
var completeFx = forgeQuizTemplateEffectsFor(widget).onQuizComplete;
|
||||
const completeFx = forgeQuizTemplateEffectsFor(widget).onQuizComplete;
|
||||
if (!completeFx) return null;
|
||||
var correctCount = state.correctCount;
|
||||
var answeredCount = state.answeredCount;
|
||||
const { correctCount } = state;
|
||||
const { answeredCount } = state;
|
||||
return function () {
|
||||
widget.classList.add('is-active');
|
||||
if (widget.getAttribute('data-fullscreen') === '1') widget.classList.add('is-fullscreen');
|
||||
completeFx(widget, { correctCount: correctCount, totalCount: answeredCount });
|
||||
completeFx(widget, { correctCount, totalCount: answeredCount });
|
||||
};
|
||||
}
|
||||
|
||||
@@ -593,10 +620,10 @@ function forgeQuizPrepareResultScreen(quizObjectId, widget, state, forceEnd) {
|
||||
// ci-dessus (widget déjà masqué) est donc le résultat visible le plus
|
||||
// courant en pratique.
|
||||
function forgeQuizResultQuit(quizObjectId) {
|
||||
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
const widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
if (widget) {
|
||||
widget.classList.remove('is-active', 'is-fullscreen', 'is-showing-result');
|
||||
var content = widget.querySelector('.classiqueContent');
|
||||
const content = widget.querySelector('.classiqueContent');
|
||||
if (content) content.classList.remove('is-showing-result');
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.close === 'function') window.close();
|
||||
@@ -608,10 +635,10 @@ function forgeQuizResultQuit(quizObjectId) {
|
||||
// résultat, voir forgeQuizPrepareResultScreen ci-dessus), jamais
|
||||
// automatique (voir le commentaire ci-dessus).
|
||||
function forgeQuizBoxContinue(quizObjectId) {
|
||||
var state = forgeQuizBoxState[quizObjectId];
|
||||
const state = forgeQuizBoxState[quizObjectId];
|
||||
if (!state) return;
|
||||
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
var showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, state, false);
|
||||
const widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
const showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, state, false);
|
||||
if (widget && !showResultScreen) widget.classList.remove('is-active');
|
||||
delete forgeQuizBoxState[quizObjectId];
|
||||
forgeAdvanceConversation(state.dialogueObjectId);
|
||||
@@ -629,31 +656,31 @@ function forgeQuizBoxContinue(quizObjectId) {
|
||||
// choiceIndex=-1 (ne correspond à aucun bouton : révèle la bonne réponse
|
||||
// sans en marquer une "fausse"), jamais de code dupliqué.
|
||||
function forgeQuizBoxTimeout(quizObjectId, wholeQuizExpired) {
|
||||
var state = forgeQuizBoxState[quizObjectId];
|
||||
const state = forgeQuizBoxState[quizObjectId];
|
||||
if (!state || state.resolved) return;
|
||||
forgeQuizBoxAnswer(quizObjectId, -1);
|
||||
if (!wholeQuizExpired) return;
|
||||
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
const widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
// Pas de "Continuer →" ici : rien à continuer, le temps du quiz ENTIER
|
||||
// est écoulé — désactivé/relibellé pour éviter toute course avec la
|
||||
// fin automatique programmée juste en dessous.
|
||||
if (widget) {
|
||||
var continueBtn = widget.querySelector('.quizBoxContinueBtn');
|
||||
const continueBtn = widget.querySelector('.quizBoxContinueBtn');
|
||||
if (continueBtn) { continueBtn.disabled = true; continueBtn.textContent = 'Temps écoulé…'; }
|
||||
}
|
||||
setTimeout(function () {
|
||||
var s = forgeQuizBoxState[quizObjectId];
|
||||
setTimeout(() => {
|
||||
const s = forgeQuizBoxState[quizObjectId];
|
||||
if (!s) return; // déjà fermé entre-temps
|
||||
// forceEnd=true : le quiz ENTIER se termine ici, qu'il reste ou non
|
||||
// des questions (voir forgeQuizPrepareResultScreen) — capturé AVANT
|
||||
// que forgeAdvanceConversation ne supprime forgeDialogueBoxState.
|
||||
var showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, s, true);
|
||||
const showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, s, true);
|
||||
if (widget && !showResultScreen) widget.classList.remove('is-active', 'is-fullscreen');
|
||||
delete forgeQuizBoxState[quizObjectId];
|
||||
// Force l'épuisement des lignes restantes — même sortie que la fin
|
||||
// naturelle d'un dialogue (markCompleted/thenAction inclus, voir
|
||||
// forgeAdvanceConversation), sans dupliquer cette logique.
|
||||
var dialogueState = forgeDialogueBoxState[s.dialogueObjectId];
|
||||
const dialogueState = forgeDialogueBoxState[s.dialogueObjectId];
|
||||
if (dialogueState) {
|
||||
dialogueState.index = dialogueState.lines.length;
|
||||
forgeAdvanceConversation(s.dialogueObjectId);
|
||||
@@ -666,17 +693,34 @@ function forgeQuizBoxTimeout(quizObjectId, wholeQuizExpired) {
|
||||
// contrôleurs de ce dossier.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
|
||||
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
|
||||
forgeShowDialogueBox,
|
||||
forgeDialogueBoxAdvance,
|
||||
forgeCompleteDialogue,
|
||||
forgeDialogueBoxWidgetOnCurrentScreen,
|
||||
forgeDialogueBoxWidgetById,
|
||||
forgeRenderDialogueBoxLine,
|
||||
forgeResetDialogueBoxFooter,
|
||||
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxTimeout,
|
||||
forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById, forgeHideQuizBoxOnCurrentScreen,
|
||||
forgeUpdateAllScoreWidgets, forgeUpdateQuizBoxScoreDisplays, forgeAddQuizScore,
|
||||
forgeAdvanceConversation, forgeShowCurrentConversationLine,
|
||||
forgeSyncQuizScoreToScorm, forgeSyncDialogueStartedToScorm, forgeSyncAllDialoguesCompletionToScorm,
|
||||
forgeShowQuizBox,
|
||||
forgeQuizBoxAnswer,
|
||||
forgeQuizBoxContinue,
|
||||
forgeQuizBoxTimeout,
|
||||
forgeQuizBoxWidgetOnCurrentScreen,
|
||||
forgeQuizBoxWidgetById,
|
||||
forgeHideQuizBoxOnCurrentScreen,
|
||||
forgeUpdateAllScoreWidgets,
|
||||
forgeUpdateQuizBoxScoreDisplays,
|
||||
forgeAddQuizScore,
|
||||
forgeAdvanceConversation,
|
||||
forgeShowCurrentConversationLine,
|
||||
forgeSyncQuizScoreToScorm,
|
||||
forgeSyncDialogueStartedToScorm,
|
||||
forgeSyncAllDialoguesCompletionToScorm,
|
||||
forgeComputeFinalScormStatus,
|
||||
forgeStartQuizTimer, forgeStopQuizTimer, forgeQuizTimerState,
|
||||
forgeStartQuizTimer,
|
||||
forgeStopQuizTimer,
|
||||
forgeQuizTimerState,
|
||||
forgeEscapeCameraForFullscreen,
|
||||
forgeQuizPrepareResultScreen, forgeQuizResultQuit,
|
||||
forgeQuizPrepareResultScreen,
|
||||
forgeQuizResultQuit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,23 +23,23 @@ function runFlowFrom(nodeId, flowScreenId) {
|
||||
const flow = gameData.flows[String(flowScreenId != null ? flowScreenId : window.currentScreenId)];
|
||||
if (!flow) return;
|
||||
const nodesById = {};
|
||||
flow.nodes.forEach(n => { nodesById[n.id] = n; });
|
||||
flow.nodes.forEach((n) => { nodesById[n.id] = n; });
|
||||
|
||||
function nextEdges(fromId, port) {
|
||||
return flow.edges.filter(e => e.from_node_id === fromId && e.from_port === port);
|
||||
return flow.edges.filter((e) => e.from_node_id === fromId && e.from_port === port);
|
||||
}
|
||||
|
||||
function step(currentId) {
|
||||
const node = nodesById[currentId];
|
||||
if (!node) return;
|
||||
if (node.node_type === 'trigger') {
|
||||
nextEdges(node.id, 'out').forEach(edge => step(edge.to_node_id));
|
||||
nextEdges(node.id, 'out').forEach((edge) => step(edge.to_node_id));
|
||||
} else if (node.node_type === 'condition') {
|
||||
const port = evaluateConditionNode(node) ? 'vrai' : 'faux';
|
||||
nextEdges(node.id, port).forEach(edge => step(edge.to_node_id));
|
||||
nextEdges(node.id, port).forEach((edge) => step(edge.to_node_id));
|
||||
} else if (node.node_type === 'action') {
|
||||
runActionNode(node).then(() => {
|
||||
nextEdges(node.id, 'out').forEach(edge => step(edge.to_node_id));
|
||||
nextEdges(node.id, 'out').forEach((edge) => step(edge.to_node_id));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -55,11 +55,11 @@ function runFlowFrom(nodeId, flowScreenId) {
|
||||
// C'est à CHAQUE écouteur de décider quoi faire ensuite (via sa propre
|
||||
// suite condition/action, avec ses réglages habituels — cible fixe,
|
||||
// "Ligne cliquée"...), jamais à l'événement lui-même de le dicter.
|
||||
window.dispatchGameEvent = function(eventId) {
|
||||
window.dispatchGameEvent = function (eventId) {
|
||||
for (const screenIdKey in gameData.flows) {
|
||||
const flow = gameData.flows[screenIdKey];
|
||||
flow.nodes
|
||||
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'evenement' && n.trigger_custom_event_id === eventId; })
|
||||
.forEach(function(n) { runFlowFrom(n.id, screenIdKey); });
|
||||
.filter((n) => n.node_type === 'trigger' && n.trigger_event === 'evenement' && n.trigger_custom_event_id === eventId)
|
||||
.forEach((n) => { runFlowFrom(n.id, screenIdKey); });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8,33 +8,49 @@ require('../../conditions.js');
|
||||
global.compareValues = require('../../conditions.js').compareValues;
|
||||
|
||||
const filterMod = require('../filter-repeater-rows.js');
|
||||
|
||||
Object.assign(global, filterMod);
|
||||
|
||||
const visibilityMod = require('../visibility-condition.js');
|
||||
|
||||
Object.assign(global, visibilityMod);
|
||||
|
||||
const computeMod = require('../compute-operation.js');
|
||||
|
||||
Object.assign(global, computeMod);
|
||||
|
||||
const applyMod = require('../apply-actions.js');
|
||||
|
||||
Object.assign(global, applyMod);
|
||||
|
||||
const renderElementMod = require('../render-element.js');
|
||||
|
||||
Object.assign(global, renderElementMod);
|
||||
|
||||
const jaugeMod = require('../render-jauge.js');
|
||||
|
||||
Object.assign(global, jaugeMod);
|
||||
|
||||
const overlayMod = require('../render-overlay.js');
|
||||
|
||||
Object.assign(global, overlayMod);
|
||||
|
||||
const repeaterMod = require('../render-repeater.js');
|
||||
|
||||
Object.assign(global, repeaterMod);
|
||||
|
||||
const specialMod = require('../render-special-widgets.js');
|
||||
|
||||
Object.assign(global, specialMod);
|
||||
|
||||
module.exports = Object.assign(
|
||||
{}, filterMod, visibilityMod, computeMod, applyMod, renderElementMod,
|
||||
jaugeMod, overlayMod, repeaterMod, specialMod
|
||||
);
|
||||
module.exports = {
|
||||
...filterMod,
|
||||
...visibilityMod,
|
||||
...computeMod,
|
||||
...applyMod,
|
||||
...renderElementMod,
|
||||
...jaugeMod,
|
||||
...overlayMod,
|
||||
...repeaterMod,
|
||||
...specialMod,
|
||||
};
|
||||
|
||||
@@ -30,12 +30,14 @@ function withOfflineFetchGuard(fn) {
|
||||
}
|
||||
}
|
||||
|
||||
test("modifier_variable — mute gameData en mémoire sans fetch()", () => {
|
||||
test('modifier_variable — mute gameData en mémoire sans fetch()', () => {
|
||||
withOfflineFetchGuard((getRefreshCalls) => {
|
||||
global.gameData = { variables: { score_var: { value: '10', type: 'nombre_entier' } } };
|
||||
const node = {
|
||||
action_type: 'modifier_variable',
|
||||
target_variable: 'score_var', data_operation: 'incrementer', data_value: '5',
|
||||
target_variable: 'score_var',
|
||||
data_operation: 'incrementer',
|
||||
data_value: '5',
|
||||
};
|
||||
runActionNode(node);
|
||||
assert.equal(global.gameData.variables.score_var.value, '15');
|
||||
@@ -43,7 +45,7 @@ test("modifier_variable — mute gameData en mémoire sans fetch()", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("modifier_score — mute gameData.scoring sans fetch()", () => {
|
||||
test('modifier_score — mute gameData.scoring sans fetch()', () => {
|
||||
withOfflineFetchGuard((getRefreshCalls) => {
|
||||
global.gameData = { scoring: { score: 0, status: 'non_commence' } };
|
||||
const node = { action_type: 'modifier_score', data_operation: 'incrementer', data_value: '3' };
|
||||
@@ -53,7 +55,7 @@ test("modifier_score — mute gameData.scoring sans fetch()", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("definir_statut_partie — mute gameData.scoring.status sans fetch()", () => {
|
||||
test('definir_statut_partie — mute gameData.scoring.status sans fetch()', () => {
|
||||
withOfflineFetchGuard((getRefreshCalls) => {
|
||||
global.gameData = { scoring: { score: 0, status: 'non_commence' } };
|
||||
const node = { action_type: 'definir_statut_partie', data_value: 'reussi' };
|
||||
@@ -63,9 +65,9 @@ test("definir_statut_partie — mute gameData.scoring.status sans fetch()", () =
|
||||
});
|
||||
});
|
||||
|
||||
test("ajouter_ligne — crée une ligne en mémoire et fixe window.lastInsertedRowId sans fetch()", () => {
|
||||
test('ajouter_ligne — crée une ligne en mémoire et fixe window.lastInsertedRowId sans fetch()', () => {
|
||||
withOfflineFetchGuard((getRefreshCalls) => {
|
||||
global.gameData = { data: { '1': [{ id: 5 }] }, definition_names: {}, fields_meta: { '1': [] } };
|
||||
global.gameData = { data: { 1: [{ id: 5 }] }, definition_names: {}, fields_meta: { 1: [] } };
|
||||
const node = { action_type: 'ajouter_ligne', target_definition_id: 1 };
|
||||
runActionNode(node);
|
||||
assert.equal(global.window.lastInsertedRowId, 6);
|
||||
|
||||
@@ -18,12 +18,16 @@ function baseGameData() {
|
||||
},
|
||||
scoring: { score: 0, status: 'non_commence' },
|
||||
data: {
|
||||
'1': [{ id: 1, points: 5, nom: 'Alice' }, { id: 2, points: 8, nom: 'Bob' }],
|
||||
1: [{ id: 1, points: 5, nom: 'Alice' }, { id: 2, points: 8, nom: 'Bob' }],
|
||||
},
|
||||
fields_meta: {
|
||||
'1': [
|
||||
{ name: 'points', type: 'nombre_entier', min_value: 0, max_value: 10 },
|
||||
{ name: 'nom', type: 'texte', min_value: null, max_value: null },
|
||||
1: [
|
||||
{
|
||||
name: 'points', type: 'nombre_entier', min_value: 0, max_value: 10,
|
||||
},
|
||||
{
|
||||
name: 'nom', type: 'texte', min_value: null, max_value: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -65,21 +69,30 @@ test('definir_statut_partie — rejette un statut inconnu', () => {
|
||||
test('modifier_donnee — bornage min/max comme apply_data_action.py', () => {
|
||||
const gd = baseGameData();
|
||||
forgeApplyDataActionOffline(gd, {
|
||||
target_definition_id: 1, target_row_id: 1, target_field: 'points',
|
||||
data_operation: 'incrementer', data_value: '100',
|
||||
target_definition_id: 1,
|
||||
target_row_id: 1,
|
||||
target_field: 'points',
|
||||
data_operation: 'incrementer',
|
||||
data_value: '100',
|
||||
});
|
||||
assert.equal(gd.data['1'][0].points, 10); // borné à max_value=10, pas 105
|
||||
});
|
||||
|
||||
test('modifier_donnee — ligne/champ/objet inconnu échoue proprement', () => {
|
||||
const gd = baseGameData();
|
||||
assert.equal(forgeApplyDataActionOffline(gd, { target_definition_id: 1, target_row_id: 999, target_field: 'points', data_operation: 'incrementer', data_value: '1' }), false);
|
||||
assert.equal(forgeApplyDataActionOffline(gd, { target_definition_id: 1, target_row_id: 1, target_field: 'inconnu', data_operation: 'incrementer', data_value: '1' }), false);
|
||||
assert.equal(forgeApplyDataActionOffline(gd, {
|
||||
target_definition_id: 1, target_row_id: 999, target_field: 'points', data_operation: 'incrementer', data_value: '1',
|
||||
}), false);
|
||||
assert.equal(forgeApplyDataActionOffline(gd, {
|
||||
target_definition_id: 1, target_row_id: 1, target_field: 'inconnu', data_operation: 'incrementer', data_value: '1',
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('ajouter_ligne — ligne vide, id auto-incrémenté, booléen à 0', () => {
|
||||
const gd = baseGameData();
|
||||
gd.fields_meta['1'].push({ name: 'actif', type: 'booleen', min_value: null, max_value: null });
|
||||
gd.fields_meta['1'].push({
|
||||
name: 'actif', type: 'booleen', min_value: null, max_value: null,
|
||||
});
|
||||
const newId = forgeApplyAddRowActionOffline(gd, { target_definition_id: 1 });
|
||||
assert.equal(newId, 3); // max existant (2) + 1
|
||||
const row = gd.data['1'][2];
|
||||
|
||||
@@ -15,18 +15,18 @@ function baseGameData() {
|
||||
niveau: { value: '3', type: 'nombre_entier' },
|
||||
perso: { value: JSON.stringify({ nom: 'Zelda', armes: ['épée', 'arc'] }), type: 'objet' },
|
||||
},
|
||||
definition_names: { '1': 'Progression', '2': 'Ennemis' },
|
||||
definition_names: { 1: 'Progression', 2: 'Ennemis' },
|
||||
fields_meta: {
|
||||
// "boss" (relation) : gameData.data (voir full_game_payload.py) est
|
||||
// déjà indexé par le nom D'AFFICHAGE du champ ("boss"), JAMAIS par sa
|
||||
// colonne SQL réelle ("boss_id") — contrairement à côté serveur
|
||||
// (filter_repeater_rows.py::_field_column), qui lit la base fraîche.
|
||||
'1': [{ name: 'niveau_actuel', type: 'nombre_entier' }, { name: 'boss', type: 'relation' }],
|
||||
'2': [{ name: 'nom', type: 'texte' }, { name: 'pv', type: 'nombre_entier' }],
|
||||
1: [{ name: 'niveau_actuel', type: 'nombre_entier' }, { name: 'boss', type: 'relation' }],
|
||||
2: [{ name: 'nom', type: 'texte' }, { name: 'pv', type: 'nombre_entier' }],
|
||||
},
|
||||
data: {
|
||||
'1': [{ id: 1, niveau_actuel: 4, boss: 7 }],
|
||||
'2': [
|
||||
1: [{ id: 1, niveau_actuel: 4, boss: 7 }],
|
||||
2: [
|
||||
{ id: 1, nom: 'Slime', pv: 10 },
|
||||
{ id: 2, nom: 'Dragon', pv: 100 },
|
||||
],
|
||||
@@ -85,20 +85,20 @@ test('forgeFilterRowsByClauses — ET (toutes vraies) vs OU (au moins une)', ()
|
||||
{ champ: 'nom', operateur: 'egal', valeur: 'Dragon' },
|
||||
{ champ: 'pv', operateur: 'superieur', valeur: '50' },
|
||||
];
|
||||
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, clauses, 'et').map(r => r.id), [2]);
|
||||
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, clauses, 'et').map((r) => r.id), [2]);
|
||||
|
||||
const orClauses = [
|
||||
{ champ: 'nom', operateur: 'egal', valeur: 'Slime' },
|
||||
{ champ: 'pv', operateur: 'superieur', valeur: '50' },
|
||||
];
|
||||
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, orClauses, 'ou').map(r => r.id), [1, 2]);
|
||||
assert.deepEqual(forgeFilterRowsByClauses(gd, '2', rows, orClauses, 'ou').map((r) => r.id), [1, 2]);
|
||||
});
|
||||
|
||||
test('forgeFilterRepeaterRows — ancien format 2 filtres fixes (repli)', () => {
|
||||
const gd = baseGameData();
|
||||
const attrs = { _filtre_champ: 'nom', _filtre_operateur: 'egal', _filtre_valeur: 'Slime' };
|
||||
const result = forgeFilterRepeaterRows(gd, '2', gd.data['2'], attrs);
|
||||
assert.deepEqual(result.map(r => r.id), [1]);
|
||||
assert.deepEqual(result.map((r) => r.id), [1]);
|
||||
});
|
||||
|
||||
test('forgeDecodeClauses — format JSON illimité prioritaire sur le legacy', () => {
|
||||
|
||||
@@ -17,16 +17,26 @@ function baseGameData() {
|
||||
element_type_templates: {},
|
||||
screens: [
|
||||
{
|
||||
id: 1, kind: 'document', preview_aspect: '9/16',
|
||||
id: 1,
|
||||
kind: 'document',
|
||||
preview_aspect: '9/16',
|
||||
elements: [
|
||||
{
|
||||
id: 10, widget: 'texte', content: 'Score : x', attributes: {}, style: {}, parent_id: null,
|
||||
id: 10,
|
||||
widget: 'texte',
|
||||
content: 'Score : x',
|
||||
attributes: {},
|
||||
style: {},
|
||||
parent_id: null,
|
||||
rendered_html: '<p>ancien rendu</p>',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2, kind: 'jeu_2d', scene_width: 960, scene_height: 540,
|
||||
id: 2,
|
||||
kind: 'jeu_2d',
|
||||
scene_width: 960,
|
||||
scene_height: 540,
|
||||
elements: [{ id: 20, kind: 'decor', rendered_html: '<img data-object-id="20">' }],
|
||||
},
|
||||
],
|
||||
@@ -40,7 +50,7 @@ test('recalcule rendered_html pour un écran document', () => {
|
||||
assert.ok(gd.screens[0].elements[0].rendered_html.startsWith('<p '));
|
||||
});
|
||||
|
||||
test("ne touche jamais un écran jeu_2d (les objets de scène se mutent déjà dans le DOM)", () => {
|
||||
test('ne touche jamais un écran jeu_2d (les objets de scène se mutent déjà dans le DOM)', () => {
|
||||
const gd = baseGameData();
|
||||
forgeRecomputeFullPayloadOffline(gd);
|
||||
assert.equal(gd.screens[1].elements[0].rendered_html, '<img data-object-id="20">');
|
||||
@@ -53,7 +63,7 @@ test('un élément imbriqué reçoit aussi son PROPRE rendered_html (pas seuleme
|
||||
});
|
||||
gd.screens[0].elements[0].widget = 'conteneur'; // devient le parent
|
||||
forgeRecomputeFullPayloadOffline(gd);
|
||||
const child = gd.screens[0].elements.find(e => e.id === 11);
|
||||
const child = gd.screens[0].elements.find((e) => e.id === 11);
|
||||
assert.ok(child.rendered_html.includes('enfant'));
|
||||
});
|
||||
|
||||
@@ -62,8 +72,10 @@ test('reflète une mutation déjà appliquée (score/variable) via la Donnée li
|
||||
gd.variables.niveau = { value: '5', type: 'nombre_entier' };
|
||||
gd.screens[0].elements[0].content = 'Visible';
|
||||
gd.screens[0].elements[0].attributes = {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur_egal', _visibilite_cond_valeur: '5',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur_egal',
|
||||
_visibilite_cond_valeur: '5',
|
||||
};
|
||||
forgeRecomputeFullPayloadOffline(gd);
|
||||
assert.ok(gd.screens[0].elements[0].rendered_html.includes('Visible'));
|
||||
|
||||
@@ -9,11 +9,15 @@ const assert = require('node:assert/strict');
|
||||
const { forgeRenderElementHtml, forgeStyleString, forgeAttrString } = require('../render-element.js');
|
||||
|
||||
function baseGameData() {
|
||||
return { variables: {}, definition_names: {}, fields_meta: {}, data: {} };
|
||||
return {
|
||||
variables: {}, definition_names: {}, fields_meta: {}, data: {},
|
||||
};
|
||||
}
|
||||
|
||||
test('texte simple — échappement HTML, saut de ligne -> <br>', () => {
|
||||
const el = { id: 1, widget: 'texte', content: 'a < b\nligne 2', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 1, widget: 'texte', content: 'a < b\nligne 2', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.startsWith('<p '));
|
||||
assert.ok(html.includes('a < b<br>ligne 2'));
|
||||
@@ -21,14 +25,18 @@ test('texte simple — échappement HTML, saut de ligne -> <br>', () => {
|
||||
});
|
||||
|
||||
test('titre — balise dynamique h1/h2/h3 selon _niveau', () => {
|
||||
const el = { id: 2, widget: 'titre', content: 'Bienvenue', attributes: { _niveau: '1' }, style: {} };
|
||||
const el = {
|
||||
id: 2, widget: 'titre', content: 'Bienvenue', attributes: { _niveau: '1' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.startsWith('<h1 '));
|
||||
assert.ok(html.includes('class="title"'));
|
||||
});
|
||||
|
||||
test('bouton — fixed_attrs class="button" fusionnée avec les attributs', () => {
|
||||
const el = { id: 3, widget: 'bouton', content: 'Valider', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 3, widget: 'bouton', content: 'Valider', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.startsWith('<button '));
|
||||
assert.ok(html.includes('class="button"'));
|
||||
@@ -36,7 +44,9 @@ test('bouton — fixed_attrs class="button" fusionnée avec les attributs', () =
|
||||
});
|
||||
|
||||
test('image — self-closing, pas de contenu ni enfants', () => {
|
||||
const el = { id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {} };
|
||||
const el = {
|
||||
id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.equal(html.indexOf('</img>'), -1);
|
||||
assert.ok(html.startsWith('<img '));
|
||||
@@ -44,26 +54,36 @@ test('image — self-closing, pas de contenu ni enfants', () => {
|
||||
});
|
||||
|
||||
test('image — alt="" par défaut (RGAA 1.1.1, décoratif faute de champ de description dédié)', () => {
|
||||
const el = { id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {} };
|
||||
const el = {
|
||||
id: 4, widget: 'image', attributes: { src: 'chat.png' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('alt=""'));
|
||||
});
|
||||
|
||||
test('image — un alt déjà posé par l\'auteur n\'est jamais écrasé', () => {
|
||||
const el = { id: 4, widget: 'image', attributes: { src: 'chat.png', alt: 'Un chat noir' }, style: {} };
|
||||
const el = {
|
||||
id: 4, widget: 'image', attributes: { src: 'chat.png', alt: 'Un chat noir' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('alt="Un chat noir"'));
|
||||
});
|
||||
|
||||
test('liste — une <li> par ligne de contenu, échappée', () => {
|
||||
const el = { id: 5, widget: 'liste', content: 'Pomme\nPoire < Kiwi\n\n', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 5, widget: 'liste', content: 'Pomme\nPoire < Kiwi\n\n', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('<li>Pomme</li><li>Poire < Kiwi</li>'));
|
||||
});
|
||||
|
||||
test('conteneur avec enfants — rendu récursif', () => {
|
||||
const parent = { id: 10, widget: 'conteneur', content: '', attributes: {}, style: {} };
|
||||
const child = { id: 11, widget: 'texte', content: 'enfant', attributes: {}, style: {}, parent_id: 10 };
|
||||
const parent = {
|
||||
id: 10, widget: 'conteneur', content: '', attributes: {}, style: {},
|
||||
};
|
||||
const child = {
|
||||
id: 11, widget: 'texte', content: 'enfant', attributes: {}, style: {}, parent_id: 10,
|
||||
};
|
||||
const html = forgeRenderElementHtml(parent, baseGameData(), { 10: [child] }, {});
|
||||
assert.ok(html.includes('>enfant<'));
|
||||
});
|
||||
@@ -72,10 +92,15 @@ test('condition de visibilité fausse en mode jouable -> placeholder masqué', (
|
||||
const gd = baseGameData();
|
||||
gd.variables.niveau = { value: '1', type: 'nombre_entier' };
|
||||
const el = {
|
||||
id: 6, widget: 'texte', content: 'secret', style: {},
|
||||
id: 6,
|
||||
widget: 'texte',
|
||||
content: 'secret',
|
||||
style: {},
|
||||
attributes: {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: '99',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'egal',
|
||||
_visibilite_cond_valeur: '99',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, gd, {}, { _forge_play_mode: true });
|
||||
@@ -88,10 +113,15 @@ test('condition de visibilité ignorée hors mode jouable (éditeur)', () => {
|
||||
const gd = baseGameData();
|
||||
gd.variables.niveau = { value: '1', type: 'nombre_entier' };
|
||||
const el = {
|
||||
id: 7, widget: 'texte', content: 'visible en édition', style: {},
|
||||
id: 7,
|
||||
widget: 'texte',
|
||||
content: 'visible en édition',
|
||||
style: {},
|
||||
attributes: {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: '99',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'egal',
|
||||
_visibilite_cond_valeur: '99',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, gd, {}, {}); // pas de _forge_play_mode
|
||||
@@ -100,7 +130,9 @@ test('condition de visibilité ignorée hors mode jouable (éditeur)', () => {
|
||||
});
|
||||
|
||||
test('{{champ}} substitué dans le contenu (contexte de Répéteur)', () => {
|
||||
const el = { id: 8, widget: 'texte', content: 'Bonjour {{nom}}', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 8, widget: 'texte', content: 'Bonjour {{nom}}', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, { nom: 'Alice' });
|
||||
assert.ok(html.includes('Bonjour Alice'));
|
||||
});
|
||||
@@ -113,7 +145,9 @@ test('forgeStyleString — largeur par défaut selon axe flex du parent (enfant)
|
||||
});
|
||||
|
||||
test('forgeAttrString — booléen True = attribut sans valeur, False/null omis', () => {
|
||||
const s = forgeAttrString({ required: true, disabled: false, placeholder: null, name: 'x' }, 'color:red;');
|
||||
const s = forgeAttrString({
|
||||
required: true, disabled: false, placeholder: null, name: 'x',
|
||||
}, 'color:red;');
|
||||
assert.ok(s.includes('style="color:red;"'));
|
||||
assert.ok(s.includes('required'));
|
||||
assert.ok(!s.includes('disabled'));
|
||||
|
||||
@@ -8,35 +8,43 @@ const { forgeRenderElementHtml } = require('./_load_offline_runtime.js');
|
||||
function baseGameData() {
|
||||
return {
|
||||
variables: {},
|
||||
definition_names: { '1': 'Scores' },
|
||||
fields_meta: { '1': [{ name: 'points', type: 'nombre_entier' }, { name: 'nom', type: 'texte' }] },
|
||||
data: { '1': [{ id: 1, points: 42, nom: 'Alice' }, { id: 2, points: 7, nom: 'Bob' }] },
|
||||
personnage_animations: { '5': { idle: ['/static/characters/zombie/idle0.png', '/static/characters/zombie/idle1.png'] } },
|
||||
definition_names: { 1: 'Scores' },
|
||||
fields_meta: { 1: [{ name: 'points', type: 'nombre_entier' }, { name: 'nom', type: 'texte' }] },
|
||||
data: { 1: [{ id: 1, points: 42, nom: 'Alice' }, { id: 2, points: 7, nom: 'Bob' }] },
|
||||
personnage_animations: { 5: { idle: ['/static/characters/zombie/idle0.png', '/static/characters/zombie/idle1.png'] } },
|
||||
element_type_templates: {},
|
||||
};
|
||||
}
|
||||
|
||||
test('case à cocher — <label class="checkbox"><input type="checkbox">', () => {
|
||||
const el = { id: 1, widget: 'case_a_cocher', content: "J'accepte", attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 1, widget: 'case_a_cocher', content: "J'accepte", attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
// style toujours avant les autres attributs (voir forgeAttrString /
|
||||
// attr_string.py — le même ordre côté serveur).
|
||||
assert.ok(html.startsWith('<label style='));
|
||||
assert.ok(html.includes('class="checkbox"'));
|
||||
assert.ok(html.includes('type="checkbox"'));
|
||||
assert.ok(html.includes("J'accepte")); // échappé, comme html.escape() côté serveur
|
||||
assert.ok(html.includes('J'accepte')); // échappé, comme html.escape() côté serveur
|
||||
});
|
||||
|
||||
test('liste déroulante — une <option> par ligne de contenu', () => {
|
||||
const el = { id: 2, widget: 'liste_deroulante', content: 'Rouge\nVert\nBleu', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 2, widget: 'liste_deroulante', content: 'Rouge\nVert\nBleu', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('<div class="select is-fullwidth">'));
|
||||
assert.equal((html.match(/<option/g) || []).length, 3);
|
||||
});
|
||||
|
||||
test('groupe de champs — <fieldset><legend> + enfants', () => {
|
||||
const parent = { id: 3, widget: 'groupe_champs', content: 'Coordonnées', attributes: {}, style: {} };
|
||||
const child = { id: 4, widget: 'texte', content: 'Nom', attributes: {}, style: {}, parent_id: 3 };
|
||||
const parent = {
|
||||
id: 3, widget: 'groupe_champs', content: 'Coordonnées', attributes: {}, style: {},
|
||||
};
|
||||
const child = {
|
||||
id: 4, widget: 'texte', content: 'Nom', attributes: {}, style: {}, parent_id: 3,
|
||||
};
|
||||
const html = forgeRenderElementHtml(parent, baseGameData(), { 3: [child] }, {});
|
||||
assert.ok(html.startsWith('<fieldset '));
|
||||
assert.ok(html.includes('<legend>Coordonnées</legend>'));
|
||||
@@ -44,22 +52,28 @@ test('groupe de champs — <fieldset><legend> + enfants', () => {
|
||||
});
|
||||
|
||||
test('tableau — une ligne par ligne de contenu, cellules séparées par ";"', () => {
|
||||
const el = { id: 5, widget: 'tableau', content: 'Nom;Score\nAlice;42', attributes: { _header_row: '1' }, style: {} };
|
||||
const el = {
|
||||
id: 5, widget: 'tableau', content: 'Nom;Score\nAlice;42', attributes: { _header_row: '1' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('<th>Nom</th><th>Score</th>'));
|
||||
assert.ok(html.includes('<td>Alice</td><td>42</td>'));
|
||||
});
|
||||
|
||||
test('icône — repli sur "star" si _icon_slug absent', () => {
|
||||
const el = { id: 6, widget: 'icone', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 6, widget: 'icone', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
// le style entier passe par forgeHtmlEscape (comme html.escape() côté
|
||||
// serveur) : l'apostrophe de url('...') devient '.
|
||||
assert.ok(html.includes("--icon-url:url('icons/star.svg')"));
|
||||
assert.ok(html.includes('--icon-url:url('icons/star.svg')'));
|
||||
});
|
||||
|
||||
test('icône — aria-hidden="true" par défaut (RGAA 1.1.1, décorative faute de description)', () => {
|
||||
const el = { id: 6, widget: 'icone', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 6, widget: 'icone', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('aria-hidden="true"'));
|
||||
});
|
||||
@@ -72,17 +86,25 @@ test('icône — aria-hidden="true" par défaut (RGAA 1.1.1, décorative faute d
|
||||
// l'export, voir build_scorm_package.py::_build_icon_data_uris) contourne
|
||||
// entièrement le problème : aucune requête réseau séparée pour une data: URI.
|
||||
test('icône — utilise gameData.icon_data_uris quand pré-encodée (contourne le blocage CORS de Firefox sous file://)', () => {
|
||||
const el = { id: 6, widget: 'icone', attributes: { _icon_slug: 'envelope' }, style: {} };
|
||||
const el = {
|
||||
id: 6, widget: 'icone', attributes: { _icon_slug: 'envelope' }, style: {},
|
||||
};
|
||||
const gd = baseGameData();
|
||||
gd.icon_data_uris = { envelope: 'data:image/svg+xml;base64,QUJD' };
|
||||
const html = forgeRenderElementHtml(el, gd, {}, {});
|
||||
assert.ok(html.includes("--icon-url:url('data:image/svg+xml;base64,QUJD')"));
|
||||
assert.ok(html.includes('--icon-url:url('data:image/svg+xml;base64,QUJD')'));
|
||||
});
|
||||
|
||||
test('onglets — un panneau actif, les autres masqués', () => {
|
||||
const parent = { id: 7, widget: 'onglets', attributes: {}, style: {} };
|
||||
const tab1 = { id: 8, name: 'Infos', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7 };
|
||||
const tab2 = { id: 9, name: 'Options', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7 };
|
||||
const parent = {
|
||||
id: 7, widget: 'onglets', attributes: {}, style: {},
|
||||
};
|
||||
const tab1 = {
|
||||
id: 8, name: 'Infos', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7,
|
||||
};
|
||||
const tab2 = {
|
||||
id: 9, name: 'Options', widget: 'conteneur', content: '', attributes: {}, style: {}, parent_id: 7,
|
||||
};
|
||||
const html = forgeRenderElementHtml(parent, baseGameData(), { 7: [tab1, tab2] }, {});
|
||||
assert.ok(html.includes('is-active'));
|
||||
assert.ok(html.includes('tabpanel-8'));
|
||||
@@ -90,7 +112,9 @@ test('onglets — un panneau actif, les autres masqués', () => {
|
||||
});
|
||||
|
||||
test('personnage — lit gameData.personnage_animations (idle en premier)', () => {
|
||||
const el = { id: 5, widget: 'personnage', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 5, widget: 'personnage', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.startsWith('<img '));
|
||||
assert.ok(html.includes('src="/static/characters/zombie/idle0.png"'));
|
||||
@@ -100,8 +124,12 @@ test('personnage — lit gameData.personnage_animations (idle en premier)', () =
|
||||
|
||||
test('jauge — pourcentage et couleur interpolée depuis un champ d\'objet', () => {
|
||||
const el = {
|
||||
id: 10, widget: 'jauge', style: {},
|
||||
attributes: { _definition_id: '1', _champ: 'points', _min: '0', _max: '100', _couleur_basse: '#000000', _couleur_haute: '#ffffff' },
|
||||
id: 10,
|
||||
widget: 'jauge',
|
||||
style: {},
|
||||
attributes: {
|
||||
_definition_id: '1', _champ: 'points', _min: '0', _max: '100', _couleur_basse: '#000000', _couleur_haute: '#ffffff',
|
||||
},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('value="42"')); // ligne la plus récente = Alice (id 1, points=42)
|
||||
@@ -110,8 +138,11 @@ test('jauge — pourcentage et couleur interpolée depuis un champ d\'objet', ()
|
||||
|
||||
test('répéteur — une repeaterItem par ligne, {{champ}} substitué', () => {
|
||||
const el = {
|
||||
id: 11, widget: 'repeteur_donnees', content: '{{nom}} : {{points}} pts',
|
||||
attributes: { _definition_id: '1' }, style: {},
|
||||
id: 11,
|
||||
widget: 'repeteur_donnees',
|
||||
content: '{{nom}} : {{points}} pts',
|
||||
attributes: { _definition_id: '1' },
|
||||
style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.equal((html.match(/repeaterItem/g) || []).length, 2);
|
||||
@@ -121,14 +152,19 @@ test('répéteur — une repeaterItem par ligne, {{champ}} substitué', () => {
|
||||
});
|
||||
|
||||
test('répéteur — objet introuvable, message explicite plutôt qu\'un plantage', () => {
|
||||
const el = { id: 12, widget: 'repeteur_donnees', content: '', attributes: { _definition_id: '999' }, style: {} };
|
||||
const el = {
|
||||
id: 12, widget: 'repeteur_donnees', content: '', attributes: { _definition_id: '999' }, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.ok(html.includes('Objet introuvable'));
|
||||
});
|
||||
|
||||
test('donnée liée (Texte) — {{champ}} résolu depuis la ligne trouvée', () => {
|
||||
const el = {
|
||||
id: 13, widget: 'texte', content: 'Score actuel : {{points}}', style: {},
|
||||
id: 13,
|
||||
widget: 'texte',
|
||||
content: 'Score actuel : {{points}}',
|
||||
style: {},
|
||||
attributes: {
|
||||
_data_definition_id: '1',
|
||||
_data_filtres_json: JSON.stringify([{ champ: 'nom', operateur: 'egal', valeur: 'Bob' }]),
|
||||
@@ -140,7 +176,9 @@ test('donnée liée (Texte) — {{champ}} résolu depuis la ligne trouvée', ()
|
||||
});
|
||||
|
||||
test('superposition — voile plein écran seulement en mode jouable', () => {
|
||||
const el = { id: 14, widget: 'superposition', content: '', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 14, widget: 'superposition', content: '', attributes: {}, style: {},
|
||||
};
|
||||
const editorHtml = forgeRenderElementHtml(el, baseGameData(), {}, {});
|
||||
assert.equal(editorHtml.indexOf('position:fixed'), -1);
|
||||
const playHtml = forgeRenderElementHtml(el, baseGameData(), {}, { _forge_play_mode: true });
|
||||
@@ -151,10 +189,14 @@ test('superposition — voile plein écran seulement en mode jouable', () => {
|
||||
test('élément de jeu réutilisable — rejoue son écran-modèle depuis gameData.element_type_templates', () => {
|
||||
const gd = baseGameData();
|
||||
gd.element_type_templates['20'] = {
|
||||
top: [{ id: 30, widget: 'texte', content: 'Modèle', attributes: {}, style: {}, parent_id: 'template-root' }],
|
||||
top: [{
|
||||
id: 30, widget: 'texte', content: 'Modèle', attributes: {}, style: {}, parent_id: 'template-root',
|
||||
}],
|
||||
children_map: {},
|
||||
};
|
||||
const el = { id: 15, widget: 'conteneur', element_type_id: 20, content: '', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 15, widget: 'conteneur', element_type_id: 20, content: '', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, gd, {}, {});
|
||||
assert.ok(html.includes('>Modèle<'));
|
||||
});
|
||||
@@ -162,10 +204,14 @@ test('élément de jeu réutilisable — rejoue son écran-modèle depuis gameDa
|
||||
test('élément de jeu = superposition seule -> enveloppe transparente sans classe "box"', () => {
|
||||
const gd = baseGameData();
|
||||
gd.element_type_templates['21'] = {
|
||||
top: [{ id: 31, widget: 'superposition', content: '', attributes: {}, style: { display: 'none' }, parent_id: 'template-root' }],
|
||||
top: [{
|
||||
id: 31, widget: 'superposition', content: '', attributes: {}, style: { display: 'none' }, parent_id: 'template-root',
|
||||
}],
|
||||
children_map: {},
|
||||
};
|
||||
const el = { id: 16, widget: 'conteneur', element_type_id: 21, content: '', attributes: {}, style: {} };
|
||||
const el = {
|
||||
id: 16, widget: 'conteneur', element_type_id: 21, content: '', attributes: {}, style: {},
|
||||
};
|
||||
const html = forgeRenderElementHtml(el, gd, {}, {});
|
||||
assert.equal(html.indexOf('class="box"'), -1);
|
||||
assert.ok(html.startsWith('<div '));
|
||||
|
||||
@@ -24,8 +24,11 @@ function fakeApi() {
|
||||
|
||||
test('FORGE_TO_SCORM_STATUS — vocabulaire figé cmi.core.lesson_status', () => {
|
||||
assert.deepEqual(FORGE_TO_SCORM_STATUS, {
|
||||
non_commence: 'not attempted', en_cours: 'incomplete', termine: 'completed',
|
||||
reussi: 'passed', echoue: 'failed',
|
||||
non_commence: 'not attempted',
|
||||
en_cours: 'incomplete',
|
||||
termine: 'completed',
|
||||
reussi: 'passed',
|
||||
echoue: 'failed',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +46,7 @@ test('forgeScormInit ne pose PAS cmi.core.score.min/max si le jeu n\'a aucune qu
|
||||
global.window.FORGE_MAX_QUIZ_SCORE = 0;
|
||||
try {
|
||||
forgeScormInit();
|
||||
assert.ok(!api.calls.some(c => c[1] === 'cmi.core.score.max'));
|
||||
assert.ok(!api.calls.some((c) => c[1] === 'cmi.core.score.max'));
|
||||
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
|
||||
});
|
||||
|
||||
@@ -54,8 +57,8 @@ test('forgeScormInit pose cmi.core.score.min/max une fois si le jeu a des questi
|
||||
try {
|
||||
forgeScormInit();
|
||||
assert.deepEqual(
|
||||
api.calls.filter(c => c[0] === 'LMSSetValue').map(c => [c[1], c[2]]),
|
||||
[['cmi.core.score.min', '0'], ['cmi.core.score.max', '20']]
|
||||
api.calls.filter((c) => c[0] === 'LMSSetValue').map((c) => [c[1], c[2]]),
|
||||
[['cmi.core.score.min', '0'], ['cmi.core.score.max', '20']],
|
||||
);
|
||||
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
|
||||
});
|
||||
@@ -68,8 +71,8 @@ test('forgeScormSyncScore envoie score.raw/lesson_status et Commit, une seule fo
|
||||
forgeScormInit();
|
||||
forgeScormSyncScore({ scoring: { score: 7, status: 'reussi' } });
|
||||
const callsAfterFirst = api.calls.length;
|
||||
assert.ok(api.calls.some(c => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.score.raw' && c[2] === '7'));
|
||||
assert.ok(api.calls.some(c => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.lesson_status' && c[2] === 'passed'));
|
||||
assert.ok(api.calls.some((c) => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.score.raw' && c[2] === '7'));
|
||||
assert.ok(api.calls.some((c) => c[0] === 'LMSSetValue' && c[1] === 'cmi.core.lesson_status' && c[2] === 'passed'));
|
||||
forgeScormSyncScore({ scoring: { score: 7, status: 'reussi' } });
|
||||
assert.equal(api.calls.length, callsAfterFirst);
|
||||
} finally { delete global.window.API; delete global.window.FORGE_MAX_QUIZ_SCORE; }
|
||||
|
||||
@@ -20,7 +20,7 @@ function fakeApi(interactionsCount) {
|
||||
calls,
|
||||
Initialize: () => calls.push(['Initialize']),
|
||||
SetValue: (key, value) => calls.push(['SetValue', key, value]),
|
||||
GetValue: key => (key === 'cmi.interactions._count' ? String(interactionsCount || 0) : ''),
|
||||
GetValue: (key) => (key === 'cmi.interactions._count' ? String(interactionsCount || 0) : ''),
|
||||
Commit: () => calls.push(['Commit']),
|
||||
};
|
||||
}
|
||||
@@ -52,8 +52,8 @@ test('forgeScorm2004Init pose cmi.score.min/max une fois si le jeu a des questio
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
assert.deepEqual(
|
||||
api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]),
|
||||
[['cmi.score.min', '0'], ['cmi.score.max', '20']]
|
||||
api.calls.filter((c) => c[0] === 'SetValue').map((c) => [c[1], c[2]]),
|
||||
[['cmi.score.min', '0'], ['cmi.score.max', '20']],
|
||||
);
|
||||
} finally { delete global.window.API_1484_11; delete global.window.FORGE_MAX_QUIZ_SCORE; }
|
||||
});
|
||||
@@ -66,10 +66,10 @@ test('forgeScorm2004SyncScore — "echoue" envoie completion_status="completed"
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004SyncScore({ scoring: { score: 3, status: 'echoue' } });
|
||||
assert.deepEqual(
|
||||
api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]),
|
||||
[['cmi.score.raw', '3'], ['cmi.completion_status', 'completed'], ['cmi.success_status', 'failed']]
|
||||
api.calls.filter((c) => c[0] === 'SetValue').map((c) => [c[1], c[2]]),
|
||||
[['cmi.score.raw', '3'], ['cmi.completion_status', 'completed'], ['cmi.success_status', 'failed']],
|
||||
);
|
||||
assert.ok(api.calls.some(c => c[0] === 'Commit'));
|
||||
assert.ok(api.calls.some((c) => c[0] === 'Commit'));
|
||||
} finally { delete global.window.API_1484_11; }
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ test('forgeScorm2004SyncScore — ajoute cmi.score.scaled UNIQUEMENT si des ques
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004SyncScore({ scoring: { score: 3, status: 'reussi' } });
|
||||
const scaledCall = api.calls.find(c => c[0] === 'SetValue' && c[1] === 'cmi.score.scaled');
|
||||
const scaledCall = api.calls.find((c) => c[0] === 'SetValue' && c[1] === 'cmi.score.scaled');
|
||||
assert.equal(scaledCall[2], String(3 / 4));
|
||||
} finally {
|
||||
delete global.window.API_1484_11;
|
||||
@@ -117,7 +117,7 @@ test('forgeScorm2004NotifyQuestionAnswered pose une entrée cmi.interactions.n.*
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004NotifyQuestionAnswered('d_5', 'Le sorcier', 1, 'Qui es-tu ?', true, 0, 0);
|
||||
const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]));
|
||||
const sets = Object.fromEntries(api.calls.filter((c) => c[0] === 'SetValue').map((c) => [c[1], c[2]]));
|
||||
assert.equal(sets['cmi.interactions.2.id'], 'dialogue:d_5:question:1');
|
||||
assert.equal(sets['cmi.interactions.2.type'], 'choice');
|
||||
assert.match(sets['cmi.interactions.2.timestamp'], /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
|
||||
@@ -125,7 +125,7 @@ test('forgeScorm2004NotifyQuestionAnswered pose une entrée cmi.interactions.n.*
|
||||
assert.equal(sets['cmi.interactions.2.learner_response'], '0');
|
||||
assert.equal(sets['cmi.interactions.2.result'], 'correct');
|
||||
assert.equal(sets['cmi.interactions.2.correct_responses.0.pattern'], '0');
|
||||
assert.ok(api.calls.some(c => c[0] === 'Commit'));
|
||||
assert.ok(api.calls.some((c) => c[0] === 'Commit'));
|
||||
} finally { delete global.window.API_1484_11; }
|
||||
});
|
||||
|
||||
@@ -135,7 +135,7 @@ test('forgeScorm2004NotifyQuestionAnswered — mauvaise réponse : result="incor
|
||||
try {
|
||||
forgeScorm2004Init();
|
||||
forgeScorm2004NotifyQuestionAnswered('d_5', 'Le sorcier', 0, 'Qui es-tu ?', false, 1, 0);
|
||||
const sets = Object.fromEntries(api.calls.filter(c => c[0] === 'SetValue').map(c => [c[1], c[2]]));
|
||||
const sets = Object.fromEntries(api.calls.filter((c) => c[0] === 'SetValue').map((c) => [c[1], c[2]]));
|
||||
assert.equal(sets['cmi.interactions.0.result'], 'incorrect');
|
||||
assert.equal(sets['cmi.interactions.0.learner_response'], '1');
|
||||
assert.equal(sets['cmi.interactions.0.correct_responses.0.pattern'], '0');
|
||||
|
||||
@@ -7,9 +7,9 @@ const { forgeIsElementVisibleOffline } = require('../visibility-condition.js');
|
||||
function baseGameData() {
|
||||
return {
|
||||
variables: { niveau: { value: '3', type: 'nombre_entier' } },
|
||||
definition_names: { '1': 'Progression' },
|
||||
fields_meta: { '1': [{ name: 'debloque', type: 'booleen' }] },
|
||||
data: { '1': [{ id: 1, debloque: 1 }] },
|
||||
definition_names: { 1: 'Progression' },
|
||||
fields_meta: { 1: [{ name: 'debloque', type: 'booleen' }] },
|
||||
data: { 1: [{ id: 1, debloque: 1 }] },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,28 +21,37 @@ test('sans réglage de condition, toujours visible', () => {
|
||||
test('mode variable — égal/supérieur', () => {
|
||||
const gd = baseGameData();
|
||||
assert.equal(forgeIsElementVisibleOffline(gd, {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur_egal', _visibilite_cond_valeur: '3',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur_egal',
|
||||
_visibilite_cond_valeur: '3',
|
||||
}), true);
|
||||
assert.equal(forgeIsElementVisibleOffline(gd, {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur', _visibilite_cond_valeur: '3',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'niveau',
|
||||
_visibilite_cond_operateur: 'superieur',
|
||||
_visibilite_cond_valeur: '3',
|
||||
}), false);
|
||||
});
|
||||
|
||||
test('mode variable — variable supprimée depuis : ne bloque jamais', () => {
|
||||
const gd = baseGameData();
|
||||
assert.equal(forgeIsElementVisibleOffline(gd, {
|
||||
_visibilite_cond_mode: 'variable', _visibilite_cond_variable: 'disparue',
|
||||
_visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: '1',
|
||||
_visibilite_cond_mode: 'variable',
|
||||
_visibilite_cond_variable: 'disparue',
|
||||
_visibilite_cond_operateur: 'egal',
|
||||
_visibilite_cond_valeur: '1',
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('mode objet — lit la ligne la plus récente hors répéteur', () => {
|
||||
const gd = baseGameData();
|
||||
assert.equal(forgeIsElementVisibleOffline(gd, {
|
||||
_visibilite_cond_mode: 'objet', _visibilite_cond_definition_id: '1',
|
||||
_visibilite_cond_champ: 'debloque', _visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: 'oui',
|
||||
_visibilite_cond_mode: 'objet',
|
||||
_visibilite_cond_definition_id: '1',
|
||||
_visibilite_cond_champ: 'debloque',
|
||||
_visibilite_cond_operateur: 'egal',
|
||||
_visibilite_cond_valeur: 'oui',
|
||||
}), true);
|
||||
});
|
||||
|
||||
@@ -50,7 +59,10 @@ test('mode objet — utilise ctx (ligne du Répéteur) plutôt que la ligne la p
|
||||
const gd = baseGameData();
|
||||
const ctx = { debloque: 0 };
|
||||
assert.equal(forgeIsElementVisibleOffline(gd, {
|
||||
_visibilite_cond_mode: 'objet', _visibilite_cond_definition_id: '1',
|
||||
_visibilite_cond_champ: 'debloque', _visibilite_cond_operateur: 'egal', _visibilite_cond_valeur: 'oui',
|
||||
_visibilite_cond_mode: 'objet',
|
||||
_visibilite_cond_definition_id: '1',
|
||||
_visibilite_cond_champ: 'debloque',
|
||||
_visibilite_cond_operateur: 'egal',
|
||||
_visibilite_cond_valeur: 'oui',
|
||||
}, ctx), false);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ const assert = require('node:assert/strict');
|
||||
|
||||
global.window = global.window || {};
|
||||
global.window.addEventListener = global.window.addEventListener || function () {};
|
||||
global.btoa = global.btoa || (str => Buffer.from(str).toString('base64'));
|
||||
global.btoa = global.btoa || ((str) => Buffer.from(str).toString('base64'));
|
||||
|
||||
const {
|
||||
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
@@ -16,8 +16,11 @@ const {
|
||||
|
||||
test.beforeEach(() => {
|
||||
global.window.FORGE_XAPI_CONFIG = {
|
||||
endpoint: 'https://lrs.example.com/xapi', login: 'user', password: 'pass',
|
||||
activity_id: 'urn:forge-engine:game:test', activity_name: 'Mon jeu',
|
||||
endpoint: 'https://lrs.example.com/xapi',
|
||||
login: 'user',
|
||||
password: 'pass',
|
||||
activity_id: 'urn:forge-engine:game:test',
|
||||
activity_name: 'Mon jeu',
|
||||
};
|
||||
});
|
||||
|
||||
@@ -38,7 +41,7 @@ test('forgeXapiNotifyDialogueCompleted envoie un statement "completed" ciblant l
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyDialogueCompleted('d_5', 'Le sorcier');
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/completed');
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/completed'); // NOSONAR S5332
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:dialogue:d_5');
|
||||
assert.equal(body.result.completion, true);
|
||||
});
|
||||
@@ -48,7 +51,7 @@ test('forgeXapiNotifyQuestionAnswered envoie un statement "answered" avec succes
|
||||
global.fetch = (url, opts) => { calls.push({ url, opts }); return Promise.resolve(); };
|
||||
forgeXapiNotifyQuestionAnswered('d_5', 'Le sorcier', 1, 'Qui es-tu ?', true, 5);
|
||||
const body = JSON.parse(calls[0].opts.body);
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/answered');
|
||||
assert.equal(body.verb.id, 'http://adlnet.gov/expapi/verbs/answered'); // NOSONAR S5332
|
||||
assert.equal(body.object.id, 'urn:forge-engine:game:test:dialogue:d_5:question:1');
|
||||
assert.equal(body.result.success, true);
|
||||
assert.equal(body.result.score.raw, 5);
|
||||
|
||||
@@ -18,24 +18,24 @@
|
||||
// full_game_payload.py) pour que conditions.js continue de la lire
|
||||
// exactement comme aujourd'hui.
|
||||
function forgeApplyVariableActionOffline(gameData, action) {
|
||||
var varName = action.target_variable;
|
||||
var operation = action.data_operation;
|
||||
var rawValue = action.data_value;
|
||||
const varName = action.target_variable;
|
||||
const operation = action.data_operation;
|
||||
const rawValue = action.data_value;
|
||||
if (!varName || !operation) return false;
|
||||
var variable = gameData.variables && gameData.variables[varName];
|
||||
const variable = gameData.variables && gameData.variables[varName];
|
||||
if (!variable) return false;
|
||||
var varType = variable.type;
|
||||
var current = variable.value;
|
||||
if (varType === "nombre_entier" || varType === "nombre_decimal") {
|
||||
current = (current === null || current === undefined || current === "") ? 0 : parseFloat(current);
|
||||
if (isNaN(current)) current = 0;
|
||||
} else if (varType === "booleen") {
|
||||
var normalized = String(current == null ? "" : current).trim().toLowerCase();
|
||||
current = (normalized === "1" || normalized === "true" || normalized === "vrai") ? 1 : 0;
|
||||
const varType = variable.type;
|
||||
let current = variable.value;
|
||||
if (varType === 'nombre_entier' || varType === 'nombre_decimal') {
|
||||
current = (current === null || current === undefined || current === '') ? 0 : parseFloat(current);
|
||||
if (Number.isNaN(current)) current = 0;
|
||||
} else if (varType === 'booleen') {
|
||||
const normalized = String(current == null ? '' : current).trim().toLowerCase();
|
||||
current = (normalized === '1' || normalized === 'true' || normalized === 'vrai') ? 1 : 0;
|
||||
}
|
||||
var newValue;
|
||||
let newValue;
|
||||
try {
|
||||
newValue = forgeComputeNewValue(operation, current, rawValue, varType === "nombre_decimal");
|
||||
newValue = forgeComputeNewValue(operation, current, rawValue, varType === 'nombre_decimal');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
@@ -47,15 +47,15 @@ function forgeApplyVariableActionOffline(gameData, action) {
|
||||
// Port de apply_score_action.py/apply_status_action.py — voir db/scoring/
|
||||
// pour le vocabulaire des statuts (db.SCORE_STATUS_LABELS côté serveur,
|
||||
// dupliqué ici en dur : ces 5 clés sont figées, voir db/constants.py).
|
||||
var FORGE_SCORE_STATUS_KEYS = ["non_commence", "en_cours", "termine", "reussi", "echoue"];
|
||||
const FORGE_SCORE_STATUS_KEYS = ['non_commence', 'en_cours', 'termine', 'reussi', 'echoue'];
|
||||
|
||||
function forgeApplyScoreActionOffline(gameData, action) {
|
||||
var operation = action.data_operation;
|
||||
var rawValue = action.data_value;
|
||||
const operation = action.data_operation;
|
||||
const rawValue = action.data_value;
|
||||
if (!operation) return false;
|
||||
if (!gameData.scoring) gameData.scoring = { score: 0, status: "non_commence" };
|
||||
var current = gameData.scoring.score || 0;
|
||||
var newValue;
|
||||
if (!gameData.scoring) gameData.scoring = { score: 0, status: 'non_commence' };
|
||||
const current = gameData.scoring.score || 0;
|
||||
let newValue;
|
||||
try {
|
||||
newValue = forgeComputeNewValue(operation, current, rawValue, true);
|
||||
} catch (e) {
|
||||
@@ -65,16 +65,16 @@ function forgeApplyScoreActionOffline(gameData, action) {
|
||||
// xAPI bolt-on (voir static/js/play/offline/xapi-client.js) : no-op si
|
||||
// le fichier n'est pas chargé (tests Node via module.exports ci-dessous,
|
||||
// ou réglage xAPI jamais configuré pour ce jeu).
|
||||
if (typeof forgeXapiNotifyScoreChanged === "function") forgeXapiNotifyScoreChanged(newValue);
|
||||
if (typeof forgeXapiNotifyScoreChanged === 'function') forgeXapiNotifyScoreChanged(newValue);
|
||||
return true;
|
||||
}
|
||||
|
||||
function forgeApplyStatusActionOffline(gameData, action) {
|
||||
var status = action.data_value;
|
||||
const status = action.data_value;
|
||||
if (!status || FORGE_SCORE_STATUS_KEYS.indexOf(status) === -1) return false;
|
||||
if (!gameData.scoring) gameData.scoring = { score: 0, status: "non_commence" };
|
||||
if (!gameData.scoring) gameData.scoring = { score: 0, status: 'non_commence' };
|
||||
gameData.scoring.status = status;
|
||||
if (typeof forgeXapiNotifyStatusChanged === "function") forgeXapiNotifyStatusChanged(status);
|
||||
if (typeof forgeXapiNotifyStatusChanged === 'function') forgeXapiNotifyStatusChanged(status);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -86,45 +86,45 @@ function forgeApplyStatusActionOffline(gameData, action) {
|
||||
// {name, type, min_value, max_value} — même forme que le payload en
|
||||
// ligne (screens/payload/full_game_payload.py).
|
||||
function forgeFindFieldMeta(gameData, definitionId, fieldName) {
|
||||
var fields = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fields = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
if (!fields) return null;
|
||||
for (var i = 0; i < fields.length; i++) {
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
if (fields[i].name === fieldName) return fields[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function forgeClampToFieldBounds(value, fieldMeta) {
|
||||
if (fieldMeta.type !== "nombre_entier" && fieldMeta.type !== "nombre_decimal") return value;
|
||||
if (fieldMeta.type !== 'nombre_entier' && fieldMeta.type !== 'nombre_decimal') return value;
|
||||
if (fieldMeta.min_value !== null && fieldMeta.min_value !== undefined && value < fieldMeta.min_value) {
|
||||
value = fieldMeta.min_value;
|
||||
}
|
||||
if (fieldMeta.max_value !== null && fieldMeta.max_value !== undefined && value > fieldMeta.max_value) {
|
||||
value = fieldMeta.max_value;
|
||||
}
|
||||
return fieldMeta.type === "nombre_decimal" ? value : Math.trunc(value);
|
||||
return fieldMeta.type === 'nombre_decimal' ? value : Math.trunc(value);
|
||||
}
|
||||
|
||||
function forgeApplyDataActionOffline(gameData, action) {
|
||||
var definitionId = String(action.target_definition_id || "");
|
||||
var rowId = action.target_row_id;
|
||||
var fieldName = action.target_field;
|
||||
var operation = action.data_operation;
|
||||
var rawValue = action.data_value;
|
||||
const definitionId = String(action.target_definition_id || '');
|
||||
const rowId = action.target_row_id;
|
||||
const fieldName = action.target_field;
|
||||
const operation = action.data_operation;
|
||||
const rawValue = action.data_value;
|
||||
if (!(definitionId && rowId && fieldName && operation)) return false;
|
||||
var fieldMeta = forgeFindFieldMeta(gameData, definitionId, fieldName);
|
||||
const fieldMeta = forgeFindFieldMeta(gameData, definitionId, fieldName);
|
||||
if (!fieldMeta) return false;
|
||||
var rows = gameData.data && gameData.data[definitionId];
|
||||
const rows = gameData.data && gameData.data[definitionId];
|
||||
if (!rows) return false;
|
||||
var row = null;
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
let row = null;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
if (rows[i].id === rowId) { row = rows[i]; break; }
|
||||
}
|
||||
if (!row) return false;
|
||||
var current = row[fieldName];
|
||||
var newValue;
|
||||
const current = row[fieldName];
|
||||
let newValue;
|
||||
try {
|
||||
newValue = forgeComputeNewValue(operation, current, rawValue, fieldMeta.type === "nombre_decimal");
|
||||
newValue = forgeComputeNewValue(operation, current, rawValue, fieldMeta.type === 'nombre_decimal');
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
@@ -140,17 +140,17 @@ function forgeApplyDataActionOffline(gameData, action) {
|
||||
// tout le reste -> null). L'id est un simple auto-incrément local (pas
|
||||
// de vraie base : aucune ligne d'un autre joueur à ignorer hors ligne).
|
||||
function forgeApplyAddRowActionOffline(gameData, action) {
|
||||
var definitionId = String(action.target_definition_id || "");
|
||||
const definitionId = String(action.target_definition_id || '');
|
||||
if (!definitionId) return null;
|
||||
var fields = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fields = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
if (!fields) return null;
|
||||
if (!gameData.data[definitionId]) gameData.data[definitionId] = [];
|
||||
var rows = gameData.data[definitionId];
|
||||
var maxId = 0;
|
||||
rows.forEach(function (r) { if (r.id > maxId) maxId = r.id; });
|
||||
var newRow = { id: maxId + 1 };
|
||||
fields.forEach(function (f) {
|
||||
newRow[f.name] = f.type === "booleen" ? 0 : null;
|
||||
const rows = gameData.data[definitionId];
|
||||
let maxId = 0;
|
||||
rows.forEach((r) => { if (r.id > maxId) maxId = r.id; });
|
||||
const newRow = { id: maxId + 1 };
|
||||
fields.forEach((f) => {
|
||||
newRow[f.name] = f.type === 'booleen' ? 0 : null;
|
||||
});
|
||||
rows.push(newRow);
|
||||
return newRow.id;
|
||||
@@ -158,9 +158,14 @@ function forgeApplyAddRowActionOffline(gameData, action) {
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeApplyVariableActionOffline, forgeApplyScoreActionOffline, forgeApplyStatusActionOffline,
|
||||
forgeApplyDataActionOffline, forgeApplyAddRowActionOffline,
|
||||
forgeFindFieldMeta, forgeClampToFieldBounds, FORGE_SCORE_STATUS_KEYS,
|
||||
forgeApplyVariableActionOffline,
|
||||
forgeApplyScoreActionOffline,
|
||||
forgeApplyStatusActionOffline,
|
||||
forgeApplyDataActionOffline,
|
||||
forgeApplyAddRowActionOffline,
|
||||
forgeFindFieldMeta,
|
||||
forgeClampToFieldBounds,
|
||||
FORGE_SCORE_STATUS_KEYS,
|
||||
};
|
||||
// node:test charge ce fichier isolément (voir __tests__/) — forgeComputeNewValue
|
||||
// vit dans compute-operation.js, jamais un <script> séparé en test.
|
||||
|
||||
@@ -19,50 +19,52 @@ function forgePyMod(a, b) {
|
||||
}
|
||||
|
||||
function forgeComputeNewValue(operation, current, rawValue, isDecimal) {
|
||||
var round = function (v) { return isDecimal ? v : forgePyInt(v); };
|
||||
if (operation === "definir_texte") return rawValue || "";
|
||||
if (operation === "definir_nombre") {
|
||||
var n = parseFloat(rawValue);
|
||||
return isNaN(n) ? 0 : round(n);
|
||||
const round = function (v) { return isDecimal ? v : forgePyInt(v); };
|
||||
if (operation === 'definir_texte') return rawValue || '';
|
||||
if (operation === 'definir_nombre') {
|
||||
const n = parseFloat(rawValue);
|
||||
return Number.isNaN(n) ? 0 : round(n);
|
||||
}
|
||||
if (operation === "definir_bool_vrai") return 1;
|
||||
if (operation === "definir_bool_faux") return 0;
|
||||
if (operation === "basculer_bool") return current ? 0 : 1;
|
||||
if (operation === "incrementer" || operation === "decrementer") {
|
||||
var amount = (rawValue === null || rawValue === undefined || rawValue === "") ? 0 : parseFloat(rawValue);
|
||||
if (isNaN(amount)) amount = 0;
|
||||
if (operation === "decrementer") amount = -amount;
|
||||
if (operation === 'definir_bool_vrai') return 1;
|
||||
if (operation === 'definir_bool_faux') return 0;
|
||||
if (operation === 'basculer_bool') return current ? 0 : 1;
|
||||
if (operation === 'incrementer' || operation === 'decrementer') {
|
||||
let amount = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : parseFloat(rawValue);
|
||||
if (Number.isNaN(amount)) amount = 0;
|
||||
if (operation === 'decrementer') amount = -amount;
|
||||
return round((current || 0) + amount);
|
||||
}
|
||||
if (operation === "multiplier") {
|
||||
var factor = (rawValue === null || rawValue === undefined || rawValue === "") ? 1 : parseFloat(rawValue);
|
||||
if (isNaN(factor)) factor = 1;
|
||||
if (operation === 'multiplier') {
|
||||
let factor = (rawValue === null || rawValue === undefined || rawValue === '') ? 1 : parseFloat(rawValue);
|
||||
if (Number.isNaN(factor)) factor = 1;
|
||||
return round((current || 0) * factor);
|
||||
}
|
||||
if (operation === "diviser" || operation === "modulo") {
|
||||
var divisor = (rawValue === null || rawValue === undefined || rawValue === "") ? 0 : parseFloat(rawValue);
|
||||
if (isNaN(divisor)) divisor = 0;
|
||||
if (operation === 'diviser' || operation === 'modulo') {
|
||||
let divisor = (rawValue === null || rawValue === undefined || rawValue === '') ? 0 : parseFloat(rawValue);
|
||||
if (Number.isNaN(divisor)) divisor = 0;
|
||||
if (divisor === 0) return current;
|
||||
var result = operation === "diviser" ? (current || 0) / divisor : forgePyMod(current || 0, divisor);
|
||||
const result = operation === 'diviser' ? (current || 0) / divisor : forgePyMod(current || 0, divisor);
|
||||
return round(result);
|
||||
}
|
||||
if (operation === "minimum" || operation === "maximum") {
|
||||
var bound = parseFloat(rawValue);
|
||||
if (isNaN(bound)) return current;
|
||||
var res = operation === "minimum" ? Math.max(current || 0, bound) : Math.min(current || 0, bound);
|
||||
if (operation === 'minimum' || operation === 'maximum') {
|
||||
const bound = parseFloat(rawValue);
|
||||
if (Number.isNaN(bound)) return current;
|
||||
const res = operation === 'minimum' ? Math.max(current || 0, bound) : Math.min(current || 0, bound);
|
||||
return round(res);
|
||||
}
|
||||
if (operation === "alea") {
|
||||
var parts = String(rawValue || "").split(",");
|
||||
var lo = parseFloat(parts[0]), hi = parseFloat(parts[1]);
|
||||
if (isNaN(lo)) lo = 0;
|
||||
if (isNaN(hi)) hi = 0;
|
||||
if (lo > hi) { var tmp = lo; lo = hi; hi = tmp; }
|
||||
if (isDecimal) return Math.random() * (hi - lo) + lo;
|
||||
var iLo = Math.trunc(lo), iHi = Math.trunc(hi);
|
||||
return Math.floor(Math.random() * (iHi - iLo + 1)) + iLo;
|
||||
if (operation === 'alea') {
|
||||
const parts = String(rawValue || '').split(',');
|
||||
let lo = parseFloat(parts[0]); let
|
||||
hi = parseFloat(parts[1]);
|
||||
if (Number.isNaN(lo)) lo = 0;
|
||||
if (Number.isNaN(hi)) hi = 0;
|
||||
if (lo > hi) { const tmp = lo; lo = hi; hi = tmp; }
|
||||
if (isDecimal) return Math.random() * (hi - lo) + lo; // NOSONAR S2245 - tirage de jeu, jamais crypto
|
||||
const iLo = Math.trunc(lo); const
|
||||
iHi = Math.trunc(hi);
|
||||
return Math.floor(Math.random() * (iHi - iLo + 1)) + iLo; // NOSONAR S2245 - tirage de jeu, jamais crypto
|
||||
}
|
||||
throw new Error("opération inconnue : " + operation);
|
||||
throw new Error(`opération inconnue : ${operation}`);
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même patron que conditions.js.
|
||||
|
||||
@@ -7,36 +7,36 @@
|
||||
// _compare() côté Python (chargé avant ce fichier dans templates/
|
||||
// play.html), pas de troisième copie de cette logique.
|
||||
|
||||
var FORGE_REF_PATTERN = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/;
|
||||
var FORGE_VAR_REF_PATTERN = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/;
|
||||
var FORGE_PATH_SEGMENT_RE = /\.([^.[\]]+)|\[(\d+)\]/g;
|
||||
const FORGE_REF_PATTERN = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/;
|
||||
const FORGE_VAR_REF_PATTERN = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/;
|
||||
const FORGE_PATH_SEGMENT_RE = /\.([^.[\]]+)|\[(\d+)\]/g;
|
||||
|
||||
// Port de decode_clauses (screens/clause_list_codec.py) — repli sur
|
||||
// l'ancien format à 2 filtres fixes si l'attribut JSON est absent, même
|
||||
// convention que côté serveur (aucune migration de données nécessaire).
|
||||
function forgeDecodeClauses(attrs, clausesAttr, combinatorAttr, legacyPrefix) {
|
||||
var raw = attrs[clausesAttr];
|
||||
var clauses = null;
|
||||
const raw = attrs[clausesAttr];
|
||||
let clauses = null;
|
||||
if (raw) {
|
||||
try {
|
||||
var parsed = JSON.parse(raw);
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) clauses = parsed;
|
||||
} catch (e) { clauses = null; }
|
||||
}
|
||||
if (clauses === null && legacyPrefix) {
|
||||
clauses = [];
|
||||
["", "2"].forEach(function (suffix) {
|
||||
var champ = (attrs[legacyPrefix + suffix + "_champ"] || "").trim();
|
||||
['', '2'].forEach((suffix) => {
|
||||
const champ = (attrs[`${legacyPrefix + suffix}_champ`] || '').trim();
|
||||
if (champ) {
|
||||
clauses.push({
|
||||
champ: champ,
|
||||
operateur: attrs[legacyPrefix + suffix + "_operateur"] || "egal",
|
||||
valeur: attrs[legacyPrefix + suffix + "_valeur"] || "",
|
||||
champ,
|
||||
operateur: attrs[`${legacyPrefix + suffix}_operateur`] || 'egal',
|
||||
valeur: attrs[`${legacyPrefix + suffix}_valeur`] || '',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return { clauses: clauses || [], combinator: attrs[combinatorAttr] || "et" };
|
||||
return { clauses: clauses || [], combinator: attrs[combinatorAttr] || 'et' };
|
||||
}
|
||||
|
||||
// Port de _resolve_variable_path — navigue ".champ"/"[index]" dans une
|
||||
@@ -45,16 +45,17 @@ function forgeDecodeClauses(attrs, clausesAttr, combinatorAttr, legacyPrefix) {
|
||||
// planter).
|
||||
function forgeResolveVariablePath(rawValue, path) {
|
||||
if (!path) return rawValue;
|
||||
var current;
|
||||
let current;
|
||||
try {
|
||||
current = rawValue ? JSON.parse(rawValue) : null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
var m;
|
||||
let m;
|
||||
FORGE_PATH_SEGMENT_RE.lastIndex = 0;
|
||||
while ((m = FORGE_PATH_SEGMENT_RE.exec(path)) !== null) {
|
||||
var key = m[1], index = m[2];
|
||||
const key = m[1]; const
|
||||
index = m[2];
|
||||
try {
|
||||
current = key !== undefined ? current[key] : current[parseInt(index, 10)];
|
||||
if (current === undefined) return null;
|
||||
@@ -86,22 +87,23 @@ function forgeResolveVariablePath(rawValue, path) {
|
||||
// définition visée par son nom affiché.
|
||||
function forgeResolveFilterValue(gameData, rawValue) {
|
||||
if (!rawValue) return rawValue;
|
||||
var stripped = String(rawValue).trim();
|
||||
const stripped = String(rawValue).trim();
|
||||
|
||||
var varMatch = FORGE_VAR_REF_PATTERN.exec(stripped);
|
||||
const varMatch = FORGE_VAR_REF_PATTERN.exec(stripped);
|
||||
if (varMatch) {
|
||||
var variable = gameData.variables && gameData.variables[varMatch[1].trim()];
|
||||
const variable = gameData.variables && gameData.variables[varMatch[1].trim()];
|
||||
if (!variable) return null; // variable supprimée depuis : ne matche rien plutôt que planter
|
||||
return forgeResolveVariablePath(variable.value, varMatch[2]);
|
||||
}
|
||||
|
||||
var m = FORGE_REF_PATTERN.exec(stripped);
|
||||
const m = FORGE_REF_PATTERN.exec(stripped);
|
||||
if (!m) return rawValue;
|
||||
var objectName = m[1].trim(), fieldName = m[2].trim();
|
||||
var names = gameData.definition_names || {};
|
||||
for (var defId in names) {
|
||||
const objectName = m[1].trim(); const
|
||||
fieldName = m[2].trim();
|
||||
const names = gameData.definition_names || {};
|
||||
for (const defId in names) {
|
||||
if (names[defId] === objectName) {
|
||||
var rows = gameData.data && gameData.data[defId];
|
||||
const rows = gameData.data && gameData.data[defId];
|
||||
if (rows && rows.length) {
|
||||
return rows[0][fieldName];
|
||||
}
|
||||
@@ -113,41 +115,41 @@ function forgeResolveFilterValue(gameData, rawValue) {
|
||||
|
||||
// Port de _clause_matches.
|
||||
function forgeClauseMatches(gameData, definitionId, row, clause) {
|
||||
var fieldName = (clause.champ || "").trim();
|
||||
const fieldName = (clause.champ || '').trim();
|
||||
if (!fieldName) return true;
|
||||
var operator = clause.operateur || "egal";
|
||||
var expected = forgeResolveFilterValue(gameData, clause.valeur || "");
|
||||
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
var field = (fieldsMetaForDef || []).find(function (f) { return f.name === fieldName; });
|
||||
var fieldType = field ? field.type : "texte";
|
||||
const operator = clause.operateur || 'egal';
|
||||
const expected = forgeResolveFilterValue(gameData, clause.valeur || '');
|
||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const field = (fieldsMetaForDef || []).find((f) => f.name === fieldName);
|
||||
const fieldType = field ? field.type : 'texte';
|
||||
return compareValues(row[fieldName], operator, expected, fieldType);
|
||||
}
|
||||
|
||||
// Port de filter_rows_by_clauses.
|
||||
function forgeFilterRowsByClauses(gameData, definitionId, rows, clauses, combinator) {
|
||||
if (!clauses || !clauses.length) return rows;
|
||||
if (combinator === "ou") {
|
||||
return rows.filter(function (r) {
|
||||
return clauses.some(function (c) { return forgeClauseMatches(gameData, definitionId, r, c); });
|
||||
});
|
||||
if (combinator === 'ou') {
|
||||
return rows.filter((r) => clauses.some((c) => forgeClauseMatches(gameData, definitionId, r, c)));
|
||||
}
|
||||
return rows.filter(function (r) {
|
||||
return clauses.every(function (c) { return forgeClauseMatches(gameData, definitionId, r, c); });
|
||||
});
|
||||
return rows.filter((r) => clauses.every((c) => forgeClauseMatches(gameData, definitionId, r, c)));
|
||||
}
|
||||
|
||||
// Port de filter_repeater_rows — filtre appliqué par le widget Répéteur
|
||||
// lui-même (attrs._filtres_json/_filtre_combinateur, avec repli sur
|
||||
// l'ancien format à 2 filtres fixes _filtre(2)_champ/...).
|
||||
function forgeFilterRepeaterRows(gameData, definitionId, rows, attrs) {
|
||||
var decoded = forgeDecodeClauses(attrs, "_filtres_json", "_filtre_combinateur", "_filtre");
|
||||
const decoded = forgeDecodeClauses(attrs, '_filtres_json', '_filtre_combinateur', '_filtre');
|
||||
return forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator);
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeDecodeClauses, forgeResolveVariablePath,
|
||||
forgeResolveFilterValue, forgeClauseMatches, forgeFilterRowsByClauses, forgeFilterRepeaterRows,
|
||||
forgeDecodeClauses,
|
||||
forgeResolveVariablePath,
|
||||
forgeResolveFilterValue,
|
||||
forgeClauseMatches,
|
||||
forgeFilterRowsByClauses,
|
||||
forgeFilterRepeaterRows,
|
||||
};
|
||||
global.compareValues = global.compareValues || require('../conditions.js').compareValues;
|
||||
}
|
||||
|
||||
@@ -15,23 +15,23 @@
|
||||
// static/js/play/actions.js, appelée par l'action "Modifier un objet de
|
||||
// scène" — comportement identique en ligne et hors ligne, rien à porter).
|
||||
function forgeRecomputeElementsForScreenOffline(gameData, screen) {
|
||||
var elements = screen.elements || [];
|
||||
var childrenMap = {};
|
||||
elements.forEach(function (el) {
|
||||
const elements = screen.elements || [];
|
||||
const childrenMap = {};
|
||||
elements.forEach((el) => {
|
||||
if (el.parent_id) {
|
||||
if (!childrenMap[el.parent_id]) childrenMap[el.parent_id] = [];
|
||||
childrenMap[el.parent_id].push(el);
|
||||
}
|
||||
});
|
||||
var playCtx = { _forge_play_mode: true };
|
||||
elements.forEach(function (el) {
|
||||
const playCtx = { _forge_play_mode: true };
|
||||
elements.forEach((el) => {
|
||||
el.rendered_html = forgeRenderElementHtml(el, gameData, childrenMap, playCtx);
|
||||
});
|
||||
}
|
||||
|
||||
function forgeRecomputeFullPayloadOffline(gameData) {
|
||||
(gameData.screens || []).forEach(function (s) {
|
||||
if (s.kind !== "jeu_2d") forgeRecomputeElementsForScreenOffline(gameData, s);
|
||||
(gameData.screens || []).forEach((s) => {
|
||||
if (s.kind !== 'jeu_2d') forgeRecomputeElementsForScreenOffline(gameData, s);
|
||||
});
|
||||
return gameData;
|
||||
}
|
||||
|
||||
@@ -15,68 +15,83 @@
|
||||
// play.html) et sont routés depuis forgeRenderElementHtml() ci-dessous
|
||||
// via forgeSpecialRenderer().
|
||||
|
||||
var FORGE_LAYOUT_CAPABLE_WIDGETS = ["conteneur", "repeteur_donnees", "liste", "liste_numerotee", "groupe_champs", "tableau"];
|
||||
const FORGE_LAYOUT_CAPABLE_WIDGETS = ['conteneur', 'repeteur_donnees', 'liste', 'liste_numerotee', 'groupe_champs', 'tableau'];
|
||||
|
||||
// Widgets à balise simple portés à l'étape 3 — {tag, hasContent,
|
||||
// selfClosing, fixedAttrs} — port direct de screens/widgets/registry.py
|
||||
// (seuls les champs utiles au RENDU, pas au panneau de propriétés de
|
||||
// l'éditeur, qui ne concerne que le port serveur).
|
||||
var FORGE_SIMPLE_WIDGETS = {
|
||||
texte: { tag: "p", hasContent: true },
|
||||
titre: { tag: null, hasContent: true, fixedAttrs: { class: "title" } },
|
||||
bouton: { tag: "button", hasContent: true, fixedAttrs: { class: "button" } },
|
||||
lien: { tag: "a", hasContent: true },
|
||||
image: { tag: "img", hasContent: false, selfClosing: true },
|
||||
video: { tag: "video", hasContent: false },
|
||||
champ_texte: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "text", class: "input" } },
|
||||
champ_email: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "email", class: "input" } },
|
||||
champ_mot_de_passe: { tag: "input", hasContent: false, selfClosing: true, fixedAttrs: { type: "password", class: "input" } },
|
||||
zone_texte: { tag: "textarea", hasContent: false, fixedAttrs: { class: "textarea" } },
|
||||
liste: { tag: "ul", hasContent: true },
|
||||
liste_numerotee: { tag: "ol", hasContent: true },
|
||||
separateur: { tag: "hr", hasContent: false, selfClosing: true },
|
||||
conteneur: { tag: "div", hasContent: true, fixedAttrs: { class: "box" } },
|
||||
const FORGE_SIMPLE_WIDGETS = {
|
||||
texte: { tag: 'p', hasContent: true },
|
||||
titre: { tag: null, hasContent: true, fixedAttrs: { class: 'title' } },
|
||||
bouton: { tag: 'button', hasContent: true, fixedAttrs: { class: 'button' } },
|
||||
lien: { tag: 'a', hasContent: true },
|
||||
image: { tag: 'img', hasContent: false, selfClosing: true },
|
||||
video: { tag: 'video', hasContent: false },
|
||||
champ_texte: {
|
||||
tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'text', class: 'input' },
|
||||
},
|
||||
champ_email: {
|
||||
tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'email', class: 'input' },
|
||||
},
|
||||
champ_mot_de_passe: {
|
||||
tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'password', class: 'input' },
|
||||
},
|
||||
zone_texte: { tag: 'textarea', hasContent: false, fixedAttrs: { class: 'textarea' } },
|
||||
liste: { tag: 'ul', hasContent: true },
|
||||
liste_numerotee: { tag: 'ol', hasContent: true },
|
||||
separateur: { tag: 'hr', hasContent: false, selfClosing: true },
|
||||
conteneur: { tag: 'div', hasContent: true, fixedAttrs: { class: 'box' } },
|
||||
};
|
||||
|
||||
// widget -> clé de special_render (voir screens/widgets/registry.py).
|
||||
var FORGE_SPECIAL_RENDER_BY_WIDGET = {
|
||||
case_a_cocher: "checkbox_label", bouton_radio: "checkbox_label", liste_deroulante: "select_options",
|
||||
groupe_champs: "fieldset_legend", tableau: "table", repeteur_donnees: "repeater",
|
||||
jauge: "jauge", superposition: "overlay", onglets: "onglets", icone: "icone", personnage: "personnage",
|
||||
const FORGE_SPECIAL_RENDER_BY_WIDGET = {
|
||||
case_a_cocher: 'checkbox_label',
|
||||
bouton_radio: 'checkbox_label',
|
||||
liste_deroulante: 'select_options',
|
||||
groupe_champs: 'fieldset_legend',
|
||||
tableau: 'table',
|
||||
repeteur_donnees: 'repeater',
|
||||
jauge: 'jauge',
|
||||
superposition: 'overlay',
|
||||
onglets: 'onglets',
|
||||
icone: 'icone',
|
||||
personnage: 'personnage',
|
||||
};
|
||||
|
||||
// fixed_attrs des widgets à special_render (voir screens/widgets/registry.py)
|
||||
// — seul "tableau" en a un, tous les autres n'en ont aucun (contrairement
|
||||
// à "conteneur", qui ne doit JAMAIS servir de repli pour eux).
|
||||
var FORGE_SPECIAL_WIDGET_FIXED_ATTRS = { tableau: { class: "table" } };
|
||||
const FORGE_SPECIAL_WIDGET_FIXED_ATTRS = { tableau: { class: 'table' } };
|
||||
|
||||
function forgeHtmlEscape(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// Port de apply_ctx.py — {{champ}} remplacé par la valeur de la ligne en
|
||||
// cours (contenu ET attributs), à l'intérieur d'un Répéteur.
|
||||
function forgeApplyCtx(text, ctx) {
|
||||
if (!ctx || !text) return text;
|
||||
var result = text;
|
||||
Object.keys(ctx).forEach(function (k) {
|
||||
if (k === "_forge_play_mode") return; // indicateur interne, pas un {{champ}}
|
||||
result = result.split("{{" + k + "}}").join(ctx[k]);
|
||||
let result = text;
|
||||
Object.keys(ctx).forEach((k) => {
|
||||
if (k === '_forge_play_mode') return; // indicateur interne, pas un {{champ}}
|
||||
result = result.split(`{{${k}}}`).join(ctx[k]);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function forgeAutoId(el) {
|
||||
return "elt-" + el.id;
|
||||
return `elt-${el.id}`;
|
||||
}
|
||||
|
||||
// Port de parent_flex_direction.py.
|
||||
function forgeParentFlexDirection(parentEl) {
|
||||
var style = (parentEl && parentEl.style) || {};
|
||||
if (style.display !== "flex") return null;
|
||||
return style["flex-direction"] || "row";
|
||||
const style = (parentEl && parentEl.style) || {};
|
||||
if (style.display !== 'flex') return null;
|
||||
return style['flex-direction'] || 'row';
|
||||
}
|
||||
|
||||
// Port de style_string.py — même logique de taille par défaut (100% hors
|
||||
@@ -84,34 +99,34 @@ function forgeParentFlexDirection(parentEl) {
|
||||
// enfant), même garde-fous (transform réservé au cadre extérieur pour un
|
||||
// élément posé directement sur l'écran, 0px ignoré = pas de surcharge).
|
||||
function forgeStyleString(el, parentFlexDirection, widgetKey) {
|
||||
var style = Object.assign({}, el.style || {});
|
||||
if (FORGE_LAYOUT_CAPABLE_WIDGETS.indexOf(widgetKey) !== -1 && !("display" in style)) {
|
||||
style.display = "flex";
|
||||
if (!("flex-direction" in style)) style["flex-direction"] = "column";
|
||||
const style = { ...el.style || {} };
|
||||
if (FORGE_LAYOUT_CAPABLE_WIDGETS.indexOf(widgetKey) !== -1 && !('display' in style)) {
|
||||
style.display = 'flex';
|
||||
if (!('flex-direction' in style)) style['flex-direction'] = 'column';
|
||||
}
|
||||
var isChild = !!el.parent_id;
|
||||
var baseStyle;
|
||||
const isChild = !!el.parent_id;
|
||||
let baseStyle;
|
||||
if (isChild) {
|
||||
var widthDefault = (parentFlexDirection === "row" || parentFlexDirection === "row-reverse") ? "auto" : "100%";
|
||||
baseStyle = "width:" + widthDefault + "; height:auto; box-sizing:border-box; margin:0;";
|
||||
const widthDefault = (parentFlexDirection === 'row' || parentFlexDirection === 'row-reverse') ? 'auto' : '100%';
|
||||
baseStyle = `width:${widthDefault}; height:auto; box-sizing:border-box; margin:0;`;
|
||||
} else {
|
||||
baseStyle = "width:100%; height:100%; box-sizing:border-box; margin:0;";
|
||||
baseStyle = 'width:100%; height:100%; box-sizing:border-box; margin:0;';
|
||||
}
|
||||
var hasSizeOverride = false;
|
||||
var parts = [];
|
||||
Object.keys(style).forEach(function (k) {
|
||||
var v = style[k];
|
||||
if (v === null || v === undefined || v === "") return;
|
||||
if (typeof v === "object") return; // filet de sécurité, voir style_string.py
|
||||
if (k === "transform" && !isChild) return;
|
||||
var strV = String(v).trim();
|
||||
if ((k === "width" || k === "height") && (strV === "0px" || strV === "0")) return;
|
||||
if (k === "width" || k === "height") hasSizeOverride = true;
|
||||
parts.push(k + ":" + v);
|
||||
let hasSizeOverride = false;
|
||||
const parts = [];
|
||||
Object.keys(style).forEach((k) => {
|
||||
const v = style[k];
|
||||
if (v === null || v === undefined || v === '') return;
|
||||
if (typeof v === 'object') return; // filet de sécurité, voir style_string.py
|
||||
if (k === 'transform' && !isChild) return;
|
||||
const strV = String(v).trim();
|
||||
if ((k === 'width' || k === 'height') && (strV === '0px' || strV === '0')) return;
|
||||
if (k === 'width' || k === 'height') hasSizeOverride = true;
|
||||
parts.push(`${k}:${v}`);
|
||||
});
|
||||
if (hasSizeOverride) parts.push("flex-shrink:0");
|
||||
var userStyle = parts.join("; ");
|
||||
return baseStyle + (userStyle ? " " + userStyle + ";" : "");
|
||||
if (hasSizeOverride) parts.push('flex-shrink:0');
|
||||
const userStyle = parts.join('; ');
|
||||
return baseStyle + (userStyle ? ` ${userStyle};` : '');
|
||||
}
|
||||
|
||||
// Port de attr_string.py. "alt" est le seul attribut dont la chaîne VIDE
|
||||
@@ -120,69 +135,67 @@ function forgeStyleString(el, parentFlexDirection, widgetKey) {
|
||||
// lecteur d'écran) — tous les autres attributs vides sont omis comme
|
||||
// avant (une valeur vide y équivaut à "non réglé").
|
||||
function forgeAttrString(attrs, extraStyle) {
|
||||
var parts = ['style="' + forgeHtmlEscape(extraStyle) + '"'];
|
||||
Object.keys(attrs).forEach(function (k) {
|
||||
var v = attrs[k];
|
||||
const parts = [`style="${forgeHtmlEscape(extraStyle)}"`];
|
||||
Object.keys(attrs).forEach((k) => {
|
||||
const v = attrs[k];
|
||||
if (v === null || v === undefined || v === false) return;
|
||||
if (v === "" && k !== "alt") return;
|
||||
parts.push(v === true ? forgeHtmlEscape(k) : forgeHtmlEscape(k) + '="' + forgeHtmlEscape(v) + '"');
|
||||
if (v === '' && k !== 'alt') return;
|
||||
parts.push(v === true ? forgeHtmlEscape(k) : `${forgeHtmlEscape(k)}="${forgeHtmlEscape(v)}"`);
|
||||
});
|
||||
return parts.join(" ");
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
// Port de visible_attrs.py.
|
||||
function forgeVisibleAttrs(el, meta, ctx) {
|
||||
var rawAttrs = el.attributes || {};
|
||||
var attrs = {};
|
||||
Object.keys(rawAttrs).forEach(function (k) {
|
||||
if (k.indexOf("_") === 0) return;
|
||||
var v = rawAttrs[k];
|
||||
attrs[k] = typeof v === "string" ? forgeApplyCtx(v, ctx) : v;
|
||||
const rawAttrs = el.attributes || {};
|
||||
const attrs = {};
|
||||
Object.keys(rawAttrs).forEach((k) => {
|
||||
if (k.indexOf('_') === 0) return;
|
||||
const v = rawAttrs[k];
|
||||
attrs[k] = typeof v === 'string' ? forgeApplyCtx(v, ctx) : v;
|
||||
});
|
||||
Object.assign(attrs, meta.fixedAttrs || {});
|
||||
var extraClasses = Object.keys(rawAttrs)
|
||||
.filter(function (k) { return k.indexOf("_class_") === 0 && rawAttrs[k]; })
|
||||
.map(function (k) { return rawAttrs[k]; });
|
||||
const extraClasses = Object.keys(rawAttrs)
|
||||
.filter((k) => k.indexOf('_class_') === 0 && rawAttrs[k])
|
||||
.map((k) => rawAttrs[k]);
|
||||
if (extraClasses.length) {
|
||||
var baseClass = attrs.class || "";
|
||||
attrs.class = (baseClass ? [baseClass] : []).concat(extraClasses).join(" ");
|
||||
const baseClass = attrs.class || '';
|
||||
attrs.class = (baseClass ? [baseClass] : []).concat(extraClasses).join(' ');
|
||||
}
|
||||
if (!("id" in attrs)) attrs.id = forgeAutoId(el);
|
||||
attrs["data-element-id"] = el.id;
|
||||
if (el.parent_id) attrs["data-anim-target"] = el.id;
|
||||
if (!('id' in attrs)) attrs.id = forgeAutoId(el);
|
||||
attrs['data-element-id'] = el.id;
|
||||
if (el.parent_id) attrs['data-anim-target'] = el.id;
|
||||
return attrs;
|
||||
}
|
||||
|
||||
// Port de render_children.py.
|
||||
function forgeRenderChildren(el, gameData, childrenMap, ctx) {
|
||||
var kids = (childrenMap && childrenMap[el.id]) || [];
|
||||
var flexDir = forgeParentFlexDirection(el);
|
||||
return kids.map(function (c) {
|
||||
return forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir);
|
||||
}).join("");
|
||||
const kids = (childrenMap && childrenMap[el.id]) || [];
|
||||
const flexDir = forgeParentFlexDirection(el);
|
||||
return kids.map((c) => forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir)).join('');
|
||||
}
|
||||
|
||||
// Port de resolve_bound_row.py (_resolve_bound_row_ctx) — "Donnée liée"
|
||||
// d'un Texte/Titre : retrouve LA ligne qui correspond aux conditions
|
||||
// réglées (moteur partagé avec le Répéteur, voir filter-repeater-rows.js).
|
||||
function forgeResolveBoundRowCtx(gameData, attrs) {
|
||||
var definitionId = attrs._data_definition_id;
|
||||
let definitionId = attrs._data_definition_id;
|
||||
if (!definitionId) return null;
|
||||
definitionId = String(definitionId);
|
||||
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
if (!fieldsMetaForDef) return null;
|
||||
var rows = (gameData.data && gameData.data[definitionId]) || [];
|
||||
var decoded = forgeDecodeClauses(attrs, "_data_filtres_json", "_data_filtre_combinateur", "_data_filtre");
|
||||
let rows = (gameData.data && gameData.data[definitionId]) || [];
|
||||
const decoded = forgeDecodeClauses(attrs, '_data_filtres_json', '_data_filtre_combinateur', '_data_filtre');
|
||||
rows = forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator);
|
||||
if (!rows.length) return null;
|
||||
var row = rows[0];
|
||||
var ctx = {};
|
||||
fieldsMetaForDef.forEach(function (f) {
|
||||
const row = rows[0];
|
||||
const ctx = {};
|
||||
fieldsMetaForDef.forEach((f) => {
|
||||
// row[f.name] : gameData.data est déjà indexé par le nom D'AFFICHAGE
|
||||
// du champ (voir full_game_payload.py), jamais par sa colonne SQL —
|
||||
// voir filter-repeater-rows.js pour le détail de ce piège.
|
||||
var v = row[f.name];
|
||||
ctx[f.name] = (v === null || v === undefined || v === "") ? "" : String(v);
|
||||
const v = row[f.name];
|
||||
ctx[f.name] = (v === null || v === undefined || v === '') ? '' : String(v);
|
||||
});
|
||||
return ctx;
|
||||
}
|
||||
@@ -193,21 +206,19 @@ function forgeResolveBoundRowCtx(gameData, attrs) {
|
||||
// une fois à l'export, voir full_game_payload.py) plutôt que depuis
|
||||
// d'éventuels enfants copiés en base.
|
||||
function forgeRenderElementTypeChildren(el, gameData, ctx) {
|
||||
var templateTree = gameData.element_type_templates && gameData.element_type_templates[String(el.element_type_id)];
|
||||
const templateTree = gameData.element_type_templates && gameData.element_type_templates[String(el.element_type_id)];
|
||||
if (!templateTree || !templateTree.top.length) return [null, false];
|
||||
var isOverlayOnly = templateTree.top.every(function (c) { return c.widget === "superposition"; });
|
||||
var top = templateTree.top;
|
||||
const isOverlayOnly = templateTree.top.every((c) => c.widget === 'superposition');
|
||||
let { top } = templateTree;
|
||||
if (isOverlayOnly) {
|
||||
var overlayRoot = Object.assign({}, top[0]);
|
||||
var newStyle = {};
|
||||
Object.keys(overlayRoot.style || {}).forEach(function (k) { if (k !== "display") newStyle[k] = overlayRoot.style[k]; });
|
||||
const overlayRoot = { ...top[0] };
|
||||
const newStyle = {};
|
||||
Object.keys(overlayRoot.style || {}).forEach((k) => { if (k !== 'display') newStyle[k] = overlayRoot.style[k]; });
|
||||
overlayRoot.style = newStyle;
|
||||
top = top.slice();
|
||||
top[0] = overlayRoot;
|
||||
}
|
||||
var html = top.map(function (c) {
|
||||
return forgeRenderElementHtml(c, gameData, templateTree.children_map, ctx);
|
||||
}).join("");
|
||||
const html = top.map((c) => forgeRenderElementHtml(c, gameData, templateTree.children_map, ctx)).join('');
|
||||
return [html, isOverlayOnly];
|
||||
}
|
||||
|
||||
@@ -220,16 +231,16 @@ function forgeRenderElementTypeChildren(el, gameData, ctx) {
|
||||
// enfants/onglets/superposition).
|
||||
function forgeSpecialRenderer(specialKey) {
|
||||
switch (specialKey) {
|
||||
case "checkbox_label": return forgeRenderCheckboxOrRadio;
|
||||
case "select_options": return forgeRenderSelect;
|
||||
case "fieldset_legend": return forgeRenderFieldset;
|
||||
case "table": return forgeRenderTable;
|
||||
case "repeater": return forgeRenderRepeater;
|
||||
case "jauge": return forgeRenderJauge;
|
||||
case "overlay": return forgeRenderOverlay;
|
||||
case "onglets": return forgeRenderOnglets;
|
||||
case "icone": return forgeRenderIcone;
|
||||
case "personnage": return forgeRenderPersonnage;
|
||||
case 'checkbox_label': return forgeRenderCheckboxOrRadio;
|
||||
case 'select_options': return forgeRenderSelect;
|
||||
case 'fieldset_legend': return forgeRenderFieldset;
|
||||
case 'table': return forgeRenderTable;
|
||||
case 'repeater': return forgeRenderRepeater;
|
||||
case 'jauge': return forgeRenderJauge;
|
||||
case 'overlay': return forgeRenderOverlay;
|
||||
case 'onglets': return forgeRenderOnglets;
|
||||
case 'icone': return forgeRenderIcone;
|
||||
case 'personnage': return forgeRenderPersonnage;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -238,77 +249,78 @@ function forgeSpecialRenderer(specialKey) {
|
||||
// pas hors ligne (une seule partie en mémoire, pas de base de données) :
|
||||
// tout ce qui en dépendait côté serveur lit désormais `gameData`.
|
||||
function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var widgetKey = el.widget;
|
||||
var simple = FORGE_SIMPLE_WIDGETS[widgetKey];
|
||||
var specialKey = FORGE_SPECIAL_RENDER_BY_WIDGET[widgetKey];
|
||||
var attrsRaw = el.attributes || {};
|
||||
const widgetKey = el.widget;
|
||||
const simple = FORGE_SIMPLE_WIDGETS[widgetKey];
|
||||
const specialKey = FORGE_SPECIAL_RENDER_BY_WIDGET[widgetKey];
|
||||
const attrsRaw = el.attributes || {};
|
||||
|
||||
var hasVisibilityCondition = attrsRaw._visibilite_cond_mode === "variable" || attrsRaw._visibilite_cond_mode === "objet";
|
||||
var playMode = !!((ctx || {})._forge_play_mode);
|
||||
const hasVisibilityCondition = attrsRaw._visibilite_cond_mode === 'variable' || attrsRaw._visibilite_cond_mode === 'objet';
|
||||
const playMode = !!((ctx || {})._forge_play_mode);
|
||||
if (playMode && hasVisibilityCondition && !forgeIsElementVisibleOffline(gameData, attrsRaw, ctx)) {
|
||||
return '<div class="visibilityGated" data-element-id="' + el.id + '" style="display:none;"></div>';
|
||||
return `<div class="visibilityGated" data-element-id="${el.id}" style="display:none;"></div>`;
|
||||
}
|
||||
var hasDataBinding = !!attrsRaw._data_definition_id;
|
||||
var mark = function (htmlStr) {
|
||||
var markers = "";
|
||||
if (hasVisibilityCondition) markers += "<!--visibilityGated-->";
|
||||
if (hasDataBinding) markers += "<!--dataBound-->";
|
||||
const hasDataBinding = !!attrsRaw._data_definition_id;
|
||||
const mark = function (htmlStr) {
|
||||
let markers = '';
|
||||
if (hasVisibilityCondition) markers += '<!--visibilityGated-->';
|
||||
if (hasDataBinding) markers += '<!--dataBound-->';
|
||||
return htmlStr + markers;
|
||||
};
|
||||
|
||||
var boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw);
|
||||
if (boundCtx) ctx = Object.assign({}, ctx || {}, boundCtx);
|
||||
const boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw);
|
||||
if (boundCtx) ctx = { ...ctx || {}, ...boundCtx };
|
||||
|
||||
// meta d'un widget à special_render : fixedAttrs propre (voir
|
||||
// screens/widgets/registry.py — seul "tableau" en a un, "class":"table"),
|
||||
// jamais celui de "conteneur" ("class":"box" polluerait tout le reste).
|
||||
var meta = simple || { fixedAttrs: FORGE_SPECIAL_WIDGET_FIXED_ATTRS[widgetKey] || {} };
|
||||
const meta = simple || { fixedAttrs: FORGE_SPECIAL_WIDGET_FIXED_ATTRS[widgetKey] || {} };
|
||||
if (specialKey) {
|
||||
var renderer = forgeSpecialRenderer(specialKey);
|
||||
const renderer = forgeSpecialRenderer(specialKey);
|
||||
return mark(renderer(el, meta, gameData, childrenMap, ctx, parentFlexDirection));
|
||||
}
|
||||
|
||||
var tag = meta.tag;
|
||||
let { tag } = meta;
|
||||
if (tag === null) { // widget à balise dynamique (ex: "titre" -> h1/h2/h3)
|
||||
var niveau = attrsRaw._niveau || "2";
|
||||
tag = "h" + niveau;
|
||||
const niveau = attrsRaw._niveau || '2';
|
||||
tag = `h${niveau}`;
|
||||
}
|
||||
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var style = forgeStyleString(el, parentFlexDirection, widgetKey);
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const style = forgeStyleString(el, parentFlexDirection, widgetKey);
|
||||
|
||||
var hoverText = attrsRaw._survol_texte;
|
||||
if (hoverText) attrs["data-hover-text"] = forgeApplyCtx(hoverText, ctx);
|
||||
const hoverText = attrsRaw._survol_texte;
|
||||
if (hoverText) attrs['data-hover-text'] = forgeApplyCtx(hoverText, ctx);
|
||||
|
||||
// alt (RGAA/WCAG 1.1.1) : le widget "image" n'a pas de champ de
|
||||
// description dédié dans l'éditeur — alt="" (décoratif) par défaut
|
||||
// plutôt qu'aucun attribut du tout, jamais le nom de fichier brut par
|
||||
// défaut du navigateur. Ne touche pas à un alt déjà posé (pass-through
|
||||
// via forgeVisibleAttrs, si l'auteur en règle un un jour).
|
||||
if (tag === "img" && !("alt" in attrs)) attrs.alt = "";
|
||||
if (tag === 'img' && !('alt' in attrs)) attrs.alt = '';
|
||||
|
||||
if (meta.selfClosing) {
|
||||
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">");
|
||||
return mark(`<${tag} ${forgeAttrString(attrs, style)}>`);
|
||||
}
|
||||
|
||||
var content = forgeApplyCtx(el.content || "", ctx);
|
||||
var childResult = el.element_type_id ? forgeRenderElementTypeChildren(el, gameData, ctx) : [null, false];
|
||||
var childHtml = childResult[0], isOverlayTemplate = childResult[1];
|
||||
const content = forgeApplyCtx(el.content || '', ctx);
|
||||
const childResult = el.element_type_id ? forgeRenderElementTypeChildren(el, gameData, ctx) : [null, false];
|
||||
let childHtml = childResult[0]; const
|
||||
isOverlayTemplate = childResult[1];
|
||||
if (childHtml === null) {
|
||||
childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
|
||||
} else if (isOverlayTemplate) {
|
||||
delete attrs.class;
|
||||
return mark("<div " + forgeAttrString(attrs, style) + ">" + childHtml + "</div>");
|
||||
return mark(`<div ${forgeAttrString(attrs, style)}>${childHtml}</div>`);
|
||||
}
|
||||
|
||||
if (tag === "ul" || tag === "ol") {
|
||||
var items = content.split("\n").map(function (l) { return l.trim(); }).filter(Boolean);
|
||||
var inner = items.map(function (item) { return "<li>" + forgeHtmlEscape(item) + "</li>"; }).join("") + childHtml;
|
||||
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">" + inner + "</" + tag + ">");
|
||||
if (tag === 'ul' || tag === 'ol') {
|
||||
const items = content.split('\n').map((l) => l.trim()).filter(Boolean);
|
||||
const inner = items.map((item) => `<li>${forgeHtmlEscape(item)}</li>`).join('') + childHtml;
|
||||
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${inner}</${tag}>`);
|
||||
}
|
||||
|
||||
var safeContent = content ? forgeHtmlEscape(content).replace(/\n/g, "<br>") : "";
|
||||
return mark("<" + tag + " " + forgeAttrString(attrs, style) + ">" + safeContent + childHtml + "</" + tag + ">");
|
||||
const safeContent = content ? forgeHtmlEscape(content).replace(/\n/g, '<br>') : '';
|
||||
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${safeContent}${childHtml}</${tag}>`);
|
||||
}
|
||||
|
||||
// NOTE (node:test uniquement) : forgeRenderElementHtml() référence, pour
|
||||
@@ -324,14 +336,24 @@ function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirect
|
||||
// fonctions ci-dessus pour leurs propres enfants/onglets/superposition).
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeHtmlEscape, forgeApplyCtx, forgeAutoId, forgeParentFlexDirection, forgeStyleString,
|
||||
forgeAttrString, forgeVisibleAttrs, forgeRenderChildren, forgeRenderElementHtml,
|
||||
forgeResolveBoundRowCtx, forgeRenderElementTypeChildren, forgeSpecialRenderer,
|
||||
FORGE_SIMPLE_WIDGETS, FORGE_SPECIAL_RENDER_BY_WIDGET,
|
||||
forgeHtmlEscape,
|
||||
forgeApplyCtx,
|
||||
forgeAutoId,
|
||||
forgeParentFlexDirection,
|
||||
forgeStyleString,
|
||||
forgeAttrString,
|
||||
forgeVisibleAttrs,
|
||||
forgeRenderChildren,
|
||||
forgeRenderElementHtml,
|
||||
forgeResolveBoundRowCtx,
|
||||
forgeRenderElementTypeChildren,
|
||||
forgeSpecialRenderer,
|
||||
FORGE_SIMPLE_WIDGETS,
|
||||
FORGE_SPECIAL_RENDER_BY_WIDGET,
|
||||
};
|
||||
global.forgeIsElementVisibleOffline = global.forgeIsElementVisibleOffline
|
||||
|| require('./visibility-condition.js').forgeIsElementVisibleOffline;
|
||||
var filterMod = require('./filter-repeater-rows.js');
|
||||
const filterMod = require('./filter-repeater-rows.js');
|
||||
global.forgeResolveFilterValue = global.forgeResolveFilterValue || filterMod.forgeResolveFilterValue;
|
||||
global.forgeResolveVariablePath = global.forgeResolveVariablePath || filterMod.forgeResolveVariablePath;
|
||||
global.forgeDecodeClauses = global.forgeDecodeClauses || filterMod.forgeDecodeClauses;
|
||||
|
||||
@@ -4,16 +4,17 @@
|
||||
// au lieu d'une requête SQL (voir _read_field_value côté serveur).
|
||||
|
||||
function forgeHexToRgb(hexColor) {
|
||||
var hex = (hexColor || "#5b8cff").replace(/^#/, "");
|
||||
if (hex.length !== 6) hex = "5b8cff";
|
||||
let hex = (hexColor || '#5b8cff').replace(/^#/, '');
|
||||
if (hex.length !== 6) hex = '5b8cff';
|
||||
return [parseInt(hex.substr(0, 2), 16), parseInt(hex.substr(2, 2), 16), parseInt(hex.substr(4, 2), 16)];
|
||||
}
|
||||
|
||||
function forgeInterpolateColor(lowHex, highHex, ratio) {
|
||||
var lo = forgeHexToRgb(lowHex), hi = forgeHexToRgb(highHex);
|
||||
var mixed = lo.map(function (c, i) { return Math.round(c + (hi[i] - c) * ratio); });
|
||||
var toHex = function (n) { return n.toString(16).padStart(2, "0"); };
|
||||
return "#" + mixed.map(toHex).join("");
|
||||
const lo = forgeHexToRgb(lowHex); const
|
||||
hi = forgeHexToRgb(highHex);
|
||||
const mixed = lo.map((c, i) => Math.round(c + (hi[i] - c) * ratio));
|
||||
const toHex = function (n) { return n.toString(16).padStart(2, '0'); };
|
||||
return `#${mixed.map(toHex).join('')}`;
|
||||
}
|
||||
|
||||
// Port de _read_field_value — repli sur la ligne la PLUS RÉCENTE si
|
||||
@@ -22,88 +23,84 @@ function forgeInterpolateColor(lowHex, highHex, ratio) {
|
||||
function forgeReadFieldValue(gameData, definitionId, rowId, fieldName) {
|
||||
if (!definitionId || !fieldName) return null;
|
||||
definitionId = String(definitionId);
|
||||
var rows = gameData.data && gameData.data[definitionId];
|
||||
const rows = gameData.data && gameData.data[definitionId];
|
||||
if (!rows || !rows.length) return null;
|
||||
var row = null;
|
||||
if (rowId) row = rows.find(function (r) { return String(r.id) === String(rowId); });
|
||||
let row = null;
|
||||
if (rowId) row = rows.find((r) => String(r.id) === String(rowId));
|
||||
if (!row) row = rows[0];
|
||||
return row[fieldName];
|
||||
}
|
||||
|
||||
function forgeRenderJauge(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var attrs = el.attributes || {};
|
||||
var nomPosition = attrs._nom_position || "dessus";
|
||||
var nomAlignement = attrs._nom_alignement || "centre";
|
||||
var nomPolice = attrs._nom_police || "inherit";
|
||||
var nomTaille = attrs._nom_taille || "12";
|
||||
var nomGras = attrs._nom_gras === "1";
|
||||
var nomItalique = attrs._nom_italique === "1";
|
||||
var flexDir = nomPosition === "cote" ? "row" : "column";
|
||||
var alignItems = nomPosition === "cote" ? "center" : "stretch";
|
||||
var style = forgeStyleString(
|
||||
el, parentFlexDirection, el.widget
|
||||
) + " display:flex; flex-direction:" + flexDir + "; align-items:" + alignItems + "; justify-content:center; gap:4px;";
|
||||
const attrs = el.attributes || {};
|
||||
const nomPosition = attrs._nom_position || 'dessus';
|
||||
const nomAlignement = attrs._nom_alignement || 'centre';
|
||||
const nomPolice = attrs._nom_police || 'inherit';
|
||||
const nomTaille = attrs._nom_taille || '12';
|
||||
const nomGras = attrs._nom_gras === '1';
|
||||
const nomItalique = attrs._nom_italique === '1';
|
||||
const flexDir = nomPosition === 'cote' ? 'row' : 'column';
|
||||
const alignItems = nomPosition === 'cote' ? 'center' : 'stretch';
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex; flex-direction:${flexDir}; align-items:${alignItems}; justify-content:center; gap:4px;`;
|
||||
|
||||
var definitionId = attrs._definition_id;
|
||||
var rowId = attrs._row_id;
|
||||
var fieldName = (attrs._champ || "").trim();
|
||||
var fieldNameNom = (attrs._champ_nom || "").trim();
|
||||
var minV = parseFloat(attrs._min); if (isNaN(minV)) minV = 0;
|
||||
var maxV = parseFloat(attrs._max); if (isNaN(maxV)) maxV = 100;
|
||||
var couleurBasse = attrs._couleur_basse || "#e05252";
|
||||
var couleurHaute = attrs._couleur_haute || "#4caf50";
|
||||
var afficherValeur = attrs._afficher_valeur === "1";
|
||||
const definitionId = attrs._definition_id;
|
||||
const rowId = attrs._row_id;
|
||||
const fieldName = (attrs._champ || '').trim();
|
||||
const fieldNameNom = (attrs._champ_nom || '').trim();
|
||||
let minV = parseFloat(attrs._min); if (Number.isNaN(minV)) minV = 0;
|
||||
let maxV = parseFloat(attrs._max); if (Number.isNaN(maxV)) maxV = 100;
|
||||
const couleurBasse = attrs._couleur_basse || '#e05252';
|
||||
const couleurHaute = attrs._couleur_haute || '#4caf50';
|
||||
const afficherValeur = attrs._afficher_valeur === '1';
|
||||
|
||||
var rawValue = fieldName ? forgeReadFieldValue(gameData, definitionId, rowId, fieldName) : null;
|
||||
var value = parseFloat(rawValue);
|
||||
if (isNaN(value)) value = minV;
|
||||
const rawValue = fieldName ? forgeReadFieldValue(gameData, definitionId, rowId, fieldName) : null;
|
||||
let value = parseFloat(rawValue);
|
||||
if (Number.isNaN(value)) value = minV;
|
||||
|
||||
var span = (maxV - minV) || 1;
|
||||
var ratio = Math.max(0, Math.min(1, (value - minV) / span));
|
||||
var percent = Math.round(ratio * 1000) / 10;
|
||||
var fillColor = forgeInterpolateColor(couleurBasse, couleurHaute, ratio);
|
||||
const span = (maxV - minV) || 1;
|
||||
const ratio = Math.max(0, Math.min(1, (value - minV) / span));
|
||||
const percent = Math.round(ratio * 1000) / 10;
|
||||
const fillColor = forgeInterpolateColor(couleurBasse, couleurHaute, ratio);
|
||||
|
||||
var labelHtml = "";
|
||||
let labelHtml = '';
|
||||
if (afficherValeur) {
|
||||
labelHtml =
|
||||
'<div style="position:absolute; inset:0; display:flex; align-items:center; ' +
|
||||
'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; ' +
|
||||
'color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">' +
|
||||
forgeHtmlEscape(rawValue !== null && rawValue !== undefined ? String(rawValue) : "—") + '</div>';
|
||||
labelHtml = '<div style="position:absolute; inset:0; display:flex; align-items:center; '
|
||||
+ 'justify-content:center; font-family:system-ui,sans-serif; font-size:12px; '
|
||||
+ `color:#e8eaf0; text-shadow:0 1px 2px rgba(0,0,0,.6);">${
|
||||
forgeHtmlEscape(rawValue !== null && rawValue !== undefined ? String(rawValue) : '—')}</div>`;
|
||||
}
|
||||
|
||||
var nameHtml = "";
|
||||
let nameHtml = '';
|
||||
if (fieldNameNom) {
|
||||
var nomValue = forgeReadFieldValue(gameData, definitionId, rowId, fieldNameNom);
|
||||
if (nomValue !== null && nomValue !== undefined && nomValue !== "") {
|
||||
var textAlign = (nomPosition === "dessus" && nomAlignement === "gauche") ? "left" : "center";
|
||||
nameHtml =
|
||||
'<div style="flex:0 0 auto; font-family:' + forgeHtmlEscape(nomPolice) + '; ' +
|
||||
'font-size:' + forgeHtmlEscape(String(nomTaille)) + 'px; ' +
|
||||
'font-weight:' + (nomGras ? "700" : "400") + '; ' +
|
||||
'font-style:' + (nomItalique ? "italic" : "normal") + '; ' +
|
||||
'color:#e8eaf0; text-align:' + textAlign + ';">' + forgeHtmlEscape(String(nomValue)) + '</div>';
|
||||
const nomValue = forgeReadFieldValue(gameData, definitionId, rowId, fieldNameNom);
|
||||
if (nomValue !== null && nomValue !== undefined && nomValue !== '') {
|
||||
const textAlign = (nomPosition === 'dessus' && nomAlignement === 'gauche') ? 'left' : 'center';
|
||||
nameHtml = `<div style="flex:0 0 auto; font-family:${forgeHtmlEscape(nomPolice)}; `
|
||||
+ `font-size:${forgeHtmlEscape(String(nomTaille))}px; `
|
||||
+ `font-weight:${nomGras ? '700' : '400'}; `
|
||||
+ `font-style:${nomItalique ? 'italic' : 'normal'}; `
|
||||
+ `color:#e8eaf0; text-align:${textAlign};">${forgeHtmlEscape(String(nomValue))}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
var extraClasses = Object.keys(attrs)
|
||||
.filter(function (k) { return k.indexOf("_class_") === 0 && attrs[k]; })
|
||||
.map(function (k) { return attrs[k]; }).join(" ");
|
||||
var barClass = forgeHtmlEscape(("progress jaugeBar " + extraClasses).trim());
|
||||
var bar =
|
||||
'<progress class="' + barClass + '" value="' + percent + '" max="100" ' +
|
||||
'style="--bulma-progress-value-background-color:' + fillColor + '; --bulma-progress-bar-background-color:#1c2130; ' +
|
||||
'margin:0; border-radius:6px; transition:background-color .3s ease;">' + percent + '%</progress>';
|
||||
var barWrap =
|
||||
'<div style="position:relative; display:flex; align-items:center; ' +
|
||||
'flex:1 1 auto; min-height:14px;">' + bar + labelHtml + '</div>';
|
||||
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '" style="' + forgeHtmlEscape(style) + '">' +
|
||||
nameHtml + barWrap + '</div>';
|
||||
const extraClasses = Object.keys(attrs)
|
||||
.filter((k) => k.indexOf('_class_') === 0 && attrs[k])
|
||||
.map((k) => attrs[k]).join(' ');
|
||||
const barClass = forgeHtmlEscape((`progress jaugeBar ${extraClasses}`).trim());
|
||||
const bar = `<progress class="${barClass}" value="${percent}" max="100" `
|
||||
+ `style="--bulma-progress-value-background-color:${fillColor}; --bulma-progress-bar-background-color:#1c2130; `
|
||||
+ `margin:0; border-radius:6px; transition:background-color .3s ease;">${percent}%</progress>`;
|
||||
const barWrap = '<div style="position:relative; display:flex; align-items:center; '
|
||||
+ `flex:1 1 auto; min-height:14px;">${bar}${labelHtml}</div>`;
|
||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)}">${
|
||||
nameHtml}${barWrap}</div>`;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeRenderJauge, forgeHexToRgb, forgeInterpolateColor, forgeReadFieldValue };
|
||||
var renderMod = require('./render-element.js');
|
||||
module.exports = {
|
||||
forgeRenderJauge, forgeHexToRgb, forgeInterpolateColor, forgeReadFieldValue,
|
||||
};
|
||||
const renderMod = require('./render-element.js');
|
||||
global.forgeStyleString = global.forgeStyleString || renderMod.forgeStyleString;
|
||||
global.forgeHtmlEscape = global.forgeHtmlEscape || renderMod.forgeHtmlEscape;
|
||||
global.forgeAutoId = global.forgeAutoId || renderMod.forgeAutoId;
|
||||
|
||||
@@ -4,47 +4,46 @@
|
||||
// dans l'éditeur (non pertinent hors ligne, qui ne sert jamais l'éditeur,
|
||||
// mais le port reste fidèle pour ne pas diverger inutilement).
|
||||
function forgeRenderOverlay(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var isPlayMode = !!(ctx && ctx._forge_play_mode);
|
||||
var attrsRaw = el.attributes || {};
|
||||
var boxColor = attrsRaw._couleur_boite || "#1f2430";
|
||||
var radius = attrsRaw._arrondi || "12";
|
||||
const isPlayMode = !!(ctx && ctx._forge_play_mode);
|
||||
const attrsRaw = el.attributes || {};
|
||||
const boxColor = attrsRaw._couleur_boite || '#1f2430';
|
||||
const radius = attrsRaw._arrondi || '12';
|
||||
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var style, wrapperClass;
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
let style; let
|
||||
wrapperClass;
|
||||
if (isPlayMode) {
|
||||
var baseOverlayStyle =
|
||||
"position:fixed; inset:0; z-index:9999; display:flex; " +
|
||||
"align-items:center; justify-content:center; background:rgba(0,0,0,0.6);";
|
||||
style = baseOverlayStyle + " " + forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
wrapperClass = "modal is-active";
|
||||
const baseOverlayStyle = 'position:fixed; inset:0; z-index:9999; display:flex; '
|
||||
+ 'align-items:center; justify-content:center; background:rgba(0,0,0,0.6);';
|
||||
style = `${baseOverlayStyle} ${forgeStyleString(el, parentFlexDirection, el.widget)}`;
|
||||
wrapperClass = 'modal is-active';
|
||||
} else {
|
||||
style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
wrapperClass = "";
|
||||
wrapperClass = '';
|
||||
}
|
||||
|
||||
var attrParts = ['style="' + forgeHtmlEscape(style) + '"'];
|
||||
Object.keys(attrs).forEach(function (k) {
|
||||
var v = attrs[k];
|
||||
if (v === null || v === undefined || v === "" || v === false) return;
|
||||
attrParts.push(v === true ? forgeHtmlEscape(k) : forgeHtmlEscape(k) + '="' + forgeHtmlEscape(v) + '"');
|
||||
const attrParts = [`style="${forgeHtmlEscape(style)}"`];
|
||||
Object.keys(attrs).forEach((k) => {
|
||||
const v = attrs[k];
|
||||
if (v === null || v === undefined || v === '' || v === false) return;
|
||||
attrParts.push(v === true ? forgeHtmlEscape(k) : `${forgeHtmlEscape(k)}="${forgeHtmlEscape(v)}"`);
|
||||
});
|
||||
|
||||
var boxStyle =
|
||||
"background:" + forgeHtmlEscape(String(boxColor)) + "; border-radius:" + forgeHtmlEscape(String(radius)) + "px; " +
|
||||
"color:#e8eaf0; padding:24px; width:100%; max-width:min(560px, 90%); max-height:90%; overflow:auto; box-sizing:border-box;";
|
||||
var childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
|
||||
var wrapperClassAttr = wrapperClass ? 'class="' + wrapperClass + '" ' : "";
|
||||
const boxStyle = `background:${forgeHtmlEscape(String(boxColor))}; border-radius:${forgeHtmlEscape(String(radius))}px; `
|
||||
+ 'color:#e8eaf0; padding:24px; width:100%; max-width:min(560px, 90%); max-height:90%; overflow:auto; box-sizing:border-box;';
|
||||
const childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
|
||||
const wrapperClassAttr = wrapperClass ? `class="${wrapperClass}" ` : '';
|
||||
return (
|
||||
'<div ' + wrapperClassAttr + attrParts.join(" ") + '>' +
|
||||
'<div class="box forgeOverlayBox" style="' + forgeHtmlEscape(boxStyle) + '">' + childHtml + '</div>' +
|
||||
'</div>'
|
||||
`<div ${wrapperClassAttr}${attrParts.join(' ')}>`
|
||||
+ `<div class="box forgeOverlayBox" style="${forgeHtmlEscape(boxStyle)}">${childHtml}</div>`
|
||||
+ '</div>'
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeRenderOverlay };
|
||||
var renderMod = require('./render-element.js');
|
||||
["forgeVisibleAttrs", "forgeStyleString", "forgeHtmlEscape", "forgeRenderChildren"].forEach(function (name) {
|
||||
const renderMod = require('./render-element.js');
|
||||
['forgeVisibleAttrs', 'forgeStyleString', 'forgeHtmlEscape', 'forgeRenderChildren'].forEach((name) => {
|
||||
global[name] = global[name] || renderMod[name];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,75 +4,71 @@
|
||||
// d'un écran-modèle d'élément de jeu réutilisable, précalculé une fois à
|
||||
// l'export) au lieu de screens/element_types/load_template_tree.py.
|
||||
function forgeRenderRepeater(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " overflow:auto;";
|
||||
var attrsRaw = el.attributes || {};
|
||||
var definitionId = attrsRaw._definition_id;
|
||||
var definitionAttr = definitionId ? ' data-definition-id="' + forgeHtmlEscape(String(definitionId)) + '"' : "";
|
||||
var templateTypeId = attrsRaw._template_type_id;
|
||||
var template = forgeApplyCtx(el.content || "", ctx);
|
||||
var children = (childrenMap && childrenMap[el.id]) || [];
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} overflow:auto;`;
|
||||
const attrsRaw = el.attributes || {};
|
||||
let definitionId = attrsRaw._definition_id;
|
||||
const definitionAttr = definitionId ? ` data-definition-id="${forgeHtmlEscape(String(definitionId))}"` : '';
|
||||
const templateTypeId = attrsRaw._template_type_id;
|
||||
const template = forgeApplyCtx(el.content || '', ctx);
|
||||
const children = (childrenMap && childrenMap[el.id]) || [];
|
||||
|
||||
if (!definitionId) {
|
||||
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '" style="' + forgeHtmlEscape(style) +
|
||||
'"><em>Répéteur : choisis un objet de données dans les propriétés.</em></div>';
|
||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
|
||||
}"><em>Répéteur : choisis un objet de données dans les propriétés.</em></div>`;
|
||||
}
|
||||
definitionId = String(definitionId);
|
||||
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
if (!fieldsMetaForDef) {
|
||||
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '" style="' + forgeHtmlEscape(style) +
|
||||
'"><em>Objet introuvable — a-t-il été supprimé ?</em></div>';
|
||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}" style="${forgeHtmlEscape(style)
|
||||
}"><em>Objet introuvable — a-t-il été supprimé ?</em></div>`;
|
||||
}
|
||||
|
||||
var rows = gameData.data[definitionId] || [];
|
||||
let rows = gameData.data[definitionId] || [];
|
||||
rows = forgeFilterRepeaterRows(gameData, definitionId, rows, attrsRaw);
|
||||
|
||||
var templateTree = null;
|
||||
let templateTree = null;
|
||||
if (templateTypeId) {
|
||||
templateTree = gameData.element_type_templates && gameData.element_type_templates[String(templateTypeId)];
|
||||
}
|
||||
|
||||
var items = rows.map(function (row) {
|
||||
var rowCtx = Object.assign({}, ctx || {});
|
||||
const items = rows.map((row) => {
|
||||
const rowCtx = { ...ctx || {} };
|
||||
// gameData.data est déjà indexé par le nom D'AFFICHAGE du champ (voir
|
||||
// full_game_payload.py), jamais par sa colonne SQL — row[f.name] lit
|
||||
// donc directement la bonne valeur, y compris pour un champ "relation"
|
||||
// (voir filter-repeater-rows.js pour le détail de ce piège).
|
||||
fieldsMetaForDef.forEach(function (f) {
|
||||
var v = row[f.name];
|
||||
rowCtx[f.name] = v === null || v === undefined ? "" : String(v);
|
||||
fieldsMetaForDef.forEach((f) => {
|
||||
const v = row[f.name];
|
||||
rowCtx[f.name] = v === null || v === undefined ? '' : String(v);
|
||||
});
|
||||
var inner;
|
||||
let inner;
|
||||
if (templateTree) {
|
||||
inner = templateTree.top.map(function (c) {
|
||||
return forgeRenderElementHtml(c, gameData, templateTree.children_map, rowCtx);
|
||||
}).join("");
|
||||
inner = templateTree.top.map((c) => forgeRenderElementHtml(c, gameData, templateTree.children_map, rowCtx)).join('');
|
||||
} else if (children.length) {
|
||||
inner = children.map(function (c) {
|
||||
return forgeRenderElementHtml(c, gameData, childrenMap, rowCtx);
|
||||
}).join("");
|
||||
inner = children.map((c) => forgeRenderElementHtml(c, gameData, childrenMap, rowCtx)).join('');
|
||||
} else {
|
||||
var line = template;
|
||||
Object.keys(rowCtx).forEach(function (fname) {
|
||||
if (fname === "_forge_play_mode") return;
|
||||
line = line.split("{{" + fname + "}}").join(rowCtx[fname]);
|
||||
let line = template;
|
||||
Object.keys(rowCtx).forEach((fname) => {
|
||||
if (fname === '_forge_play_mode') return;
|
||||
line = line.split(`{{${fname}}}`).join(rowCtx[fname]);
|
||||
});
|
||||
inner = forgeHtmlEscape(line);
|
||||
}
|
||||
return '<div class="repeaterItem" data-row-id="' + row.id + '">' + inner + '</div>';
|
||||
return `<div class="repeaterItem" data-row-id="${row.id}">${inner}</div>`;
|
||||
});
|
||||
|
||||
var emptyMsg = attrsRaw._filtre_champ ? "Aucune ligne ne correspond au filtre réglé." : "Aucune donnée dans cet objet pour l'instant.";
|
||||
var innerAll = items.length ? items.join("") : "<em>" + emptyMsg + "</em>";
|
||||
return '<div id="' + forgeAutoId(el) + '" data-element-id="' + el.id + '"' + definitionAttr +
|
||||
' style="' + forgeHtmlEscape(style) + '">' + innerAll + '</div>';
|
||||
const emptyMsg = attrsRaw._filtre_champ ? 'Aucune ligne ne correspond au filtre réglé.' : "Aucune donnée dans cet objet pour l'instant.";
|
||||
const innerAll = items.length ? items.join('') : `<em>${emptyMsg}</em>`;
|
||||
return `<div id="${forgeAutoId(el)}" data-element-id="${el.id}"${definitionAttr
|
||||
} style="${forgeHtmlEscape(style)}">${innerAll}</div>`;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeRenderRepeater };
|
||||
var renderMod = require('./render-element.js');
|
||||
["forgeStyleString", "forgeHtmlEscape", "forgeAutoId", "forgeApplyCtx", "forgeRenderElementHtml"].forEach(function (name) {
|
||||
const renderMod = require('./render-element.js');
|
||||
['forgeStyleString', 'forgeHtmlEscape', 'forgeAutoId', 'forgeApplyCtx', 'forgeRenderElementHtml'].forEach((name) => {
|
||||
global[name] = global[name] || renderMod[name];
|
||||
});
|
||||
var filterMod = require('./filter-repeater-rows.js');
|
||||
const filterMod = require('./filter-repeater-rows.js');
|
||||
global.forgeFilterRepeaterRows = global.forgeFilterRepeaterRows || filterMod.forgeFilterRepeaterRows;
|
||||
}
|
||||
|
||||
@@ -9,57 +9,56 @@
|
||||
// forgeParentFlexDirection/forgeRenderChildren), chargé avant ce fichier.
|
||||
|
||||
function forgeRenderCheckboxOrRadio(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var inputType = el.widget === "case_a_cocher" ? "checkbox" : "radio";
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const inputType = el.widget === 'case_a_cocher' ? 'checkbox' : 'radio';
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
attrs.type = inputType;
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex; align-items:center; gap:8px;";
|
||||
var content = forgeApplyCtx(el.content || "", ctx);
|
||||
return '<label ' + forgeAttrString({ class: inputType }, style) + '><input ' + forgeAttrString(attrs, "") + '> ' + forgeHtmlEscape(content) + '</label>';
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex; align-items:center; gap:8px;`;
|
||||
const content = forgeApplyCtx(el.content || '', ctx);
|
||||
return `<label ${forgeAttrString({ class: inputType }, style)}><input ${forgeAttrString(attrs, '')}> ${forgeHtmlEscape(content)}</label>`;
|
||||
}
|
||||
|
||||
function forgeRenderSelect(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var outerAttrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var bulmaClasses = outerAttrs.class || "";
|
||||
var selectAttrs = {}, wrapperAttrs = {};
|
||||
Object.keys(outerAttrs).forEach(function (k) {
|
||||
if (k === "id" || k === "data-element-id" || k === "data-anim-target") wrapperAttrs[k] = outerAttrs[k];
|
||||
else if (k !== "class") selectAttrs[k] = outerAttrs[k];
|
||||
const outerAttrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const bulmaClasses = outerAttrs.class || '';
|
||||
const selectAttrs = {}; const
|
||||
wrapperAttrs = {};
|
||||
Object.keys(outerAttrs).forEach((k) => {
|
||||
if (k === 'id' || k === 'data-element-id' || k === 'data-anim-target') wrapperAttrs[k] = outerAttrs[k];
|
||||
else if (k !== 'class') selectAttrs[k] = outerAttrs[k];
|
||||
});
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex;";
|
||||
var content = forgeApplyCtx(el.content || "", ctx);
|
||||
var options = content.split("\n").map(function (o) { return o.trim(); }).filter(Boolean);
|
||||
var optsHtml = options.map(function (o) {
|
||||
var esc = forgeHtmlEscape(o);
|
||||
return '<option value="' + esc + '">' + esc + '</option>';
|
||||
}).join("");
|
||||
var selectHtml = '<select ' + forgeAttrString(selectAttrs, "") + '>' + optsHtml + '</select>';
|
||||
var selectWrapClass = forgeHtmlEscape(("select is-fullwidth " + bulmaClasses).trim());
|
||||
return '<div ' + forgeAttrString(wrapperAttrs, style) + '><div class="' + selectWrapClass + '">' + selectHtml + '</div></div>';
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex;`;
|
||||
const content = forgeApplyCtx(el.content || '', ctx);
|
||||
const options = content.split('\n').map((o) => o.trim()).filter(Boolean);
|
||||
const optsHtml = options.map((o) => {
|
||||
const esc = forgeHtmlEscape(o);
|
||||
return `<option value="${esc}">${esc}</option>`;
|
||||
}).join('');
|
||||
const selectHtml = `<select ${forgeAttrString(selectAttrs, '')}>${optsHtml}</select>`;
|
||||
const selectWrapClass = forgeHtmlEscape((`select is-fullwidth ${bulmaClasses}`).trim());
|
||||
return `<div ${forgeAttrString(wrapperAttrs, style)}><div class="${selectWrapClass}">${selectHtml}</div></div>`;
|
||||
}
|
||||
|
||||
function forgeRenderFieldset(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
var content = forgeApplyCtx(el.content || "", ctx) || "Groupe de champs";
|
||||
var childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
|
||||
return '<fieldset ' + forgeAttrString(attrs, style) + '><legend>' + forgeHtmlEscape(content) + '</legend>' + childHtml + '</fieldset>';
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
const content = forgeApplyCtx(el.content || '', ctx) || 'Groupe de champs';
|
||||
const childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
|
||||
return `<fieldset ${forgeAttrString(attrs, style)}><legend>${forgeHtmlEscape(content)}</legend>${childHtml}</fieldset>`;
|
||||
}
|
||||
|
||||
function forgeRenderTable(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var headerRow = ["1", "true", "on"].indexOf((el.attributes || {})._header_row) !== -1;
|
||||
var content = forgeApplyCtx(el.content || "", ctx);
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
var rows = content.split("\n").filter(function (r) { return r.trim(); });
|
||||
var htmlRows = rows.map(function (r, i) {
|
||||
var cells = r.split(";");
|
||||
var cellTag = (headerRow && i === 0) ? "th" : "td";
|
||||
var cellsHtml = cells.map(function (c) {
|
||||
return "<" + cellTag + ">" + forgeHtmlEscape(c.trim()) + "</" + cellTag + ">";
|
||||
}).join("");
|
||||
return "<tr>" + cellsHtml + "</tr>";
|
||||
}).join("");
|
||||
return '<table ' + forgeAttrString(attrs, style) + '>' + htmlRows + '</table>';
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const headerRow = ['1', 'true', 'on'].indexOf((el.attributes || {})._header_row) !== -1;
|
||||
const content = forgeApplyCtx(el.content || '', ctx);
|
||||
const style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
const rows = content.split('\n').filter((r) => r.trim());
|
||||
const htmlRows = rows.map((r, i) => {
|
||||
const cells = r.split(';');
|
||||
const cellTag = (headerRow && i === 0) ? 'th' : 'td';
|
||||
const cellsHtml = cells.map((c) => `<${cellTag}>${forgeHtmlEscape(c.trim())}</${cellTag}>`).join('');
|
||||
return `<tr>${cellsHtml}</tr>`;
|
||||
}).join('');
|
||||
return `<table ${forgeAttrString(attrs, style)}>${htmlRows}</table>`;
|
||||
}
|
||||
|
||||
function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
@@ -67,12 +66,12 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
||||
// catalogue curaté (screens/widgets/fontawesome_icons.py) : une valeur
|
||||
// invalide ne peut être enregistrée que depuis l'éditeur, qui la valide
|
||||
// déjà à la sauvegarde — un jeu EXPORTÉ n'en produit jamais.
|
||||
var rawAttrs = el.attributes || {};
|
||||
var iconSlug = rawAttrs._icon_slug || "star";
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var existingClass = attrs.class || "";
|
||||
const rawAttrs = el.attributes || {};
|
||||
const iconSlug = rawAttrs._icon_slug || 'star';
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const existingClass = attrs.class || '';
|
||||
delete attrs.class;
|
||||
attrs.class = (existingClass + " icon-svg").trim();
|
||||
attrs.class = (`${existingClass} icon-svg`).trim();
|
||||
// data: URI pré-calculée à l'export (voir gameData.icon_data_uris,
|
||||
// publish/build_scorm_package.py::_build_icon_data_uris) — jamais un
|
||||
// chemin de fichier, même relatif : Firefox traite chaque ressource
|
||||
@@ -84,49 +83,50 @@ function forgeRenderIcone(el, meta, gameData, childrenMap, ctx, parentFlexDirect
|
||||
// devrait pas arriver : _collect_icon_slugs scanne tous les widgets
|
||||
// "icone", posés directement ou nichés dans un élément de jeu
|
||||
// réutilisable) — au moins un lien exploitable plutôt qu'un vide.
|
||||
var iconUrl = (gameData.icon_data_uris && gameData.icon_data_uris[iconSlug]) || ("icons/" + iconSlug + ".svg");
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " --icon-url:url('" + iconUrl + "');";
|
||||
const iconUrl = (gameData.icon_data_uris && gameData.icon_data_uris[iconSlug]) || (`icons/${iconSlug}.svg`);
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} --icon-url:url('${iconUrl}');`;
|
||||
// aria-hidden (RGAA/WCAG 1.1.1) : une icône seule (pas de champ de
|
||||
// description dédié dans l'éditeur) est traitée comme purement
|
||||
// décorative — jamais annoncée par un lecteur d'écran, comme le nom du
|
||||
// slug FontAwesome ("star") n'aurait aucun sens lu à voix haute.
|
||||
if (!("aria-hidden" in attrs)) attrs["aria-hidden"] = "true";
|
||||
return "<i " + forgeAttrString(attrs, style) + "></i>";
|
||||
if (!('aria-hidden' in attrs)) attrs['aria-hidden'] = 'true';
|
||||
return `<i ${forgeAttrString(attrs, style)}></i>`;
|
||||
}
|
||||
|
||||
function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var tabs = (childrenMap && childrenMap[el.id]) || [];
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
var tabsExtraClasses = attrs.class || "";
|
||||
const tabs = (childrenMap && childrenMap[el.id]) || [];
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
const tabsExtraClasses = attrs.class || '';
|
||||
delete attrs.class;
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget) + " display:flex; flex-direction:column;";
|
||||
var groupId = forgeAutoId(el);
|
||||
const style = `${forgeStyleString(el, parentFlexDirection, el.widget)} display:flex; flex-direction:column;`;
|
||||
const groupId = forgeAutoId(el);
|
||||
|
||||
if (!tabs.length) {
|
||||
return '<div ' + forgeAttrString(attrs, style) + '><em style="opacity:.6;">Onglets — ajoute au moins un onglet dans ses réglages.</em></div>';
|
||||
return `<div ${forgeAttrString(attrs, style)}><em style="opacity:.6;">Onglets — ajoute au moins un onglet dans ses réglages.</em></div>`;
|
||||
}
|
||||
|
||||
var flexDir = forgeParentFlexDirection(el);
|
||||
var barItems = [], panels = [];
|
||||
tabs.forEach(function (tab, i) {
|
||||
var label = tab.name || ("Onglet " + (i + 1));
|
||||
var active = i === 0;
|
||||
var tabHtml = forgeRenderElementHtml(tab, gameData, childrenMap, ctx, flexDir);
|
||||
var targetId = "tabpanel-" + tab.id;
|
||||
const flexDir = forgeParentFlexDirection(el);
|
||||
const barItems = []; const
|
||||
panels = [];
|
||||
tabs.forEach((tab, i) => {
|
||||
const label = tab.name || (`Onglet ${i + 1}`);
|
||||
const active = i === 0;
|
||||
const tabHtml = forgeRenderElementHtml(tab, gameData, childrenMap, ctx, flexDir);
|
||||
const targetId = `tabpanel-${tab.id}`;
|
||||
barItems.push(
|
||||
'<li class="' + (active ? "is-active" : "") + '" data-tab-btn="' + groupId + '" data-tab-target="' + targetId +
|
||||
'" onclick="forgeShowTab(this)"><a>' + forgeHtmlEscape(String(label)) + '</a></li>'
|
||||
`<li class="${active ? 'is-active' : ''}" data-tab-btn="${groupId}" data-tab-target="${targetId
|
||||
}" onclick="forgeShowTab(this)"><a>${forgeHtmlEscape(String(label))}</a></li>`,
|
||||
);
|
||||
var hiddenStyle = active ? "" : ' style="display:none;"';
|
||||
panels.push('<div class="forgeTabPanel" id="' + targetId + '" data-tab-group="' + groupId + '"' + hiddenStyle + '>' + tabHtml + '</div>');
|
||||
const hiddenStyle = active ? '' : ' style="display:none;"';
|
||||
panels.push(`<div class="forgeTabPanel" id="${targetId}" data-tab-group="${groupId}"${hiddenStyle}>${tabHtml}</div>`);
|
||||
});
|
||||
|
||||
var tabsClass = forgeHtmlEscape(("tabs " + tabsExtraClasses).trim());
|
||||
const tabsClass = forgeHtmlEscape((`tabs ${tabsExtraClasses}`).trim());
|
||||
return (
|
||||
'<div ' + forgeAttrString(attrs, style) + '>' +
|
||||
'<div class="' + tabsClass + '"><ul>' + barItems.join("") + '</ul></div>' +
|
||||
'<div class="forgeTabPanels" style="flex:1 1 auto; min-height:0;">' + panels.join("") + '</div>' +
|
||||
'</div>'
|
||||
`<div ${forgeAttrString(attrs, style)}>`
|
||||
+ `<div class="${tabsClass}"><ul>${barItems.join('')}</ul></div>`
|
||||
+ `<div class="forgeTabPanels" style="flex:1 1 auto; min-height:0;">${panels.join('')}</div>`
|
||||
+ '</div>'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,27 +136,32 @@ function forgeRenderOnglets(el, meta, gameData, childrenMap, ctx, parentFlexDire
|
||||
// travail est fait UNE fois, à la génération du paquet, jamais à chaque
|
||||
// rendu côté client.
|
||||
function forgeRenderPersonnage(el, meta, gameData, childrenMap, ctx, parentFlexDirection) {
|
||||
var attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
attrs["data-personnage"] = "1";
|
||||
var animations = gameData.personnage_animations && gameData.personnage_animations[String(el.id)];
|
||||
var frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null;
|
||||
var idleFrame = frames && frames.length ? frames[0] : "";
|
||||
const attrs = forgeVisibleAttrs(el, meta, ctx);
|
||||
attrs['data-personnage'] = '1';
|
||||
const animations = gameData.personnage_animations && gameData.personnage_animations[String(el.id)];
|
||||
const frames = animations ? (animations.idle || animations[Object.keys(animations)[0]]) : null;
|
||||
const idleFrame = frames && frames.length ? frames[0] : '';
|
||||
if (idleFrame) attrs.src = idleFrame;
|
||||
// alt (RGAA/WCAG 1.1.1) : voir render-element.js pour le même défaut
|
||||
// décoratif sur le widget "image" générique.
|
||||
if (!("alt" in attrs)) attrs.alt = "";
|
||||
var style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
return "<img " + forgeAttrString(attrs, style) + ">";
|
||||
if (!('alt' in attrs)) attrs.alt = '';
|
||||
const style = forgeStyleString(el, parentFlexDirection, el.widget);
|
||||
return `<img ${forgeAttrString(attrs, style)}>`;
|
||||
}
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeRenderCheckboxOrRadio, forgeRenderSelect, forgeRenderFieldset, forgeRenderTable,
|
||||
forgeRenderIcone, forgeRenderOnglets, forgeRenderPersonnage,
|
||||
forgeRenderCheckboxOrRadio,
|
||||
forgeRenderSelect,
|
||||
forgeRenderFieldset,
|
||||
forgeRenderTable,
|
||||
forgeRenderIcone,
|
||||
forgeRenderOnglets,
|
||||
forgeRenderPersonnage,
|
||||
};
|
||||
var renderMod = require('./render-element.js');
|
||||
["forgeVisibleAttrs", "forgeStyleString", "forgeAttrString", "forgeApplyCtx", "forgeHtmlEscape",
|
||||
"forgeAutoId", "forgeParentFlexDirection", "forgeRenderChildren", "forgeRenderElementHtml"].forEach(function (name) {
|
||||
global[name] = global[name] || renderMod[name];
|
||||
});
|
||||
const renderMod = require('./render-element.js');
|
||||
['forgeVisibleAttrs', 'forgeStyleString', 'forgeAttrString', 'forgeApplyCtx', 'forgeHtmlEscape',
|
||||
'forgeAutoId', 'forgeParentFlexDirection', 'forgeRenderChildren', 'forgeRenderElementHtml'].forEach((name) => {
|
||||
global[name] = global[name] || renderMod[name];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,68 +16,73 @@ function forgeScormFindApi(win, triesLeft) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var forgeScormApi = null;
|
||||
var forgeScormLastSync = null; // évite de spammer LMSCommit() si rien n'a changé
|
||||
let forgeScormApi = null;
|
||||
let forgeScormLastSync = null; // évite de spammer LMSCommit() si rien n'a changé
|
||||
|
||||
function forgeScormInit() {
|
||||
forgeScormApi = forgeScormFindApi(window, 10);
|
||||
if (!forgeScormApi) return; // ouvert hors LMS (test local) : dégrade en silence
|
||||
forgeScormApi.LMSInitialize("");
|
||||
forgeScormApi.LMSInitialize('');
|
||||
// cmi.core.score.min/max (voir publish/build_scorm_package.py,
|
||||
// db/dialogue_lines.py::sum_question_rewards) : sans eux, le LMS n'a que le score BRUT
|
||||
// (cmi.core.score.raw, un total de points sans barème) et l'affiche
|
||||
// parfois à tort avec un "%" — en fournissant aussi min/max une seule
|
||||
// fois ici, le LMS peut calculer raw/max lui-même, un vrai pourcentage.
|
||||
// Omis si le jeu n'a aucune question de quiz (max=0, rien à barémer).
|
||||
var maxScore = typeof window !== "undefined" ? window.FORGE_MAX_QUIZ_SCORE : 0;
|
||||
const maxScore = typeof window !== 'undefined' ? window.FORGE_MAX_QUIZ_SCORE : 0;
|
||||
if (maxScore) {
|
||||
forgeScormApi.LMSSetValue("cmi.core.score.min", "0");
|
||||
forgeScormApi.LMSSetValue("cmi.core.score.max", String(maxScore));
|
||||
forgeScormApi.LMSSetValue('cmi.core.score.min', '0');
|
||||
forgeScormApi.LMSSetValue('cmi.core.score.max', String(maxScore));
|
||||
}
|
||||
}
|
||||
|
||||
// Statut Forge (voir db/constants.py, SCORM_STATUS_KEYS) -> vocabulaire
|
||||
// figé cmi.core.lesson_status (SCORM 1.2) : pas de mapping 1-pour-1 pour
|
||||
// "en_cours" (SCORM n'a pas d'équivalent "en cours" hors "incomplete").
|
||||
var FORGE_TO_SCORM_STATUS = {
|
||||
non_commence: "not attempted", en_cours: "incomplete", termine: "completed",
|
||||
reussi: "passed", echoue: "failed",
|
||||
const FORGE_TO_SCORM_STATUS = {
|
||||
non_commence: 'not attempted',
|
||||
en_cours: 'incomplete',
|
||||
termine: 'completed',
|
||||
reussi: 'passed',
|
||||
echoue: 'failed',
|
||||
};
|
||||
|
||||
function forgeScormSyncScore(gameData) {
|
||||
if (!forgeScormApi || !gameData || !gameData.scoring) return;
|
||||
var score = gameData.scoring.score || 0;
|
||||
var status = FORGE_TO_SCORM_STATUS[gameData.scoring.status] || "incomplete";
|
||||
var key = score + "|" + status;
|
||||
const score = gameData.scoring.score || 0;
|
||||
const status = FORGE_TO_SCORM_STATUS[gameData.scoring.status] || 'incomplete';
|
||||
const key = `${score}|${status}`;
|
||||
if (key === forgeScormLastSync) return;
|
||||
forgeScormLastSync = key;
|
||||
forgeScormApi.LMSSetValue("cmi.core.score.raw", String(score));
|
||||
forgeScormApi.LMSSetValue("cmi.core.lesson_status", status);
|
||||
forgeScormApi.LMSCommit("");
|
||||
forgeScormApi.LMSSetValue('cmi.core.score.raw', String(score));
|
||||
forgeScormApi.LMSSetValue('cmi.core.lesson_status', status);
|
||||
forgeScormApi.LMSCommit('');
|
||||
}
|
||||
|
||||
function forgeScormFinish() {
|
||||
if (!forgeScormApi) return;
|
||||
forgeScormApi.LMSFinish("");
|
||||
forgeScormApi.LMSFinish('');
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("load", forgeScormInit);
|
||||
window.addEventListener("beforeunload", forgeScormFinish);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('load', forgeScormInit);
|
||||
window.addEventListener('beforeunload', forgeScormFinish);
|
||||
// gameData est déjà en mémoire (window.FORGE_OFFLINE) — pas de fetch à
|
||||
// attendre, un intervalle court suffit à refléter score/statut sans
|
||||
// devoir faire dépendre bindings.js de ce fichier optionnel.
|
||||
var forgeScormSyncTimer = setInterval(function () {
|
||||
if (typeof gameData !== "undefined") forgeScormSyncScore(gameData);
|
||||
const forgeScormSyncTimer = setInterval(() => {
|
||||
if (typeof gameData !== 'undefined') forgeScormSyncScore(gameData);
|
||||
}, 2000);
|
||||
// .unref() n'existe pas sur la valeur renvoyée par setInterval() d'un
|
||||
// navigateur (juste un nombre) — seulement sous Node (static/js/play/
|
||||
// offline/__tests__/) : sans ça, `node --test` ne se termine jamais.
|
||||
if (forgeScormSyncTimer && typeof forgeScormSyncTimer.unref === "function") {
|
||||
if (forgeScormSyncTimer && typeof forgeScormSyncTimer.unref === 'function') {
|
||||
forgeScormSyncTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { forgeScormFindApi, forgeScormInit, forgeScormSyncScore, forgeScormFinish, FORGE_TO_SCORM_STATUS };
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeScormFindApi, forgeScormInit, forgeScormSyncScore, forgeScormFinish, FORGE_TO_SCORM_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,21 +24,21 @@ function forgeScorm2004FindApi(win, triesLeft) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var forgeScorm2004Api = null;
|
||||
var forgeScorm2004LastSync = null; // évite de spammer Commit() si rien n'a changé
|
||||
let forgeScorm2004Api = null;
|
||||
let forgeScorm2004LastSync = null; // évite de spammer Commit() si rien n'a changé
|
||||
|
||||
function forgeScorm2004Init() {
|
||||
forgeScorm2004Api = forgeScorm2004FindApi(window, 10);
|
||||
if (!forgeScorm2004Api) return; // ouvert hors LMS (test local) : dégrade en silence
|
||||
forgeScorm2004Api.Initialize("");
|
||||
forgeScorm2004Api.Initialize('');
|
||||
// cmi.score.min/max (voir static/js/play/offline/scorm-api.js pour le
|
||||
// même réglage côté 1.2) : cmi.score.scaled (forgeScorm2004SyncScore)
|
||||
// couvre déjà le pourcentage correctement, mais min/max reste utile
|
||||
// pour tout LMS qui affiche plutôt raw/max.
|
||||
var maxScore = typeof window !== "undefined" ? window.FORGE_MAX_QUIZ_SCORE : 0;
|
||||
const maxScore = typeof window !== 'undefined' ? window.FORGE_MAX_QUIZ_SCORE : 0;
|
||||
if (maxScore) {
|
||||
forgeScorm2004Api.SetValue("cmi.score.min", "0");
|
||||
forgeScorm2004Api.SetValue("cmi.score.max", String(maxScore));
|
||||
forgeScorm2004Api.SetValue('cmi.score.min', '0');
|
||||
forgeScorm2004Api.SetValue('cmi.score.max', String(maxScore));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,33 +46,33 @@ function forgeScorm2004Init() {
|
||||
// (completion_status, success_status) SCORM 2004 — contrairement à la
|
||||
// 1.2, "echoue" reste "completed" (l'apprenant a fini la formation) avec
|
||||
// success_status="failed", au lieu de retomber en "incomplete".
|
||||
var FORGE_TO_SCORM2004_STATUS = {
|
||||
non_commence: { completion: "not attempted", success: "unknown" },
|
||||
en_cours: { completion: "incomplete", success: "unknown" },
|
||||
termine: { completion: "completed", success: "unknown" },
|
||||
reussi: { completion: "completed", success: "passed" },
|
||||
echoue: { completion: "completed", success: "failed" },
|
||||
const FORGE_TO_SCORM2004_STATUS = {
|
||||
non_commence: { completion: 'not attempted', success: 'unknown' },
|
||||
en_cours: { completion: 'incomplete', success: 'unknown' },
|
||||
termine: { completion: 'completed', success: 'unknown' },
|
||||
reussi: { completion: 'completed', success: 'passed' },
|
||||
echoue: { completion: 'completed', success: 'failed' },
|
||||
};
|
||||
|
||||
function forgeScorm2004SyncScore(gameData) {
|
||||
if (!forgeScorm2004Api || !gameData || !gameData.scoring) return;
|
||||
var score = gameData.scoring.score || 0;
|
||||
var statuses = FORGE_TO_SCORM2004_STATUS[gameData.scoring.status] || FORGE_TO_SCORM2004_STATUS.en_cours;
|
||||
var key = score + "|" + statuses.completion + "|" + statuses.success;
|
||||
const score = gameData.scoring.score || 0;
|
||||
const statuses = FORGE_TO_SCORM2004_STATUS[gameData.scoring.status] || FORGE_TO_SCORM2004_STATUS.en_cours;
|
||||
const key = `${score}|${statuses.completion}|${statuses.success}`;
|
||||
if (key === forgeScorm2004LastSync) return;
|
||||
forgeScorm2004LastSync = key;
|
||||
forgeScorm2004Api.SetValue("cmi.score.raw", String(score));
|
||||
forgeScorm2004Api.SetValue('cmi.score.raw', String(score));
|
||||
// cmi.score.scaled (-1..1, ici toujours 0..1) : uniquement dérivable du
|
||||
// taux de bonnes réponses au quiz (voir dialogue-box-controller.js),
|
||||
// jamais de gameData.scoring.score qui n'est qu'un total de points sans
|
||||
// maximum connu — omis si aucune question n'a encore été répondue.
|
||||
if (typeof window !== "undefined" && window.forgeQuizTotalAnswered) {
|
||||
var scaled = window.forgeQuizCorrectAnswered / window.forgeQuizTotalAnswered;
|
||||
forgeScorm2004Api.SetValue("cmi.score.scaled", String(scaled));
|
||||
if (typeof window !== 'undefined' && window.forgeQuizTotalAnswered) {
|
||||
const scaled = window.forgeQuizCorrectAnswered / window.forgeQuizTotalAnswered;
|
||||
forgeScorm2004Api.SetValue('cmi.score.scaled', String(scaled));
|
||||
}
|
||||
forgeScorm2004Api.SetValue("cmi.completion_status", statuses.completion);
|
||||
forgeScorm2004Api.SetValue("cmi.success_status", statuses.success);
|
||||
forgeScorm2004Api.Commit("");
|
||||
forgeScorm2004Api.SetValue('cmi.completion_status', statuses.completion);
|
||||
forgeScorm2004Api.SetValue('cmi.success_status', statuses.success);
|
||||
forgeScorm2004Api.Commit('');
|
||||
}
|
||||
|
||||
// cmi.interactions.n.* (voir dialogue-box-controller.js::forgeQuizBoxAnswer,
|
||||
@@ -90,47 +90,60 @@ function forgeScorm2004SyncScore(gameData) {
|
||||
// silencieusement (SetValue échoue sans erreur visible : le champ reste
|
||||
// vide). "…T19:49:39" (sans les millisecondes ni le "Z") passe partout.
|
||||
function forgeScorm2004Timestamp() {
|
||||
return new Date().toISOString().replace(/\.\d+Z$/, "");
|
||||
return new Date().toISOString().replace(/\.\d+Z$/, '');
|
||||
}
|
||||
|
||||
function forgeScorm2004NotifyQuestionAnswered(dialogueId, objectName, questionIndex, questionText, correct, choiceIndex, correctIndex) {
|
||||
function forgeScorm2004NotifyQuestionAnswered(
|
||||
dialogueId,
|
||||
objectName,
|
||||
questionIndex,
|
||||
questionText,
|
||||
correct,
|
||||
choiceIndex,
|
||||
correctIndex,
|
||||
) {
|
||||
if (!forgeScorm2004Api) return;
|
||||
var n = parseInt(forgeScorm2004Api.GetValue("cmi.interactions._count"), 10);
|
||||
if (isNaN(n)) n = 0;
|
||||
var prefix = "cmi.interactions." + n + ".";
|
||||
forgeScorm2004Api.SetValue(prefix + "id", "dialogue:" + dialogueId + ":question:" + questionIndex);
|
||||
forgeScorm2004Api.SetValue(prefix + "type", "choice");
|
||||
forgeScorm2004Api.SetValue(prefix + "timestamp", forgeScorm2004Timestamp());
|
||||
forgeScorm2004Api.SetValue(prefix + "description", "{fr-FR}" + (questionText || ""));
|
||||
forgeScorm2004Api.SetValue(prefix + "learner_response", String(choiceIndex));
|
||||
forgeScorm2004Api.SetValue(prefix + "result", correct ? "correct" : "incorrect");
|
||||
forgeScorm2004Api.SetValue(prefix + "correct_responses.0.pattern", String(correctIndex));
|
||||
forgeScorm2004Api.Commit("");
|
||||
let n = parseInt(forgeScorm2004Api.GetValue('cmi.interactions._count'), 10);
|
||||
if (Number.isNaN(n)) n = 0;
|
||||
const prefix = `cmi.interactions.${n}.`;
|
||||
forgeScorm2004Api.SetValue(`${prefix}id`, `dialogue:${dialogueId}:question:${questionIndex}`);
|
||||
forgeScorm2004Api.SetValue(`${prefix}type`, 'choice');
|
||||
forgeScorm2004Api.SetValue(`${prefix}timestamp`, forgeScorm2004Timestamp());
|
||||
forgeScorm2004Api.SetValue(`${prefix}description`, `{fr-FR}${questionText || ''}`);
|
||||
forgeScorm2004Api.SetValue(`${prefix}learner_response`, String(choiceIndex));
|
||||
forgeScorm2004Api.SetValue(`${prefix}result`, correct ? 'correct' : 'incorrect');
|
||||
forgeScorm2004Api.SetValue(`${prefix}correct_responses.0.pattern`, String(correctIndex));
|
||||
forgeScorm2004Api.Commit('');
|
||||
}
|
||||
|
||||
function forgeScorm2004Finish() {
|
||||
if (!forgeScorm2004Api) return;
|
||||
forgeScorm2004Api.Terminate("");
|
||||
forgeScorm2004Api.Terminate('');
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("load", forgeScorm2004Init);
|
||||
window.addEventListener("beforeunload", forgeScorm2004Finish);
|
||||
var forgeScorm2004SyncTimer = setInterval(function () {
|
||||
if (typeof gameData !== "undefined") forgeScorm2004SyncScore(gameData);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('load', forgeScorm2004Init);
|
||||
window.addEventListener('beforeunload', forgeScorm2004Finish);
|
||||
const forgeScorm2004SyncTimer = setInterval(() => {
|
||||
if (typeof gameData !== 'undefined') forgeScorm2004SyncScore(gameData);
|
||||
}, 2000);
|
||||
// .unref() n'existe pas sur la valeur renvoyée par setInterval() d'un
|
||||
// navigateur (juste un nombre) — seulement sous Node (static/js/play/
|
||||
// offline/__tests__/) : sans ça, `node --test` ne se termine jamais
|
||||
// (l'intervalle garde le process vivant indéfiniment).
|
||||
if (forgeScorm2004SyncTimer && typeof forgeScorm2004SyncTimer.unref === "function") {
|
||||
if (forgeScorm2004SyncTimer && typeof forgeScorm2004SyncTimer.unref === 'function') {
|
||||
forgeScorm2004SyncTimer.unref();
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeScorm2004FindApi, forgeScorm2004Init, forgeScorm2004SyncScore, forgeScorm2004Finish,
|
||||
forgeScorm2004NotifyQuestionAnswered, forgeScorm2004Timestamp, FORGE_TO_SCORM2004_STATUS,
|
||||
forgeScorm2004FindApi,
|
||||
forgeScorm2004Init,
|
||||
forgeScorm2004SyncScore,
|
||||
forgeScorm2004Finish,
|
||||
forgeScorm2004NotifyQuestionAnswered,
|
||||
forgeScorm2004Timestamp,
|
||||
FORGE_TO_SCORM2004_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,38 +11,38 @@
|
||||
// serveur : sans ça, tous les exemplaires d'un même modèle de ligne
|
||||
// évalueraient la MÊME ligne au lieu de chacun la sienne).
|
||||
function forgeIsElementVisibleOffline(gameData, attrs, ctx) {
|
||||
var mode = attrs._visibilite_cond_mode;
|
||||
if (mode !== "variable" && mode !== "objet") return true;
|
||||
var operator = attrs._visibilite_cond_operateur || "egal";
|
||||
var expected = forgeResolveFilterValue(gameData, attrs._visibilite_cond_valeur || "");
|
||||
const mode = attrs._visibilite_cond_mode;
|
||||
if (mode !== 'variable' && mode !== 'objet') return true;
|
||||
const operator = attrs._visibilite_cond_operateur || 'egal';
|
||||
const expected = forgeResolveFilterValue(gameData, attrs._visibilite_cond_valeur || '');
|
||||
|
||||
if (mode === "variable") {
|
||||
var varName = attrs._visibilite_cond_variable;
|
||||
if (mode === 'variable') {
|
||||
const varName = attrs._visibilite_cond_variable;
|
||||
if (!varName) return true;
|
||||
var variable = gameData.variables && gameData.variables[varName];
|
||||
const variable = gameData.variables && gameData.variables[varName];
|
||||
if (!variable) return true; // variable supprimée depuis : ne bloque jamais l'affichage
|
||||
var value = variable.value;
|
||||
var path = attrs._visibilite_cond_variable_chemin;
|
||||
let { value } = variable;
|
||||
const path = attrs._visibilite_cond_variable_chemin;
|
||||
if (path) value = forgeResolveVariablePath(value, path);
|
||||
return compareValues(value, operator, expected, variable.type || "texte");
|
||||
return compareValues(value, operator, expected, variable.type || 'texte');
|
||||
}
|
||||
|
||||
var definitionId = attrs._visibilite_cond_definition_id;
|
||||
var fieldName = attrs._visibilite_cond_champ;
|
||||
let definitionId = attrs._visibilite_cond_definition_id;
|
||||
const fieldName = attrs._visibilite_cond_champ;
|
||||
if (!definitionId || !fieldName) return true;
|
||||
definitionId = String(definitionId);
|
||||
var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
|
||||
if (!fieldsMetaForDef) return true;
|
||||
var field = fieldsMetaForDef.find(function (f) { return f.name === fieldName; });
|
||||
var fieldType = field ? field.type : "texte";
|
||||
const field = fieldsMetaForDef.find((f) => f.name === fieldName);
|
||||
const fieldType = field ? field.type : 'texte';
|
||||
|
||||
if (ctx && Object.prototype.hasOwnProperty.call(ctx, fieldName)) {
|
||||
return compareValues(ctx[fieldName], operator, expected, fieldType);
|
||||
}
|
||||
|
||||
var rows = gameData.data && gameData.data[definitionId];
|
||||
const rows = gameData.data && gameData.data[definitionId];
|
||||
if (!rows || !rows.length) return true; // objet sans donnée pour l'instant : ne bloque pas
|
||||
var row = rows[0]; // convention "état de partie" : une seule ligne
|
||||
const row = rows[0]; // convention "état de partie" : une seule ligne
|
||||
// row[fieldName] : gameData.data est déjà indexé par le nom D'AFFICHAGE
|
||||
// du champ (voir full_game_payload.py), jamais par sa colonne SQL.
|
||||
return compareValues(row[fieldName], operator, expected, fieldType);
|
||||
@@ -50,7 +50,7 @@ function forgeIsElementVisibleOffline(gameData, attrs, ctx) {
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { forgeIsElementVisibleOffline };
|
||||
var filterMod = require('./filter-repeater-rows.js');
|
||||
const filterMod = require('./filter-repeater-rows.js');
|
||||
global.forgeResolveFilterValue = global.forgeResolveFilterValue || filterMod.forgeResolveFilterValue;
|
||||
global.forgeResolveVariablePath = global.forgeResolveVariablePath || filterMod.forgeResolveVariablePath;
|
||||
global.compareValues = global.compareValues || require('../conditions.js').compareValues;
|
||||
|
||||
@@ -15,15 +15,25 @@
|
||||
//
|
||||
// Best-effort partout : un LRS injoignable/mal configuré ne doit JAMAIS
|
||||
// bloquer ni ralentir la partie du joueur.
|
||||
//
|
||||
// NOSONAR S5332 (toutes occurrences "http://adlnet.gov/..." de ce fichier
|
||||
// + les 2 assertions de __tests__/xapi-client.test.js) : identifiants du
|
||||
// vocabulaire xAPI standard ADL, jamais dérérencés/appelés en réseau (de
|
||||
// simples chaînes comparées/embarquées) — le "http://" fait partie de la
|
||||
// chaîne fixée par la spec, jamais "https://" (changer casserait
|
||||
// l'interopérabilité avec n'importe quel LRS). Le VRAI endpoint réseau
|
||||
// (voir forgeXapiSendStatement plus bas) est toujours `config.endpoint`,
|
||||
// saisi par le créateur (routes/publish/xapi_settings.py), jamais un
|
||||
// littéral de ce fichier.
|
||||
|
||||
// Statut Forge -> verbe xAPI ADL standard, uniquement pour les statuts
|
||||
// qui marquent une VRAIE fin de partie — "non_commence"/"en_cours" ne
|
||||
// génèrent rien ici (bruit inutile pour un suivi volontairement léger,
|
||||
// voir le plan).
|
||||
var FORGE_TO_XAPI_VERB = {
|
||||
termine: { id: "http://adlnet.gov/expapi/verbs/completed" },
|
||||
reussi: { id: "http://adlnet.gov/expapi/verbs/passed" },
|
||||
echoue: { id: "http://adlnet.gov/expapi/verbs/failed" },
|
||||
const FORGE_TO_XAPI_VERB = {
|
||||
termine: { id: 'http://adlnet.gov/expapi/verbs/completed' }, // NOSONAR S5332
|
||||
reussi: { id: 'http://adlnet.gov/expapi/verbs/passed' }, // NOSONAR S5332
|
||||
echoue: { id: 'http://adlnet.gov/expapi/verbs/failed' }, // NOSONAR S5332
|
||||
};
|
||||
|
||||
// Libellé humain par verbe — l'id xAPI (http://adlnet.gov/expapi/verbs/...)
|
||||
@@ -33,18 +43,18 @@ var FORGE_TO_XAPI_VERB = {
|
||||
// français ?" — un LRS qui préfère le français (ou n'importe quelle
|
||||
// langue ici) affichera la bonne entrée au lieu de rester bloqué sur
|
||||
// l'anglais.
|
||||
var FORGE_XAPI_VERB_DISPLAY = {
|
||||
"http://adlnet.gov/expapi/verbs/initialized": { "en-US": "initialized", "fr-FR": "initialisé" },
|
||||
"http://adlnet.gov/expapi/verbs/terminated": { "en-US": "terminated", "fr-FR": "quitté" },
|
||||
"http://adlnet.gov/expapi/verbs/completed": { "en-US": "completed", "fr-FR": "terminé" },
|
||||
"http://adlnet.gov/expapi/verbs/passed": { "en-US": "passed", "fr-FR": "réussi" },
|
||||
"http://adlnet.gov/expapi/verbs/failed": { "en-US": "failed", "fr-FR": "échoué" },
|
||||
"http://adlnet.gov/expapi/verbs/scored": { "en-US": "scored", "fr-FR": "noté" },
|
||||
"http://adlnet.gov/expapi/verbs/attempted": { "en-US": "attempted", "fr-FR": "commencé" },
|
||||
"http://adlnet.gov/expapi/verbs/answered": { "en-US": "answered", "fr-FR": "répondu" },
|
||||
const FORGE_XAPI_VERB_DISPLAY = {
|
||||
'http://adlnet.gov/expapi/verbs/initialized': { 'en-US': 'initialized', 'fr-FR': 'initialisé' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/terminated': { 'en-US': 'terminated', 'fr-FR': 'quitté' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/completed': { 'en-US': 'completed', 'fr-FR': 'terminé' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/passed': { 'en-US': 'passed', 'fr-FR': 'réussi' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/failed': { 'en-US': 'failed', 'fr-FR': 'échoué' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/scored': { 'en-US': 'scored', 'fr-FR': 'noté' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/attempted': { 'en-US': 'attempted', 'fr-FR': 'commencé' }, // NOSONAR S5332
|
||||
'http://adlnet.gov/expapi/verbs/answered': { 'en-US': 'answered', 'fr-FR': 'répondu' }, // NOSONAR S5332
|
||||
};
|
||||
|
||||
var forgeXapiLastStatusSent = null; // évite de renvoyer le même statement si le statut ne change pas réellement
|
||||
let forgeXapiLastStatusSent = null; // évite de renvoyer le même statement si le statut ne change pas réellement
|
||||
|
||||
// Identité de l'apprenant : réutilise l'API SCORM déjà trouvée par
|
||||
// scorm-api.js (window.forgeScormApi, posée sur "load" — voir plus bas,
|
||||
@@ -52,23 +62,23 @@ var forgeXapiLastStatusSent = null; // évite de renvoyer le même statement si
|
||||
// si le paquet tourne dans un LMS ; sinon, un acteur anonyme générique.
|
||||
function forgeXapiActor() {
|
||||
try {
|
||||
if (typeof forgeScormApi !== "undefined" && forgeScormApi) {
|
||||
var name = forgeScormApi.LMSGetValue("cmi.core.student_name");
|
||||
var id = forgeScormApi.LMSGetValue("cmi.core.student_id");
|
||||
if (typeof forgeScormApi !== 'undefined' && forgeScormApi) {
|
||||
const name = forgeScormApi.LMSGetValue('cmi.core.student_name');
|
||||
const id = forgeScormApi.LMSGetValue('cmi.core.student_id');
|
||||
if (id) {
|
||||
return { objectType: "Agent", name: name || id, account: { homePage: "urn:forge-engine", name: id } };
|
||||
return { objectType: 'Agent', name: name || id, account: { homePage: 'urn:forge-engine', name: id } };
|
||||
}
|
||||
}
|
||||
} catch (e) { /* API SCORM présente mais qui répond mal — repli silencieux */ }
|
||||
return { objectType: "Agent", name: "Apprenant", mbox: "mailto:anonymous@forge-engine.local" };
|
||||
return { objectType: 'Agent', name: 'Apprenant', mbox: 'mailto:anonymous@forge-engine.local' };
|
||||
}
|
||||
|
||||
function forgeXapiObject() {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
const config = window.FORGE_XAPI_CONFIG;
|
||||
return {
|
||||
objectType: "Activity",
|
||||
objectType: 'Activity',
|
||||
id: config.activity_id,
|
||||
definition: { name: { "fr-FR": config.activity_name } },
|
||||
definition: { name: { 'fr-FR': config.activity_name } },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -80,24 +90,24 @@ function forgeXapiObject() {
|
||||
// de scène qui a déclenché ce dialogue (voir "ℹ️ Informations") — plus de
|
||||
// "quête" pour le porter.
|
||||
function forgeXapiDialogueObject(dialogueId, objectName) {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
const config = window.FORGE_XAPI_CONFIG;
|
||||
return {
|
||||
objectType: "Activity",
|
||||
id: config.activity_id + ":dialogue:" + dialogueId,
|
||||
definition: { name: { "fr-FR": objectName || "Dialogue" }, type: "http://adlnet.gov/expapi/activities/simulation" },
|
||||
objectType: 'Activity',
|
||||
id: `${config.activity_id}:dialogue:${dialogueId}`,
|
||||
definition: { name: { 'fr-FR': objectName || 'Dialogue' }, type: 'http://adlnet.gov/expapi/activities/simulation' }, // NOSONAR S5332
|
||||
};
|
||||
}
|
||||
|
||||
function forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText) {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
const config = window.FORGE_XAPI_CONFIG;
|
||||
return {
|
||||
objectType: "Activity",
|
||||
id: config.activity_id + ":dialogue:" + dialogueId + ":question:" + questionIndex,
|
||||
objectType: 'Activity',
|
||||
id: `${config.activity_id}:dialogue:${dialogueId}:question:${questionIndex}`,
|
||||
definition: {
|
||||
name: { "fr-FR": "Question " + (questionIndex + 1) + " — " + (objectName || "Dialogue") },
|
||||
description: { "fr-FR": questionText || "" },
|
||||
type: "http://adlnet.gov/expapi/activities/cmi.interaction",
|
||||
interactionType: "choice",
|
||||
name: { 'fr-FR': `Question ${questionIndex + 1} — ${objectName || 'Dialogue'}` },
|
||||
description: { 'fr-FR': questionText || '' },
|
||||
type: 'http://adlnet.gov/expapi/activities/cmi.interaction', // NOSONAR S5332
|
||||
interactionType: 'choice',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -110,27 +120,27 @@ function forgeXapiQuestionObject(dialogueId, objectName, questionIndex, question
|
||||
// (forgeXapiObject), ou une sous-activité quête/question
|
||||
// (forgeXapiQuestObject/forgeXapiQuestionObject) pour un suivi plus fin.
|
||||
function forgeXapiSendStatement(verbId, result, object) {
|
||||
var config = window.FORGE_XAPI_CONFIG;
|
||||
const config = window.FORGE_XAPI_CONFIG;
|
||||
if (!config || !config.endpoint) return;
|
||||
var statement = {
|
||||
const statement = {
|
||||
actor: forgeXapiActor(),
|
||||
verb: { id: verbId, display: FORGE_XAPI_VERB_DISPLAY[verbId] || { "en-US": verbId } },
|
||||
verb: { id: verbId, display: FORGE_XAPI_VERB_DISPLAY[verbId] || { 'en-US': verbId } },
|
||||
object: object || forgeXapiObject(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
if (result) statement.result = result;
|
||||
var endpoint = config.endpoint.replace(/\/+$/, "") + "/statements";
|
||||
const endpoint = `${config.endpoint.replace(/\/+$/, '')}/statements`;
|
||||
try {
|
||||
fetch(endpoint, {
|
||||
method: "POST",
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Experience-API-Version": "1.0.3",
|
||||
"Authorization": "Basic " + btoa(config.login + ":" + config.password),
|
||||
'Content-Type': 'application/json',
|
||||
'X-Experience-API-Version': '1.0.3',
|
||||
Authorization: `Basic ${btoa(`${config.login}:${config.password}`)}`,
|
||||
},
|
||||
body: JSON.stringify(statement),
|
||||
}).catch(function (err) {
|
||||
console.warn("xAPI: envoi du statement échoué (LRS injoignable ?)", err);
|
||||
}).catch((err) => {
|
||||
console.warn('xAPI: envoi du statement échoué (LRS injoignable ?)', err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("xAPI: impossible d'envoyer le statement", err);
|
||||
@@ -143,8 +153,8 @@ function forgeXapiSendStatement(verbId, result, object) {
|
||||
function forgeXapiNotifyScoreChanged(newScore) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/scored",
|
||||
{ score: { raw: newScore } }
|
||||
'http://adlnet.gov/expapi/verbs/scored', // NOSONAR S5332
|
||||
{ score: { raw: newScore } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,44 +166,51 @@ function forgeXapiNotifyScoreChanged(newScore) {
|
||||
function forgeXapiNotifyDialogueCompleted(dialogueId, objectName) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/completed",
|
||||
{ completion: true }, forgeXapiDialogueObject(dialogueId, objectName)
|
||||
'http://adlnet.gov/expapi/verbs/completed', // NOSONAR S5332
|
||||
{ completion: true },
|
||||
forgeXapiDialogueObject(dialogueId, objectName),
|
||||
);
|
||||
}
|
||||
|
||||
function forgeXapiNotifyQuestionAnswered(dialogueId, objectName, questionIndex, questionText, correct, scoreAwarded) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
forgeXapiSendStatement(
|
||||
"http://adlnet.gov/expapi/verbs/answered",
|
||||
'http://adlnet.gov/expapi/verbs/answered', // NOSONAR S5332
|
||||
{ success: correct, score: { raw: scoreAwarded || 0 } },
|
||||
forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText)
|
||||
forgeXapiQuestionObject(dialogueId, objectName, questionIndex, questionText),
|
||||
);
|
||||
}
|
||||
|
||||
function forgeXapiNotifyStatusChanged(newStatus) {
|
||||
if (!window.FORGE_XAPI_CONFIG) return;
|
||||
var verb = FORGE_TO_XAPI_VERB[newStatus];
|
||||
const verb = FORGE_TO_XAPI_VERB[newStatus];
|
||||
if (!verb || newStatus === forgeXapiLastStatusSent) return;
|
||||
forgeXapiLastStatusSent = newStatus;
|
||||
forgeXapiSendStatement(verb.id, {
|
||||
completion: true,
|
||||
success: newStatus !== "echoue",
|
||||
success: newStatus !== 'echoue',
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("load", function () {
|
||||
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/initialized");
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('load', () => {
|
||||
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement('http://adlnet.gov/expapi/verbs/initialized'); // NOSONAR S5332
|
||||
});
|
||||
window.addEventListener("beforeunload", function () {
|
||||
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement("http://adlnet.gov/expapi/verbs/terminated");
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (window.FORGE_XAPI_CONFIG) forgeXapiSendStatement('http://adlnet.gov/expapi/verbs/terminated'); // NOSONAR S5332
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeXapiSendStatement, forgeXapiNotifyScoreChanged, forgeXapiNotifyStatusChanged, FORGE_TO_XAPI_VERB,
|
||||
forgeXapiNotifyDialogueCompleted, forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiDialogueObject, forgeXapiQuestionObject, FORGE_XAPI_VERB_DISPLAY,
|
||||
forgeXapiSendStatement,
|
||||
forgeXapiNotifyScoreChanged,
|
||||
forgeXapiNotifyStatusChanged,
|
||||
FORGE_TO_XAPI_VERB,
|
||||
forgeXapiNotifyDialogueCompleted,
|
||||
forgeXapiNotifyQuestionAnswered,
|
||||
forgeXapiDialogueObject,
|
||||
forgeXapiQuestionObject,
|
||||
FORGE_XAPI_VERB_DISPLAY,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,17 +11,17 @@
|
||||
// mécanique que "Modifier un objet de scène"/"Jouer une animation",
|
||||
// jamais dupliquée.
|
||||
|
||||
var activePersonnageControllerInterval = null;
|
||||
var activePersonnageInteractListener = null;
|
||||
let activePersonnageControllerInterval = null;
|
||||
let activePersonnageInteractListener = null;
|
||||
|
||||
// Un seul rythme pour TOUTES les poses (marche, idle, interagir, touches
|
||||
// supplémentaires) — les désynchroniser (ex. idle plus lent que walk)
|
||||
// n'a aucune justification et se voit immédiatement au jeu (bug signalé
|
||||
// par l'utilisateur).
|
||||
var FORGE_PERSONNAGE_FPS = 8;
|
||||
const FORGE_PERSONNAGE_FPS = 8;
|
||||
|
||||
function forgePersonnageAnimExists(elementId, animName) {
|
||||
var byAnim = (gameData.personnage_animations || {})[elementId];
|
||||
const byAnim = (gameData.personnage_animations || {})[elementId];
|
||||
return !!(byAnim && byAnim[animName] && byAnim[animName].length);
|
||||
}
|
||||
|
||||
@@ -48,15 +48,16 @@ function forgePersonnageIdleAnim(elementId) {
|
||||
// borne un déplacement au monde entier, pas seulement au petit cadre
|
||||
// visible de la scène.
|
||||
function forgeUpdateSceneWorldSize(screenData) {
|
||||
var width = screenData.scene_width, height = screenData.scene_height;
|
||||
(screenData.elements || []).forEach(function (o) {
|
||||
let width = screenData.scene_width; let
|
||||
height = screenData.scene_height;
|
||||
(screenData.elements || []).forEach((o) => {
|
||||
if (o.kind !== 'fond') return;
|
||||
width = Math.max(width, (o.x || 0) + (o.width || 0));
|
||||
height = Math.max(height, (o.y || 0) + (o.height || 0));
|
||||
});
|
||||
screenData.world_width = width;
|
||||
screenData.world_height = height;
|
||||
return { width: width, height: height };
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
// Caméra qui suit le personnage (le premier objet kind="personnage"
|
||||
@@ -68,22 +69,32 @@ function forgeUpdateSceneWorldSize(screenData) {
|
||||
// dépasse pas la scène — c'est ce qui garde le comportement identique à
|
||||
// avant pour tout jeu sans fond plus grand que sa scène.
|
||||
function forgeUpdateSceneCamera(screenData) {
|
||||
var worldEl = document.querySelector('.playScreen[data-screen-id="' + screenData.id + '"] .sceneWorld');
|
||||
const worldEl = document.querySelector(`.playScreen[data-screen-id="${screenData.id}"] .sceneWorld`);
|
||||
if (!worldEl) return;
|
||||
var world = forgeUpdateSceneWorldSize(screenData);
|
||||
worldEl.style.width = world.width + 'px';
|
||||
worldEl.style.height = world.height + 'px';
|
||||
const world = forgeUpdateSceneWorldSize(screenData);
|
||||
worldEl.style.width = `${world.width}px`;
|
||||
worldEl.style.height = `${world.height}px`;
|
||||
if (world.width <= screenData.scene_width && world.height <= screenData.scene_height) {
|
||||
worldEl.style.transform = '';
|
||||
return;
|
||||
}
|
||||
var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
|
||||
var playerEl = player && document.querySelector('[data-object-id="' + player.id + '"]');
|
||||
var px = playerEl ? (parseFloat(playerEl.style.left) || 0) + (parseFloat(playerEl.style.width) || 0) / 2 : world.width / 2;
|
||||
var py = playerEl ? (parseFloat(playerEl.style.top) || 0) + (parseFloat(playerEl.style.height) || 0) / 2 : world.height / 2;
|
||||
var offsetX = Math.min(Math.max(px - screenData.scene_width / 2, 0), Math.max(0, world.width - screenData.scene_width));
|
||||
var offsetY = Math.min(Math.max(py - screenData.scene_height / 2, 0), Math.max(0, world.height - screenData.scene_height));
|
||||
worldEl.style.transform = 'translate(' + (-offsetX) + 'px, ' + (-offsetY) + 'px)';
|
||||
const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||
const playerEl = player && document.querySelector(`[data-object-id="${player.id}"]`);
|
||||
const px = playerEl
|
||||
? (parseFloat(playerEl.style.left) || 0) + (parseFloat(playerEl.style.width) || 0) / 2
|
||||
: world.width / 2;
|
||||
const py = playerEl
|
||||
? (parseFloat(playerEl.style.top) || 0) + (parseFloat(playerEl.style.height) || 0) / 2
|
||||
: world.height / 2;
|
||||
const offsetX = Math.min(
|
||||
Math.max(px - screenData.scene_width / 2, 0),
|
||||
Math.max(0, world.width - screenData.scene_width),
|
||||
);
|
||||
const offsetY = Math.min(
|
||||
Math.max(py - screenData.scene_height / 2, 0),
|
||||
Math.max(0, world.height - screenData.scene_height),
|
||||
);
|
||||
worldEl.style.transform = `translate(${-offsetX}px, ${-offsetY}px)`;
|
||||
}
|
||||
|
||||
function forgeStopPersonnageControllers() {
|
||||
@@ -103,7 +114,9 @@ function forgeStopPersonnageControllers() {
|
||||
// (conditions.js, déjà chargé avant ce fichier dans templates/play.html),
|
||||
// la MÊME formule que la condition de collision de flow (elementsOverlap).
|
||||
function forgeObjectCollisionRectAt(left, top, width, height, collision) {
|
||||
return forgeCollisionRectFromBox({ left: left, top: top, width: width, height: height }, collision);
|
||||
return forgeCollisionRectFromBox({
|
||||
left, top, width, height,
|
||||
}, collision);
|
||||
}
|
||||
|
||||
// Tout objet de scène potentiellement "solide" pour le déplacement au
|
||||
@@ -112,9 +125,7 @@ function forgeObjectCollisionRectAt(left, top, width, height, collision) {
|
||||
// objet dont la collision est explicitement désactivée, ni l'objet qui
|
||||
// se déplace lui-même.
|
||||
function forgeSolidObstacles(screenData, movingId) {
|
||||
return (screenData.elements || []).filter(function (o) {
|
||||
return o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && o.kind !== 'quiz_box' && o.kind !== 'score_widget' && !(o.collision && o.collision.enabled === false);
|
||||
});
|
||||
return (screenData.elements || []).filter((o) => o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && o.kind !== 'quiz_box' && o.kind !== 'score_widget' && !(o.collision && o.collision.enabled === false));
|
||||
}
|
||||
|
||||
// Est-ce que le personnage, À la position candidate (newLeft/newTop),
|
||||
@@ -130,7 +141,7 @@ function forgeWouldCollide(screenData, movingObj, newLeft, newTop, width, height
|
||||
const obstacles = forgeSolidObstacles(screenData, movingObj.id);
|
||||
for (let i = 0; i < obstacles.length; i++) {
|
||||
const other = obstacles[i];
|
||||
const otherEl = document.querySelector('[data-object-id="' + other.id + '"]');
|
||||
const otherEl = document.querySelector(`[data-object-id="${other.id}"]`);
|
||||
if (!otherEl) continue;
|
||||
const otherLeft = parseFloat(otherEl.style.left) || 0;
|
||||
const otherTop = parseFloat(otherEl.style.top) || 0;
|
||||
@@ -146,16 +157,17 @@ function forgeWouldCollide(screenData, movingObj, newLeft, newTop, width, height
|
||||
// (triggers.js), pour un déplacement perçu comme continu.
|
||||
function forgeRunPersonnageControllerTick(screenData) {
|
||||
forgeUpdateSceneCamera(screenData);
|
||||
(screenData.elements || []).forEach(function (obj) {
|
||||
(screenData.elements || []).forEach((obj) => {
|
||||
// Seul le personnage "joueur" (voir screens/rendering/personnage_role.py)
|
||||
// est déplacé/animé au clavier — "ennemie"/"pnj" restent immobiles tant
|
||||
// qu'aucune logique de flow ne les pilote (voir add_scene_object.py).
|
||||
if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return;
|
||||
var cmd = obj.personnage_commandes;
|
||||
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
||||
const cmd = obj.personnage_commandes;
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
|
||||
var rawDx = 0, rawDy = 0;
|
||||
let rawDx = 0; let
|
||||
rawDy = 0;
|
||||
if (cmd.axe !== 'vertical') {
|
||||
if (heldKeys.has(cmd.gauche)) rawDx -= cmd.vitesse;
|
||||
if (heldKeys.has(cmd.droite)) rawDx += cmd.vitesse;
|
||||
@@ -168,17 +180,18 @@ function forgeRunPersonnageControllerTick(screenData) {
|
||||
// Bloque le déplacement axe par axe (pas les deux à la fois dès qu'un
|
||||
// seul est gêné) — glisser le long d'un mur en diagonale reste
|
||||
// possible, plus naturel qu'un blocage total au moindre contact.
|
||||
var curLeft = parseFloat(targetEl.style.left) || 0;
|
||||
var curTop = parseFloat(targetEl.style.top) || 0;
|
||||
var boxWidth = parseFloat(targetEl.style.width) || 0;
|
||||
var boxHeight = parseFloat(targetEl.style.height) || 0;
|
||||
var dx = rawDx, dy = rawDy;
|
||||
const curLeft = parseFloat(targetEl.style.left) || 0;
|
||||
const curTop = parseFloat(targetEl.style.top) || 0;
|
||||
const boxWidth = parseFloat(targetEl.style.width) || 0;
|
||||
const boxHeight = parseFloat(targetEl.style.height) || 0;
|
||||
let dx = rawDx; let
|
||||
dy = rawDy;
|
||||
if (dx !== 0) {
|
||||
var candidateLeft = clampSceneObjectPosition(targetEl, 'left', curLeft + dx);
|
||||
const candidateLeft = clampSceneObjectPosition(targetEl, 'left', curLeft + dx);
|
||||
if (forgeWouldCollide(screenData, obj, candidateLeft, curTop, boxWidth, boxHeight)) dx = 0;
|
||||
}
|
||||
if (dy !== 0) {
|
||||
var candidateTop = clampSceneObjectPosition(targetEl, 'top', curTop + dy);
|
||||
const candidateTop = clampSceneObjectPosition(targetEl, 'top', curTop + dy);
|
||||
if (forgeWouldCollide(screenData, obj, curLeft, candidateTop, boxWidth, boxHeight)) dy = 0;
|
||||
}
|
||||
// Se tourne vers la direction TENTÉE même si le déplacement est
|
||||
@@ -191,14 +204,24 @@ function forgeRunPersonnageControllerTick(screenData) {
|
||||
// animations sont lentes à côté de la marche" — aucune raison réelle
|
||||
// de les désynchroniser).
|
||||
if (dx === 0 && dy === 0) {
|
||||
var idleAnim = forgePersonnageIdleAnim(obj.id);
|
||||
if (idleAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: idleAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }));
|
||||
const idleAnim = forgePersonnageIdleAnim(obj.id);
|
||||
if (idleAnim) {
|
||||
runSpriteAnimation(
|
||||
targetEl,
|
||||
resolveSpriteFrames(obj.id, { animation: idleAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (dx !== 0) applyObjectProperty(targetEl, 'pos_x_relatif', dx);
|
||||
if (dy !== 0) applyObjectProperty(targetEl, 'pos_y_relatif', dy);
|
||||
var moveAnim = forgePersonnageMovementAnim(obj.id);
|
||||
if (moveAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: moveAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }));
|
||||
const moveAnim = forgePersonnageMovementAnim(obj.id);
|
||||
if (moveAnim) {
|
||||
runSpriteAnimation(
|
||||
targetEl,
|
||||
resolveSpriteFrames(obj.id, { animation: moveAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -209,7 +232,7 @@ function forgeRunPersonnageControllerTick(screenData) {
|
||||
// (sauter, attaquer, courir...) sans ajouter un champ fixe par pose.
|
||||
function forgePersonnageActionAnim(cmd, key) {
|
||||
if (cmd.interagir === key) return 'interact';
|
||||
var extraMatch = (cmd.extra || []).find(function (e) { return e.touche === key; });
|
||||
const extraMatch = (cmd.extra || []).find((e) => e.touche === key);
|
||||
return extraMatch ? extraMatch.animation : null;
|
||||
}
|
||||
|
||||
@@ -218,13 +241,16 @@ function forgePersonnageActionAnim(cmd, key) {
|
||||
// attaquer...), jamais un mouvement continu.
|
||||
function forgeStartPersonnageInteractListener(screenData) {
|
||||
activePersonnageInteractListener = function (ev) {
|
||||
(screenData.elements || []).forEach(function (obj) {
|
||||
(screenData.elements || []).forEach((obj) => {
|
||||
if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return;
|
||||
var animName = forgePersonnageActionAnim(obj.personnage_commandes, ev.key);
|
||||
const animName = forgePersonnageActionAnim(obj.personnage_commandes, ev.key);
|
||||
if (!animName || !forgePersonnageAnimExists(obj.id, animName)) return;
|
||||
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
||||
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
|
||||
if (!targetEl) return;
|
||||
runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: animName, fps: FORGE_PERSONNAGE_FPS, loop: false }));
|
||||
runSpriteAnimation(
|
||||
targetEl,
|
||||
resolveSpriteFrames(obj.id, { animation: animName, fps: FORGE_PERSONNAGE_FPS, loop: false }),
|
||||
);
|
||||
});
|
||||
};
|
||||
window.addEventListener('keydown', activePersonnageInteractListener);
|
||||
@@ -236,9 +262,9 @@ function forgeStartPersonnageInteractListener(screenData) {
|
||||
function forgeStartPersonnageControllers(screenData) {
|
||||
forgeStopPersonnageControllers();
|
||||
if (!screenData || screenData.kind !== 'jeu_2d') return;
|
||||
var elements = screenData.elements || [];
|
||||
var hasPersonnage = elements.some(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
|
||||
var hasFond = elements.some(function (o) { return o.kind === 'fond'; });
|
||||
const elements = screenData.elements || [];
|
||||
const hasPersonnage = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
|
||||
const hasFond = elements.some((o) => o.kind === 'fond');
|
||||
// Appliqué tout de suite (avant le premier tick de l'intervalle, 50ms
|
||||
// plus tard) pour éviter un flash d'une frame à la taille/caméra par
|
||||
// défaut. Un fond seul (sans personnage) n'a besoin que de CET appel
|
||||
@@ -246,7 +272,7 @@ function forgeStartPersonnageControllers(screenData) {
|
||||
// tick régulier pour le déplacement continu au clavier.
|
||||
if (hasFond) forgeUpdateSceneCamera(screenData);
|
||||
if (!hasPersonnage) return;
|
||||
activePersonnageControllerInterval = setInterval(function () { forgeRunPersonnageControllerTick(screenData); }, 50);
|
||||
activePersonnageControllerInterval = setInterval(() => { forgeRunPersonnageControllerTick(screenData); }, 50);
|
||||
forgeStartPersonnageInteractListener(screenData);
|
||||
}
|
||||
|
||||
@@ -255,10 +281,19 @@ function forgeStartPersonnageControllers(screenData) {
|
||||
// client.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim, forgePersonnageActionAnim,
|
||||
forgeRunPersonnageControllerTick, forgeStartPersonnageControllers, forgeStopPersonnageControllers,
|
||||
forgeStartPersonnageInteractListener, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera,
|
||||
forgeObjectCollisionRectAt, forgeSolidObstacles, forgeWouldCollide,
|
||||
forgePersonnageAnimExists,
|
||||
forgePersonnageMovementAnim,
|
||||
forgePersonnageIdleAnim,
|
||||
forgePersonnageActionAnim,
|
||||
forgeRunPersonnageControllerTick,
|
||||
forgeStartPersonnageControllers,
|
||||
forgeStopPersonnageControllers,
|
||||
forgeStartPersonnageInteractListener,
|
||||
forgeUpdateSceneWorldSize,
|
||||
forgeUpdateSceneCamera,
|
||||
forgeObjectCollisionRectAt,
|
||||
forgeSolidObstacles,
|
||||
forgeWouldCollide,
|
||||
};
|
||||
// node:test charge ce fichier isolément (voir __tests__/) — jamais un
|
||||
// <script> séparé en navigateur, où conditions.js est déjà chargé avant
|
||||
|
||||
@@ -20,22 +20,22 @@
|
||||
// décoration propre à CHAQUE modèle, pas du contrat partagé).
|
||||
function forgeQuizProgressOnShow(widget, progressSelector) {
|
||||
if (!widget) return;
|
||||
var quizObjectId = widget.getAttribute('data-object-id');
|
||||
var quizState = (typeof forgeQuizBoxState !== 'undefined') ? forgeQuizBoxState[quizObjectId] : null;
|
||||
const quizObjectId = widget.getAttribute('data-object-id');
|
||||
const quizState = (typeof forgeQuizBoxState !== 'undefined') ? forgeQuizBoxState[quizObjectId] : null;
|
||||
if (!quizState) return;
|
||||
var dialogueState = (typeof forgeDialogueBoxState !== 'undefined') ? forgeDialogueBoxState[quizState.dialogueObjectId] : null;
|
||||
const dialogueState = (typeof forgeDialogueBoxState !== 'undefined') ? forgeDialogueBoxState[quizState.dialogueObjectId] : null;
|
||||
if (!dialogueState) return;
|
||||
function isQuestion(line) { return line && line.type === 'question'; }
|
||||
var total = dialogueState.lines.filter(isQuestion).length;
|
||||
const total = dialogueState.lines.filter(isQuestion).length;
|
||||
if (total === 0) return;
|
||||
var current = dialogueState.lines.slice(0, dialogueState.index + 1).filter(isQuestion).length;
|
||||
var header = widget.querySelector('[data-quiz-role="header"]');
|
||||
if (header) header.textContent = 'Question ' + current + '/' + total;
|
||||
var progressEl = widget.querySelector(progressSelector);
|
||||
const current = dialogueState.lines.slice(0, dialogueState.index + 1).filter(isQuestion).length;
|
||||
const header = widget.querySelector('[data-quiz-role="header"]');
|
||||
if (header) header.textContent = `Question ${current}/${total}`;
|
||||
const progressEl = widget.querySelector(progressSelector);
|
||||
if (progressEl) {
|
||||
var bars = [];
|
||||
for (var i = 1; i <= total; i++) {
|
||||
bars.push('<i class="' + (i < current ? 'is-done' : (i === current ? 'is-current' : '')) + '"></i>');
|
||||
const bars = [];
|
||||
for (let i = 1; i <= total; i++) {
|
||||
bars.push(`<i class="${i < current ? 'is-done' : (i === current ? 'is-current' : '')}"></i>`);
|
||||
}
|
||||
progressEl.innerHTML = bars.join('');
|
||||
}
|
||||
@@ -56,7 +56,7 @@ function forgeQuizClassiqueOnShow(widget) {
|
||||
// .classiqueContent (pas la racine du widget, voir
|
||||
// render_quiz_box_page_classique) plutôt que sur le widget lui-même.
|
||||
if (widget) {
|
||||
var content = widget.querySelector('.classiqueContent');
|
||||
const content = widget.querySelector('.classiqueContent');
|
||||
if (content) content.classList.remove('is-showing-result');
|
||||
}
|
||||
forgeQuizProgressOnShow(widget, '[data-classique-role="progress"]');
|
||||
@@ -76,26 +76,25 @@ function forgeQuizClassiqueOnShow(widget) {
|
||||
// forgeQuizResultQuit, dialogue-box-controller.js.
|
||||
function forgeQuizClassiqueOnQuizComplete(widget, result) {
|
||||
if (!widget) return;
|
||||
var content = widget.querySelector('.classiqueContent');
|
||||
var zone = widget.querySelector('[data-classique-role="result"]');
|
||||
const content = widget.querySelector('.classiqueContent');
|
||||
const zone = widget.querySelector('[data-classique-role="result"]');
|
||||
if (!content || !zone) return;
|
||||
var total = (result && result.totalCount) || 0;
|
||||
var correct = (result && result.correctCount) || 0;
|
||||
var pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
var threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
||||
const total = (result && result.totalCount) || 0;
|
||||
const correct = (result && result.correctCount) || 0;
|
||||
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
const threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
||||
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||
var passed = pct >= threshold;
|
||||
var titleEl = widget.querySelector('.classiqueHeaderTitle');
|
||||
var title = titleEl ? titleEl.textContent : 'Quiz';
|
||||
var quizObjectId = widget.getAttribute('data-object-id');
|
||||
zone.innerHTML =
|
||||
'<div class="classiqueResultRing" style="--pct:' + pct + '"><span>' + pct + '%</span></div>' +
|
||||
'<p class="classiqueResultTitle">' + correct + ' bonne' + (correct > 1 ? 's' : '') + ' réponse' + (correct > 1 ? 's' : '') + ' sur ' + total + '</p>' +
|
||||
'<p class="classiqueResultSub">« ' + (typeof forgeEscapeHtml === 'function' ? forgeEscapeHtml(title) : title) + ' »</p>' +
|
||||
'<div class="classiqueResultBadge ' + (passed ? 'pass' : 'fail') + '">' +
|
||||
(passed ? '✓ Module validé' : '✕ Module non validé — seuil ' + threshold + '%') +
|
||||
'</div>' +
|
||||
'<button type="button" class="classiqueQuitBtn" onclick="forgeQuizResultQuit(' + quizObjectId + ')">✕ Quitter le quiz</button>';
|
||||
const passed = pct >= threshold;
|
||||
const titleEl = widget.querySelector('.classiqueHeaderTitle');
|
||||
const title = titleEl ? titleEl.textContent : 'Quiz';
|
||||
const quizObjectId = widget.getAttribute('data-object-id');
|
||||
zone.innerHTML = `<div class="classiqueResultRing" style="--pct:${pct}"><span>${pct}%</span></div>`
|
||||
+ `<p class="classiqueResultTitle">${correct} bonne${correct > 1 ? 's' : ''} réponse${correct > 1 ? 's' : ''} sur ${total}</p>`
|
||||
+ `<p class="classiqueResultSub">« ${typeof forgeEscapeHtml === 'function' ? forgeEscapeHtml(title) : title} »</p>`
|
||||
+ `<div class="classiqueResultBadge ${passed ? 'pass' : 'fail'}">${
|
||||
passed ? '✓ Module validé' : `✕ Module non validé — seuil ${threshold}%`
|
||||
}</div>`
|
||||
+ `<button type="button" class="classiqueQuitBtn" onclick="forgeQuizResultQuit(${quizObjectId})">✕ Quitter le quiz</button>`;
|
||||
content.classList.add('is-showing-result');
|
||||
}
|
||||
|
||||
@@ -111,32 +110,31 @@ function forgeQuizClassiqueOnQuizComplete(widget, result) {
|
||||
// visuellement se lire comme entièrement complété.
|
||||
function forgeQuizMangaOnQuizComplete(widget, result) {
|
||||
if (!widget) return;
|
||||
var zone = widget.querySelector('[data-manga-role="result"]');
|
||||
const zone = widget.querySelector('[data-manga-role="result"]');
|
||||
if (!zone) return;
|
||||
var total = (result && result.totalCount) || 0;
|
||||
var correct = (result && result.correctCount) || 0;
|
||||
var pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
var threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
||||
const total = (result && result.totalCount) || 0;
|
||||
const correct = (result && result.correctCount) || 0;
|
||||
const pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
const threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
||||
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||
var passed = pct >= threshold;
|
||||
var quizObjectId = widget.getAttribute('data-object-id');
|
||||
zone.innerHTML =
|
||||
'<div class="mangaResultCard">' +
|
||||
'<span class="mangaResultTitle">Fin du quiz</span>' +
|
||||
'<p class="mangaResultScore">Score final : ' + correct + ' / ' + total + '</p>' +
|
||||
'<div class="mangaResultBadge ' + (passed ? 'pass' : 'fail') + '">' + (passed ? '合格' : '不合格') + '</div>' +
|
||||
'<div><button type="button" class="mangaQuitBtn" onclick="forgeQuizResultQuit(' + quizObjectId + ')">Quitter le quiz</button></div>' +
|
||||
'</div>';
|
||||
var progressEl = widget.querySelector('[data-manga-role="progress"]');
|
||||
const passed = pct >= threshold;
|
||||
const quizObjectId = widget.getAttribute('data-object-id');
|
||||
zone.innerHTML = '<div class="mangaResultCard">'
|
||||
+ '<span class="mangaResultTitle">Fin du quiz</span>'
|
||||
+ `<p class="mangaResultScore">Score final : ${correct} / ${total}</p>`
|
||||
+ `<div class="mangaResultBadge ${passed ? 'pass' : 'fail'}">${passed ? '合格' : '不合格'}</div>`
|
||||
+ `<div><button type="button" class="mangaQuitBtn" onclick="forgeQuizResultQuit(${quizObjectId})">Quitter le quiz</button></div>`
|
||||
+ '</div>';
|
||||
const progressEl = widget.querySelector('[data-manga-role="progress"]');
|
||||
if (progressEl && total > 0) {
|
||||
var bars = [];
|
||||
for (var i = 0; i < total; i++) bars.push('<i class="is-done"></i>');
|
||||
const bars = [];
|
||||
for (let i = 0; i < total; i++) bars.push('<i class="is-done"></i>');
|
||||
progressEl.innerHTML = bars.join('');
|
||||
}
|
||||
widget.classList.add('is-showing-result');
|
||||
}
|
||||
|
||||
var forgeQuizTemplateEffects = {
|
||||
const forgeQuizTemplateEffects = {
|
||||
// Modèles "boîte de dialogue" (voir screens/rendering/quiz_box_config.py
|
||||
// ::QUIZ_BOX_DIALOG_TEMPLATES) — "Verre dépoli"/"Néon"/"Minimal"/
|
||||
// "Ludique" ont été retirés (demande explicite : "supprime tous les
|
||||
@@ -159,7 +157,10 @@ var forgeQuizTemplateEffects = {
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeQuizTemplateEffects,
|
||||
forgeQuizProgressOnShow, forgeQuizMangaOnShow, forgeQuizMangaOnQuizComplete,
|
||||
forgeQuizClassiqueOnShow, forgeQuizClassiqueOnQuizComplete,
|
||||
forgeQuizProgressOnShow,
|
||||
forgeQuizMangaOnShow,
|
||||
forgeQuizMangaOnQuizComplete,
|
||||
forgeQuizClassiqueOnShow,
|
||||
forgeQuizClassiqueOnQuizComplete,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
// voir surbrillance/visibilite/indication).
|
||||
function forgeRunScreenTriggers(screenData) {
|
||||
if (!screenData) return;
|
||||
var rules = screenData.screen_triggers;
|
||||
const rules = screenData.screen_triggers;
|
||||
if (!rules || !rules.length) return;
|
||||
rules.forEach(function (rule) {
|
||||
rules.forEach((rule) => {
|
||||
if (rule.trigger !== 'affichage') return;
|
||||
forgeRunCollisionRuleAction(rule.action, null, {});
|
||||
});
|
||||
|
||||
+51
-49
@@ -8,12 +8,12 @@
|
||||
// sans aller-retour serveur — même fonction que dans screen_edit.html,
|
||||
// pour un comportement identique éditeur/jeu.
|
||||
function forgeShowTab(btn) {
|
||||
var groupId = btn.dataset.tabBtn;
|
||||
var targetId = btn.dataset.tabTarget;
|
||||
document.querySelectorAll('[data-tab-btn="' + groupId + '"]').forEach(function (b) {
|
||||
const groupId = btn.dataset.tabBtn;
|
||||
const targetId = btn.dataset.tabTarget;
|
||||
document.querySelectorAll(`[data-tab-btn="${groupId}"]`).forEach((b) => {
|
||||
b.classList.toggle('is-active', b === btn);
|
||||
});
|
||||
document.querySelectorAll('[data-tab-group="' + groupId + '"]').forEach(function (p) {
|
||||
document.querySelectorAll(`[data-tab-group="${groupId}"]`).forEach((p) => {
|
||||
p.style.display = (p.id === targetId) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
@@ -31,8 +31,8 @@ function showScreen(id) {
|
||||
// visible" du même clic...). Aller vers un écran DIFFÉRENT continue de
|
||||
// tout rejouer normalement, comme un vrai premier affichage.
|
||||
if (id === window.currentScreenId) return;
|
||||
document.querySelectorAll('.playScreen').forEach(s => s.style.display = 'none');
|
||||
const el = document.querySelector('.playScreen[data-screen-id="' + id + '"]');
|
||||
document.querySelectorAll('.playScreen').forEach((s) => s.style.display = 'none');
|
||||
const el = document.querySelector(`.playScreen[data-screen-id="${id}"]`);
|
||||
if (el) {
|
||||
el.style.display = 'block';
|
||||
window.currentScreenId = id;
|
||||
@@ -52,15 +52,15 @@ function showScreen(id) {
|
||||
// Déplacement/animation automatiques de tout personnage posé sur cet
|
||||
// écran (voir static/js/play/personnage-controller.js) — n'a d'effet
|
||||
// que sur un écran "jeu_2d" contenant au moins un objet kind="personnage".
|
||||
forgeStartPersonnageControllers(screensData.find(function(s) { return s.id === id; }));
|
||||
forgeStartPersonnageControllers(screensData.find((s) => s.id === id));
|
||||
// Règles "à la collision/dans un périmètre -> action" (voir
|
||||
// static/js/play/collision-rules-controller.js) — démarré juste après
|
||||
// le contrôleur de personnage dont il dépend (position du joueur).
|
||||
forgeStartCollisionRuleControllers(screensData.find(function(s) { return s.id === id; }));
|
||||
forgeStartCollisionRuleControllers(screensData.find((s) => s.id === id));
|
||||
// Déclencheurs D'ÉCRAN "à l'affichage" (voir screen-triggers-controller.js,
|
||||
// screens/rendering/screen_triggers.py) — narration/cinématique sans
|
||||
// objet requis, exécutée à chaque affichage de CET écran.
|
||||
forgeRunScreenTriggers(screensData.find(function(s) { return s.id === id; }));
|
||||
forgeRunScreenTriggers(screensData.find((s) => s.id === id));
|
||||
// Widget "🏆 Score" (voir static/js/play/dialogue-box-controller.js) :
|
||||
// synchronise le compteur affiché sur CET écran avec le total déjà
|
||||
// gagné — sans ça, un score posé sur un écran affiché APRÈS que le
|
||||
@@ -74,28 +74,29 @@ function showScreen(id) {
|
||||
// arrêtée au changement d'écran — un seul Audio actif à la fois (pas un
|
||||
// tableau comme activeTimerTriggerIntervals dans triggers.js, une
|
||||
// musique de fond ne se cumule jamais avec une autre).
|
||||
var currentBackgroundAudio = null;
|
||||
let currentBackgroundAudio = null;
|
||||
|
||||
function runScreenBackgroundMusic(screenId) {
|
||||
if (currentBackgroundAudio) {
|
||||
currentBackgroundAudio.pause();
|
||||
currentBackgroundAudio = null;
|
||||
}
|
||||
var screenData = screensData.find(function(s) { return String(s.id) === String(screenId); });
|
||||
var url = screenData && screenData.background_music_url;
|
||||
const screenData = screensData.find((s) => String(s.id) === String(screenId));
|
||||
const url = screenData && screenData.background_music_url;
|
||||
if (!url) return;
|
||||
currentBackgroundAudio = new Audio(url);
|
||||
currentBackgroundAudio.loop = true;
|
||||
currentBackgroundAudio.play().catch(function() {});
|
||||
currentBackgroundAudio.play().catch(() => {});
|
||||
}
|
||||
|
||||
// Voir le commentaire sur #playFrame dans le <style> de play.html : la
|
||||
// zone de jeu garde toujours la proportion de l'écran affiché, quelle
|
||||
// que soit la forme réelle de la fenêtre du joueur.
|
||||
function applyScreenAspect(aspect) {
|
||||
var parts = String(aspect).split('/');
|
||||
var w = parseFloat(parts[0]) || 9, h = parseFloat(parts[1]) || 16;
|
||||
var frame = document.getElementById('playFrame');
|
||||
const parts = String(aspect).split('/');
|
||||
const w = parseFloat(parts[0]) || 9; const
|
||||
h = parseFloat(parts[1]) || 16;
|
||||
const frame = document.getElementById('playFrame');
|
||||
if (frame) {
|
||||
frame.style.setProperty('--ratio-w', w);
|
||||
frame.style.setProperty('--ratio-h', h);
|
||||
@@ -109,14 +110,14 @@ function applyScreenAspect(aspect) {
|
||||
// et sa taille en ratio fluide (écran "document") À CHAQUE showScreen(),
|
||||
// pas une seule fois au chargement.
|
||||
function applyScreenKind(kind) {
|
||||
var frame = document.getElementById('playFrame');
|
||||
const frame = document.getElementById('playFrame');
|
||||
if (frame) {
|
||||
frame.classList.toggle('sceneMode', kind === 'jeu_2d');
|
||||
}
|
||||
}
|
||||
|
||||
function screenIndexById(id) {
|
||||
return screensData.findIndex(s => s.id === id);
|
||||
return screensData.findIndex((s) => s.id === id);
|
||||
}
|
||||
|
||||
// ---------- Timeline d'animation (menu "Ajouter un élément" → panneau
|
||||
@@ -126,7 +127,7 @@ function screenIndexById(id) {
|
||||
// Deux types de clip : "animate_css" pose une classe de la bibliothèque
|
||||
// Animate.css (voir animate_css_catalog.py), "custom" joue des
|
||||
// images-clés propres au jeu (voir injectCustomKeyframes ci-dessous). ----------
|
||||
var pendingAnimationTimers = [];
|
||||
let pendingAnimationTimers = [];
|
||||
|
||||
function injectCustomKeyframes() {
|
||||
// Construit UNE FOIS toutes les @keyframes "personnalisées" de tous les
|
||||
@@ -134,20 +135,20 @@ function injectCustomKeyframes() {
|
||||
// pose dans une unique balise <style>, plutôt que d'en injecter une par
|
||||
// clip à chaque lecture — un nom de règle unique par clip (customAnim_id)
|
||||
// évite toute collision entre deux clips personnalisés différents.
|
||||
var rules = [];
|
||||
Object.keys(gameData.animations || {}).forEach(function(screenId) {
|
||||
(gameData.animations[screenId] || []).forEach(function(clip) {
|
||||
const rules = [];
|
||||
Object.keys(gameData.animations || {}).forEach((screenId) => {
|
||||
(gameData.animations[screenId] || []).forEach((clip) => {
|
||||
if (clip.kind !== 'custom' || !clip.custom_keyframes) return;
|
||||
var keyframes;
|
||||
let keyframes;
|
||||
try { keyframes = JSON.parse(clip.custom_keyframes); } catch (e) { return; }
|
||||
var steps = keyframes.map(function(k) {
|
||||
var props = Object.keys(k.props || {}).map(function(p) { return p + ':' + k.props[p] + ';'; }).join(' ');
|
||||
return k.percent + '% { ' + props + ' }';
|
||||
const steps = keyframes.map((k) => {
|
||||
const props = Object.keys(k.props || {}).map((p) => `${p}:${k.props[p]};`).join(' ');
|
||||
return `${k.percent}% { ${props} }`;
|
||||
}).join(' ');
|
||||
rules.push('@keyframes customAnim_' + clip.id + ' { ' + steps + ' }');
|
||||
rules.push(`@keyframes customAnim_${clip.id} { ${steps} }`);
|
||||
});
|
||||
});
|
||||
var styleTag = document.getElementById('customAnimStyles');
|
||||
let styleTag = document.getElementById('customAnimStyles');
|
||||
if (!styleTag) {
|
||||
styleTag = document.createElement('style');
|
||||
styleTag.id = 'customAnimStyles';
|
||||
@@ -164,15 +165,16 @@ function applyAnimationClip(clip) {
|
||||
// collectAnimationClips ci-dessous), y compris pour chaque ligne d'un
|
||||
// Répéteur utilisant ce modèle comme gabarit. Un seul querySelector
|
||||
// n'aurait animé que le tout premier exemplaire trouvé dans le DOM.
|
||||
var targets = document.querySelectorAll('[data-anim-target="' + clip.element_id + '"]');
|
||||
const targets = document.querySelectorAll(`[data-anim-target="${clip.element_id}"]`);
|
||||
if (!targets.length) return;
|
||||
var duration = clip.duration || 1, delay = clip.delay || 0;
|
||||
var iterationCount = clip.iteration_count || '1';
|
||||
var infinite = iterationCount === 'infinite';
|
||||
targets.forEach(function(target) {
|
||||
const duration = clip.duration || 1; const
|
||||
delay = clip.delay || 0;
|
||||
const iterationCount = clip.iteration_count || '1';
|
||||
const infinite = iterationCount === 'infinite';
|
||||
targets.forEach((target) => {
|
||||
if (clip.kind === 'custom') {
|
||||
target.style.animation = 'customAnim_' + clip.id + ' ' + duration + 's ' + (clip.easing || 'ease') +
|
||||
' ' + delay + 's ' + iterationCount;
|
||||
target.style.animation = `customAnim_${clip.id} ${duration}s ${clip.easing || 'ease'
|
||||
} ${delay}s ${iterationCount}`;
|
||||
if (!infinite) {
|
||||
target.addEventListener('animationend', function handler() {
|
||||
target.style.animation = '';
|
||||
@@ -186,14 +188,14 @@ function applyAnimationClip(clip) {
|
||||
// ses frames — résolues ICI depuis le personnage ACTUELLEMENT
|
||||
// assigné, gameData.personnage_animations, pas figées au moment où
|
||||
// le clip a été configuré).
|
||||
var spriteData = {};
|
||||
let spriteData = {};
|
||||
try { spriteData = JSON.parse(clip.custom_keyframes || '{}'); } catch (e) { /* invalide : rien à jouer */ }
|
||||
spriteData.loop = infinite;
|
||||
setTimeout(function() { runSpriteAnimation(target, resolveSpriteFrames(clip.element_id, spriteData)); }, delay * 1000);
|
||||
setTimeout(() => { runSpriteAnimation(target, resolveSpriteFrames(clip.element_id, spriteData)); }, delay * 1000);
|
||||
} else {
|
||||
var animClass = 'animate__' + clip.animate_name;
|
||||
target.style.setProperty('--animate-duration', duration + 's');
|
||||
target.style.setProperty('--animate-delay', delay + 's');
|
||||
const animClass = `animate__${clip.animate_name}`;
|
||||
target.style.setProperty('--animate-duration', `${duration}s`);
|
||||
target.style.setProperty('--animate-delay', `${delay}s`);
|
||||
target.style.animationIterationCount = iterationCount;
|
||||
target.classList.add('animate__animated', animClass);
|
||||
if (!infinite) {
|
||||
@@ -215,17 +217,17 @@ function applyAnimationClip(clip) {
|
||||
// modèle ne se jouait jamais quand le modèle est utilisé ailleurs — seul
|
||||
// l'écran-modèle affiché EN TANT QU'ÉCRAN pouvait la jouer.
|
||||
function collectAnimationClips(screenId) {
|
||||
var visited = {};
|
||||
var clips = [];
|
||||
const visited = {};
|
||||
let clips = [];
|
||||
function visit(sid) {
|
||||
if (visited[sid]) return;
|
||||
visited[sid] = true;
|
||||
clips = clips.concat((gameData.animations || {})[String(sid)] || []);
|
||||
var screenData = screensData.find(function(s){ return String(s.id) === String(sid); });
|
||||
const screenData = screensData.find((s) => String(s.id) === String(sid));
|
||||
if (!screenData) return;
|
||||
screenData.elements.forEach(function(el) {
|
||||
screenData.elements.forEach((el) => {
|
||||
if (!el.element_type_id) return;
|
||||
var templateScreenId = (gameData.element_types || {})[String(el.element_type_id)];
|
||||
const templateScreenId = (gameData.element_types || {})[String(el.element_type_id)];
|
||||
if (templateScreenId != null) visit(templateScreenId);
|
||||
});
|
||||
}
|
||||
@@ -239,11 +241,11 @@ function runAnimationTimeline(screenId) {
|
||||
// attente d'un affichage précédent, pour ne jamais empiler des
|
||||
// déclenchements en double si le joueur revient plusieurs fois de
|
||||
// suite sur le même écran avant la fin de sa timeline.
|
||||
pendingAnimationTimers.forEach(function(t) { clearTimeout(t); });
|
||||
pendingAnimationTimers.forEach((t) => { clearTimeout(t); });
|
||||
pendingAnimationTimers = [];
|
||||
var clips = collectAnimationClips(screenId);
|
||||
clips.forEach(function(clip) {
|
||||
var t = setTimeout(function() { applyAnimationClip(clip); }, (clip.start_time || 0) * 1000);
|
||||
const clips = collectAnimationClips(screenId);
|
||||
clips.forEach((clip) => {
|
||||
const t = setTimeout(() => { applyAnimationClip(clip); }, (clip.start_time || 0) * 1000);
|
||||
pendingAnimationTimers.push(t);
|
||||
});
|
||||
}
|
||||
|
||||
+38
-37
@@ -12,8 +12,8 @@ function runScreenShowTriggers(screenId) {
|
||||
const flow = gameData.flows[String(screenId)];
|
||||
if (!flow) return;
|
||||
flow.nodes
|
||||
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'affichage'; })
|
||||
.forEach(function(n){ runFlowFrom(n.id); });
|
||||
.filter((n) => n.node_type === 'trigger' && n.trigger_event === 'affichage')
|
||||
.forEach((n) => { runFlowFrom(n.id); });
|
||||
}
|
||||
|
||||
// Minuteur récurrent (Phase 3) : contrairement aux autres déclencheurs
|
||||
@@ -24,17 +24,17 @@ function runScreenShowTriggers(screenId) {
|
||||
// précédent (même principe que runAnimationTimeline) — sans ça, revenir
|
||||
// plusieurs fois sur le même écran accumulerait des setInterval en
|
||||
// double, chacun exécutant le graphe en plus des autres.
|
||||
var activeTimerTriggerIntervals = [];
|
||||
let activeTimerTriggerIntervals = [];
|
||||
|
||||
function runScreenTimerTriggers(screenId) {
|
||||
activeTimerTriggerIntervals.forEach(function(id) { clearInterval(id); });
|
||||
activeTimerTriggerIntervals.forEach((id) => { clearInterval(id); });
|
||||
activeTimerTriggerIntervals = [];
|
||||
const flow = gameData.flows[String(screenId)];
|
||||
if (!flow) return;
|
||||
flow.nodes
|
||||
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'minuteur' && n.trigger_interval_ms > 0; })
|
||||
.forEach(function(n){
|
||||
var intervalId = setInterval(function(){ runFlowFrom(n.id, screenId); }, n.trigger_interval_ms);
|
||||
.filter((n) => n.node_type === 'trigger' && n.trigger_event === 'minuteur' && n.trigger_interval_ms > 0)
|
||||
.forEach((n) => {
|
||||
const intervalId = setInterval(() => { runFlowFrom(n.id, screenId); }, n.trigger_interval_ms);
|
||||
activeTimerTriggerIntervals.push(intervalId);
|
||||
});
|
||||
}
|
||||
@@ -66,7 +66,7 @@ function runScreenTimerTriggers(screenId) {
|
||||
function findTriggerNodess(elementId, event) {
|
||||
const found = [];
|
||||
for (const screenIdKey in gameData.flows) {
|
||||
gameData.flows[screenIdKey].nodes.forEach(function(n) {
|
||||
gameData.flows[screenIdKey].nodes.forEach((n) => {
|
||||
if (n.node_type === 'trigger' && n.trigger_element_id === elementId && (n.trigger_event || 'clic') === event) {
|
||||
found.push({ node: n, screenId: screenIdKey });
|
||||
}
|
||||
@@ -86,13 +86,13 @@ function findTriggerNodess(elementId, event) {
|
||||
// en page imbriquée avec du texte brut — cette fonctionnalité vise les
|
||||
// éléments de texte simples (nom, lien...), pas les conteneurs.
|
||||
function bindHoverTexts() {
|
||||
document.querySelectorAll('[data-hover-text]').forEach(function(el) {
|
||||
document.querySelectorAll('[data-hover-text]').forEach((el) => {
|
||||
if (el.dataset.hoverBound) return;
|
||||
if (el.children.length > 0) return;
|
||||
el.dataset.hoverBound = '1';
|
||||
var original = el.textContent;
|
||||
el.addEventListener('mouseenter', function(){ el.textContent = el.dataset.hoverText; });
|
||||
el.addEventListener('mouseleave', function(){ el.textContent = original; });
|
||||
const original = el.textContent;
|
||||
el.addEventListener('mouseenter', () => { el.textContent = el.dataset.hoverText; });
|
||||
el.addEventListener('mouseleave', () => { el.textContent = original; });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ function bindHoverTexts() {
|
||||
// "bouton"/"lien", ne doit pas se voir écraser son rôle natif) ; Entrée/
|
||||
// Espace déclenchent le même comportement que le clic.
|
||||
function bindClicks() {
|
||||
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
|
||||
document.querySelectorAll('[data-element-id]').forEach((elDiv) => {
|
||||
const elementId = parseInt(elDiv.dataset.elementId, 10);
|
||||
const triggers = findTriggerNodess(elementId, 'clic');
|
||||
if (triggers.length) {
|
||||
@@ -137,7 +137,7 @@ function bindClicks() {
|
||||
window.lastClickedDefinitionId = elDiv.dataset.definitionId ? parseInt(elDiv.dataset.definitionId, 10) : null;
|
||||
// Exécute CHAQUE déclencheur "Au clic" posé sur cet élément, pas
|
||||
// seulement le premier (voir le commentaire de findTriggerNodess).
|
||||
triggers.forEach(trigger => runFlowFrom(trigger.node.id, trigger.screenId));
|
||||
triggers.forEach((trigger) => runFlowFrom(trigger.node.id, trigger.screenId));
|
||||
};
|
||||
elDiv.addEventListener('click', activate);
|
||||
elDiv.addEventListener('keydown', (ev) => {
|
||||
@@ -163,7 +163,7 @@ function bindClicks() {
|
||||
// bindClicks() ci-dessus) doit aussi devenir focusable pour rester
|
||||
// atteignable au clavier.
|
||||
function bindHoverTriggers() {
|
||||
document.querySelectorAll('[data-element-id]').forEach(elDiv => {
|
||||
document.querySelectorAll('[data-element-id]').forEach((elDiv) => {
|
||||
if (elDiv.dataset.hoverTriggerBound) return;
|
||||
const elementId = parseInt(elDiv.dataset.elementId, 10);
|
||||
const enterTriggers = findTriggerNodess(elementId, 'survol');
|
||||
@@ -175,12 +175,12 @@ function bindHoverTriggers() {
|
||||
// le commentaire de findTriggerNodess (plusieurs déclencheurs
|
||||
// indépendants sur le même élément/événement doivent tous s'exécuter).
|
||||
if (enterTriggers.length) {
|
||||
const onEnter = () => enterTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId));
|
||||
const onEnter = () => enterTriggers.forEach((t) => runFlowFrom(t.node.id, t.screenId));
|
||||
elDiv.addEventListener('mouseenter', onEnter);
|
||||
elDiv.addEventListener('focus', onEnter);
|
||||
}
|
||||
if (leaveTriggers.length) {
|
||||
const onLeave = () => leaveTriggers.forEach(t => runFlowFrom(t.node.id, t.screenId));
|
||||
const onLeave = () => leaveTriggers.forEach((t) => runFlowFrom(t.node.id, t.screenId));
|
||||
elDiv.addEventListener('mouseleave', onLeave);
|
||||
elDiv.addEventListener('blur', onLeave);
|
||||
}
|
||||
@@ -204,22 +204,20 @@ function bindHoverTriggers() {
|
||||
// quel un champ de saisie ailleurs sur la page, par ex.).
|
||||
function isGameKey(key) {
|
||||
for (const screenIdKey in gameData.flows) {
|
||||
if (gameData.flows[screenIdKey].nodes.some(function(n) {
|
||||
return n.node_type === 'trigger' &&
|
||||
(n.trigger_event === 'clavier' || n.trigger_event === 'touche_maintenue' || n.trigger_event === 'touche_relachee') &&
|
||||
n.trigger_key === key;
|
||||
})) return true;
|
||||
if (gameData.flows[screenIdKey].nodes.some((n) => n.node_type === 'trigger'
|
||||
&& (n.trigger_event === 'clavier' || n.trigger_event === 'touche_maintenue' || n.trigger_event === 'touche_relachee')
|
||||
&& n.trigger_key === key)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function bindKeyboardTriggers() {
|
||||
window.addEventListener('keydown', function (ev) {
|
||||
window.addEventListener('keydown', (ev) => {
|
||||
if (isGameKey(ev.key)) ev.preventDefault();
|
||||
for (const screenIdKey in gameData.flows) {
|
||||
gameData.flows[screenIdKey].nodes
|
||||
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'clavier' && n.trigger_key === ev.key; })
|
||||
.forEach(function(n){ runFlowFrom(n.id, screenIdKey); });
|
||||
.filter((n) => n.node_type === 'trigger' && n.trigger_event === 'clavier' && n.trigger_key === ev.key)
|
||||
.forEach((n) => { runFlowFrom(n.id, screenIdKey); });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -235,24 +233,24 @@ function bindKeyboardTriggers() {
|
||||
// runScreenTimerTriggers). "touche_relachee" (keyup), lui, n'a pas besoin
|
||||
// d'être scopé par écran — un seul déclenchement ponctuel, même patron de
|
||||
// scan global que "clavier".
|
||||
var heldKeys = new Set();
|
||||
const heldKeys = new Set();
|
||||
|
||||
function bindHeldKeyTriggers() {
|
||||
window.addEventListener('keydown', function (ev) {
|
||||
window.addEventListener('keydown', (ev) => {
|
||||
if (isGameKey(ev.key)) ev.preventDefault();
|
||||
heldKeys.add(ev.key);
|
||||
});
|
||||
window.addEventListener('keyup', function (ev) {
|
||||
window.addEventListener('keyup', (ev) => {
|
||||
heldKeys.delete(ev.key);
|
||||
for (const screenIdKey in gameData.flows) {
|
||||
gameData.flows[screenIdKey].nodes
|
||||
.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'touche_relachee' && n.trigger_key === ev.key; })
|
||||
.forEach(function(n){ runFlowFrom(n.id, screenIdKey); });
|
||||
.filter((n) => n.node_type === 'trigger' && n.trigger_event === 'touche_relachee' && n.trigger_key === ev.key)
|
||||
.forEach((n) => { runFlowFrom(n.id, screenIdKey); });
|
||||
}
|
||||
});
|
||||
// La fenêtre peut perdre le focus touche encore enfoncée (alt-tab...) —
|
||||
// sans ça, heldKeys garderait une touche "fantôme" indéfiniment maintenue.
|
||||
window.addEventListener('blur', function () { heldKeys.clear(); });
|
||||
window.addEventListener('blur', () => { heldKeys.clear(); });
|
||||
}
|
||||
|
||||
// Même patron PAR ÉCRAN que runScreenTimerTriggers() ci-dessus : n'existe
|
||||
@@ -260,17 +258,20 @@ function bindHeldKeyTriggers() {
|
||||
// à chaque changement d'écran (voir showScreen(), static/js/play/screens.js).
|
||||
// 50ms (20 fois/seconde) : assez fluide pour un déplacement perçu comme
|
||||
// continu, sans re-exécuter le graphe à une fréquence excessive.
|
||||
var activeHeldKeyTriggerInterval = null;
|
||||
let activeHeldKeyTriggerInterval = null;
|
||||
|
||||
function runScreenHeldKeyTriggers(screenId) {
|
||||
if (activeHeldKeyTriggerInterval) { clearInterval(activeHeldKeyTriggerInterval); activeHeldKeyTriggerInterval = null; }
|
||||
if (activeHeldKeyTriggerInterval) {
|
||||
clearInterval(activeHeldKeyTriggerInterval);
|
||||
activeHeldKeyTriggerInterval = null;
|
||||
}
|
||||
const flow = gameData.flows[String(screenId)];
|
||||
if (!flow) return;
|
||||
const nodes = flow.nodes.filter(function(n){ return n.node_type === 'trigger' && n.trigger_event === 'touche_maintenue'; });
|
||||
const nodes = flow.nodes.filter((n) => n.node_type === 'trigger' && n.trigger_event === 'touche_maintenue');
|
||||
if (!nodes.length) return;
|
||||
activeHeldKeyTriggerInterval = setInterval(function () {
|
||||
nodes.filter(function(n){ return heldKeys.has(n.trigger_key); })
|
||||
.forEach(function(n){ runFlowFrom(n.id, screenId); });
|
||||
activeHeldKeyTriggerInterval = setInterval(() => {
|
||||
nodes.filter((n) => heldKeys.has(n.trigger_key))
|
||||
.forEach((n) => { runFlowFrom(n.id, screenId); });
|
||||
}, 50);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,15 +5,14 @@
|
||||
// overlay posé sur <body>, réutilisé pour chaque lecture (pas la peine
|
||||
// d'en recréer un par appel).
|
||||
|
||||
var forgeVideoOverlayEl = null;
|
||||
let forgeVideoOverlayEl = null;
|
||||
|
||||
function forgeVideoOverlayRoot() {
|
||||
if (forgeVideoOverlayEl) return forgeVideoOverlayEl;
|
||||
var el = document.createElement('div');
|
||||
const el = document.createElement('div');
|
||||
el.className = 'forgeVideoOverlay';
|
||||
el.innerHTML =
|
||||
'<video class="forgeVideoOverlayPlayer" playsinline></video>' +
|
||||
'<button type="button" class="forgeVideoOverlaySkip">Passer ▶</button>';
|
||||
el.innerHTML = '<video class="forgeVideoOverlayPlayer" playsinline></video>'
|
||||
+ '<button type="button" class="forgeVideoOverlaySkip">Passer ▶</button>';
|
||||
document.body.appendChild(el);
|
||||
forgeVideoOverlayEl = el;
|
||||
return el;
|
||||
@@ -27,12 +26,12 @@ function forgeShowVideoOverlay(url, mode, onDone) {
|
||||
if (onDone) onDone();
|
||||
return;
|
||||
}
|
||||
var root = forgeVideoOverlayRoot();
|
||||
root.className = 'forgeVideoOverlay ' + (mode === 'incrustation' ? 'forgeVideoOverlay-incrustation' : 'forgeVideoOverlay-pleinEcran');
|
||||
const root = forgeVideoOverlayRoot();
|
||||
root.className = `forgeVideoOverlay ${mode === 'incrustation' ? 'forgeVideoOverlay-incrustation' : 'forgeVideoOverlay-pleinEcran'}`;
|
||||
root.style.display = 'flex';
|
||||
var player = root.querySelector('.forgeVideoOverlayPlayer');
|
||||
var skipBtn = root.querySelector('.forgeVideoOverlaySkip');
|
||||
var finished = false;
|
||||
const player = root.querySelector('.forgeVideoOverlayPlayer');
|
||||
const skipBtn = root.querySelector('.forgeVideoOverlaySkip');
|
||||
let finished = false;
|
||||
|
||||
function finish() {
|
||||
if (finished) return;
|
||||
@@ -48,7 +47,9 @@ function forgeShowVideoOverlay(url, mode, onDone) {
|
||||
player.src = url;
|
||||
player.onended = finish;
|
||||
skipBtn.onclick = finish;
|
||||
player.play().catch(function () {}); // lecture bloquée par le navigateur (pas d'interaction récente) : reste affichée, "Passer" fonctionne toujours.
|
||||
// lecture bloquée par le navigateur (pas d'interaction récente) : reste
|
||||
// affichée, "Passer" fonctionne toujours.
|
||||
player.play().catch(() => {});
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même garde que le reste de
|
||||
|
||||
@@ -17,13 +17,17 @@ function loadAsScript(relativePath) {
|
||||
|
||||
function makeEl() {
|
||||
const el = {
|
||||
_html: '', style: {}, value: '',
|
||||
_html: '',
|
||||
style: {},
|
||||
value: '',
|
||||
classList: { add() {}, remove() {}, toggle() {} },
|
||||
children: [],
|
||||
appendChild(c) { this.children.push(c); },
|
||||
querySelector() { return null; },
|
||||
setAttribute() {}, getAttribute() { return null; },
|
||||
addEventListener() {}, removeEventListener() {},
|
||||
setAttribute() {},
|
||||
getAttribute() { return null; },
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
Object.defineProperty(el, 'innerHTML', {
|
||||
get() { return this._html; },
|
||||
@@ -90,7 +94,7 @@ test('assistant — une chaîne "attendre -> puis dialogue" sauvegarde bien LES
|
||||
|
||||
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;
|
||||
const { action } = saveCall.body.rule;
|
||||
assert.equal(action.type, 'attendre');
|
||||
assert.equal(action.data_value, '2');
|
||||
assert.equal(action.then.type, 'dialogue');
|
||||
@@ -114,7 +118,7 @@ test('assistant — une chaîne de 3 actions ("variable" -> "attendre" -> "dialo
|
||||
global.collisionWizardChainDone('none');
|
||||
|
||||
const saveCall = posted.find((p) => p.url === '/game/pytest_wizard/screens/7/triggers');
|
||||
const action = saveCall.body.rule.action;
|
||||
const { action } = saveCall.body.rule;
|
||||
assert.equal(action.type, 'variable');
|
||||
assert.equal(action.then.type, 'attendre');
|
||||
assert.equal(action.then.data_value, '1.5');
|
||||
@@ -131,7 +135,7 @@ test('assistant — une seule action ("Rien de plus" tout de suite) sauvegarde b
|
||||
global.collisionWizardChainDone('none');
|
||||
|
||||
const saveCall = posted.find((p) => p.url === '/game/pytest_wizard/screens/7/triggers');
|
||||
const action = saveCall.body.rule.action;
|
||||
const { action } = saveCall.body.rule;
|
||||
assert.equal(action.type, 'dialogue');
|
||||
assert.equal(action.then, undefined);
|
||||
});
|
||||
@@ -142,9 +146,15 @@ test('assistant — l\'action "vidéo" ne propose QUE les fichiers vidéo (jamai
|
||||
// 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' },
|
||||
{
|
||||
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');
|
||||
|
||||
@@ -161,9 +171,15 @@ test('assistant — l\'action "vidéo" ne propose QUE les fichiers vidéo (jamai
|
||||
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' },
|
||||
{
|
||||
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');
|
||||
|
||||
@@ -183,7 +199,9 @@ test('triggerActionChainHtml — un bouton "+ Ajouter une action" apparaît apr
|
||||
// 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: '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: [] } },
|
||||
];
|
||||
|
||||
@@ -9,30 +9,37 @@
|
||||
// exacte d'une règle (côté serveur, où elle est validée une seconde fois
|
||||
// avant d'être enregistrée).
|
||||
|
||||
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',
|
||||
const 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',
|
||||
interagir: 'Interagir avec une touche',
|
||||
condition: 'Si une condition est vraie',
|
||||
};
|
||||
// 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'];
|
||||
const 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' };
|
||||
const COLLISION_CHAIN_CHOICES = ['dialogue', 'variable', 'surbrillance', 'visibilite', 'son', 'video', 'indication', 'attendre'];
|
||||
const 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
|
||||
// pour celles-ci.
|
||||
var COLLISION_NO_VALUE_OPERATIONS = ['definir_bool_vrai', 'definir_bool_faux', 'basculer_bool'];
|
||||
const COLLISION_NO_VALUE_OPERATIONS = ['definir_bool_vrai', 'definir_bool_faux', 'basculer_bool'];
|
||||
|
||||
// obj.thumbnail_html (screens.render_scene_object) porte un style inline
|
||||
// PENSÉ POUR LE CANEVAS (position:absolute + left/top en dur, voir
|
||||
@@ -46,8 +53,8 @@ var COLLISION_NO_VALUE_OPERATIONS = ['definir_bool_vrai', 'definir_bool_faux', '
|
||||
// (l'image elle-même) est repris. Utilisée par static/js/triggers/
|
||||
// trigger-editor.js (chaîne de bulles + grille de choix d'objet).
|
||||
function collisionRuleThumbHtml(obj) {
|
||||
var match = /\ssrc="([^"]*)"/.exec(obj.thumbnail_html || '');
|
||||
return match ? '<img src="' + match[1] + '" alt="">' : '';
|
||||
const match = /\ssrc="([^"]*)"/.exec(obj.thumbnail_html || '');
|
||||
return match ? `<img src="${match[1]}" alt="">` : '';
|
||||
}
|
||||
|
||||
// Id court généré CÔTÉ CLIENT pour une NOUVELLE feuille ("dialogue" ou
|
||||
@@ -58,7 +65,7 @@ function collisionRuleThumbHtml(obj) {
|
||||
// permet d'adresser CETTE feuille précise pour y attacher un "then"
|
||||
// plus tard (voir "+ Ajouter une action", trigger-editor.js).
|
||||
function collisionGenerateActionId() {
|
||||
return 'd_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
return `d_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; // NOSONAR S2245 - id local UI, jamais crypto
|
||||
}
|
||||
|
||||
// ---------- Assistant "déclencheur -> action" ----------
|
||||
@@ -68,12 +75,14 @@ function collisionGenerateActionId() {
|
||||
// `action`, ou dans `action.sub_action` au second niveau pour "interagir").
|
||||
// Plus de "quête cible" à connaître d'avance — la règle est posée sur
|
||||
// l'objet, un déclencheur "dialogue" se suffit à lui-même.
|
||||
var _collisionWizard = null;
|
||||
let _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 = { target: 'object', objectId: objectId, trigger: null, bodyEl: bodyEl };
|
||||
_collisionWizard = {
|
||||
target: 'object', objectId, trigger: null, bodyEl,
|
||||
};
|
||||
renderCollisionWizardTriggerStep();
|
||||
}
|
||||
|
||||
@@ -83,7 +92,9 @@ function openTriggerWizard(objectId, bodyEl) {
|
||||
// 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 };
|
||||
_collisionWizard = {
|
||||
target: 'screen', screenId, trigger: null, bodyEl,
|
||||
};
|
||||
renderCollisionWizardTriggerStep();
|
||||
}
|
||||
|
||||
@@ -96,15 +107,12 @@ function collisionWizardBodyEl() {
|
||||
// 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">' + buttons + '</div>';
|
||||
const keys = _collisionWizard.target === 'screen' ? ['affichage'] : ['collision', 'clic', 'survol'];
|
||||
const buttons = keys.map((key, i) => `<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">${buttons}</div>`;
|
||||
}
|
||||
|
||||
function collisionWizardChooseTrigger(trigger) {
|
||||
@@ -119,17 +127,14 @@ function collisionWizardChooseTrigger(trigger) {
|
||||
// (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 || _collisionWizard.trigger !== 'collision')
|
||||
? COLLISION_WIZARD_ACTION_TYPES.filter(function (t) { return t !== 'interagir'; })
|
||||
const types = (nested || _collisionWizard.trigger !== 'collision')
|
||||
? COLLISION_WIZARD_ACTION_TYPES.filter((t) => 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>' +
|
||||
'</button>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">' + (nested ? 'Une fois la touche pressée...' : 'Alors...') + '</p>' +
|
||||
'<div class="collisionWizardChoices">' + buttons + '</div>';
|
||||
const buttons = types.map((type, i) => `<button type="button" class="collisionWizardChoice" style="animation-delay:${i * 40}ms" onclick="collisionWizardChooseAction('${type}', ${nested ? 'true' : 'false'})">`
|
||||
+ `<span>${COLLISION_ACTION_LABELS[type]}</span>`
|
||||
+ '</button>').join('');
|
||||
collisionWizardBodyEl().innerHTML = `<p class="hint mb-2">${nested ? 'Une fois la touche pressée...' : 'Alors...'}</p>`
|
||||
+ `<div class="collisionWizardChoices">${buttons}</div>`;
|
||||
}
|
||||
|
||||
function collisionWizardFinalizeAction(action, nested) {
|
||||
@@ -156,17 +161,18 @@ function collisionWizardFinalizeAction(action, nested) {
|
||||
// "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 };
|
||||
const rule = { trigger: _collisionWizard.trigger, action };
|
||||
closeTriggerModal();
|
||||
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 };
|
||||
const isScreen = _collisionWizard.target === 'screen';
|
||||
const url = isScreen
|
||||
? `/game/${GAME_SLUG}/screens/${_collisionWizard.screenId}/triggers`
|
||||
: `/game/${GAME_SLUG}/triggers`;
|
||||
const body = isScreen ? { rule } : { object_id: _collisionWizard.objectId, rule };
|
||||
fetch(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then(function () { isScreen ? screenTriggerLoadAll() : triggerLoadAll(); });
|
||||
}).then(() => { isScreen ? screenTriggerLoadAll() : triggerLoadAll(); });
|
||||
}
|
||||
|
||||
// Construit une FEUILLE du type demandé — passe par ses éventuelles
|
||||
@@ -188,53 +194,56 @@ function collisionWizardBuildLeafAction(type, onBuilt) {
|
||||
return;
|
||||
}
|
||||
if (type === 'surbrillance' || type === 'visibilite') {
|
||||
renderCollisionWizardObjectTargetStep(function (objectId) {
|
||||
renderCollisionWizardValeurStep(type, function (valeur) {
|
||||
onBuilt({ type: type, id: collisionGenerateActionId(), object_id: objectId, valeur: valeur });
|
||||
renderCollisionWizardObjectTargetStep((objectId) => {
|
||||
renderCollisionWizardValeurStep(type, (valeur) => {
|
||||
onBuilt({
|
||||
type, id: collisionGenerateActionId(), object_id: objectId, valeur,
|
||||
});
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (type === 'son') {
|
||||
renderCollisionWizardAssetStep(function (assetUrl) {
|
||||
renderCollisionWizardAssetStep((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 });
|
||||
renderCollisionWizardAssetStep((assetUrl) => {
|
||||
renderCollisionWizardVideoModeStep((mode) => {
|
||||
onBuilt({
|
||||
type: 'video', id: collisionGenerateActionId(), asset_url: assetUrl, mode,
|
||||
});
|
||||
});
|
||||
}, 'video');
|
||||
return;
|
||||
}
|
||||
if (type === 'indication') {
|
||||
renderCollisionWizardObjectTargetStep(function (objectId) {
|
||||
renderCollisionWizardObjectTargetStep((objectId) => {
|
||||
_collisionWizard.pendingIndicationObjectId = objectId;
|
||||
renderCollisionWizardIndicationStep(function (action) { onBuilt(action); });
|
||||
renderCollisionWizardIndicationStep((action) => { onBuilt(action); });
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 'attendre'
|
||||
renderCollisionWizardAttendreStep(function (secondes) {
|
||||
renderCollisionWizardAttendreStep((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>';
|
||||
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;
|
||||
const raw = document.getElementById('attendreWizSecondes').value;
|
||||
const secondes = String(Math.max(0, parseFloat(raw) || 0));
|
||||
const onChoose = _collisionWizard.onAttendreChosen;
|
||||
_collisionWizard.onAttendreChosen = null;
|
||||
onChoose(secondes);
|
||||
}
|
||||
@@ -246,27 +255,24 @@ function collisionWizardAttendreNext() {
|
||||
// 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');
|
||||
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((r) => r.json())
|
||||
.then((objects) => {
|
||||
const 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('');
|
||||
grid.innerHTML = objects.map((o, i) => `<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;
|
||||
const onChoose = _collisionWizard.onObjectTargetChosen;
|
||||
_collisionWizard.onObjectTargetChosen = null;
|
||||
onChoose(objectId);
|
||||
}
|
||||
@@ -276,21 +282,18 @@ function collisionWizardObjectTargetChosen(objectId) {
|
||||
// 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>';
|
||||
const labelsMap = actionType === 'surbrillance' ? SURBRILLANCE_LABELS_MAP : ELEMENT_VISIBILITY_LABELS_MAP;
|
||||
const keys = Object.keys(labelsMap);
|
||||
const buttons = keys.map((key, i) => `<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;
|
||||
const onChoose = _collisionWizard.onValeurChosen;
|
||||
_collisionWizard.onValeurChosen = null;
|
||||
onChoose(valeur);
|
||||
}
|
||||
@@ -305,46 +308,39 @@ function collisionWizardValeurChosen(valeur) {
|
||||
// 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; });
|
||||
const assets = USER_ASSETS_OPTIONS.filter((a) => 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>';
|
||||
const 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>';
|
||||
const options = assets.map((a) => `<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;
|
||||
const url = document.getElementById('collisionWizardAssetSelect').value;
|
||||
const 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>';
|
||||
const keys = Object.keys(VIDEO_MODE_LABELS_MAP);
|
||||
const buttons = keys.map((key, i) => `<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;
|
||||
const onChoose = _collisionWizard.onVideoModeChosen;
|
||||
_collisionWizard.onVideoModeChosen = null;
|
||||
onChoose(mode);
|
||||
}
|
||||
@@ -354,22 +350,23 @@ function collisionWizardVideoModeChosen(mode) {
|
||||
// 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>';
|
||||
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 };
|
||||
const texte = document.getElementById('indicationWizText').value;
|
||||
const dureeRaw = document.getElementById('indicationWizDuree').value;
|
||||
const action = {
|
||||
type: 'indication', id: collisionGenerateActionId(), object_id: _collisionWizard.pendingIndicationObjectId, texte,
|
||||
};
|
||||
if (dureeRaw) action.duree_ms = parseInt(dureeRaw, 10);
|
||||
_collisionWizard.pendingIndicationObjectId = null;
|
||||
var onChoose = _collisionWizard.onIndicationChosen;
|
||||
const onChoose = _collisionWizard.onIndicationChosen;
|
||||
_collisionWizard.onIndicationChosen = null;
|
||||
onChoose(action);
|
||||
}
|
||||
@@ -384,15 +381,15 @@ function collisionWizardIndicationNext() {
|
||||
// mini-formulaire (voir renderCollisionWizardConditionStep).
|
||||
function collisionWizardChooseAction(type, nested) {
|
||||
if (type === 'interagir') {
|
||||
collisionWizardFinalizeAction({ type: type }, nested);
|
||||
collisionWizardFinalizeAction({ type }, nested);
|
||||
return;
|
||||
}
|
||||
if (type === 'condition') {
|
||||
renderCollisionWizardConditionStep(nested);
|
||||
return;
|
||||
}
|
||||
collisionWizardBuildLeafAction(type, function (leaf) {
|
||||
renderCollisionWizardChainStep(leaf, function (finalLeaf) { collisionWizardFinalizeAction(finalLeaf, nested); });
|
||||
collisionWizardBuildLeafAction(type, (leaf) => {
|
||||
renderCollisionWizardChainStep(leaf, (finalLeaf) => { collisionWizardFinalizeAction(finalLeaf, nested); });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -404,40 +401,38 @@ function collisionWizardChooseAction(type, nested) {
|
||||
// valeur (masquée pour les opérations qui n'en ont pas besoin, voir
|
||||
// COLLISION_NO_VALUE_OPERATIONS). `onChoose(action)`.
|
||||
function renderCollisionWizardVariableStep(onChoose) {
|
||||
var variableOptions = (typeof GLOBAL_VARIABLE_NAMES !== 'undefined' ? GLOBAL_VARIABLE_NAMES : [])
|
||||
.map(function (name) { return '<option value="' + name + '">' + name + '</option>'; }).join('');
|
||||
var operationOptions = Object.keys(DATA_OPERATION_LABELS_MAP).map(function (key) {
|
||||
return '<option value="' + key + '">' + DATA_OPERATION_LABELS_MAP[key] + '</option>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Modifier...</p>' +
|
||||
'<div class="collisionWizardConditionForm">' +
|
||||
'<select id="varWizVariable">' + variableOptions + '</select>' +
|
||||
'<select id="varWizOperation" onchange="collisionWizardVariableOperationChanged()">' + operationOptions + '</select>' +
|
||||
'<input type="text" id="varWizValue" placeholder="Valeur">' +
|
||||
'</div>' +
|
||||
'<button type="button" class="button primary mt-3" onclick="collisionWizardVariableNext()">Continuer</button>';
|
||||
const variableOptions = (typeof GLOBAL_VARIABLE_NAMES !== 'undefined' ? GLOBAL_VARIABLE_NAMES : [])
|
||||
.map((name) => `<option value="${name}">${name}</option>`).join('');
|
||||
const operationOptions = Object.keys(DATA_OPERATION_LABELS_MAP).map((key) => `<option value="${key}">${DATA_OPERATION_LABELS_MAP[key]}</option>`).join('');
|
||||
collisionWizardBodyEl().innerHTML = '<p class="hint mb-2">Modifier...</p>'
|
||||
+ '<div class="collisionWizardConditionForm">'
|
||||
+ `<select id="varWizVariable">${variableOptions}</select>`
|
||||
+ `<select id="varWizOperation" onchange="collisionWizardVariableOperationChanged()">${operationOptions}</select>`
|
||||
+ '<input type="text" id="varWizValue" placeholder="Valeur">'
|
||||
+ '</div>'
|
||||
+ '<button type="button" class="button primary mt-3" onclick="collisionWizardVariableNext()">Continuer</button>';
|
||||
_collisionWizard.onVariableChosen = onChoose;
|
||||
collisionWizardVariableOperationChanged();
|
||||
}
|
||||
|
||||
function collisionWizardVariableOperationChanged() {
|
||||
var operation = document.getElementById('varWizOperation').value;
|
||||
var valueInput = document.getElementById('varWizValue');
|
||||
const operation = document.getElementById('varWizOperation').value;
|
||||
const valueInput = document.getElementById('varWizValue');
|
||||
valueInput.style.display = COLLISION_NO_VALUE_OPERATIONS.indexOf(operation) === -1 ? '' : 'none';
|
||||
}
|
||||
|
||||
function collisionWizardVariableNext() {
|
||||
var operation = document.getElementById('varWizOperation').value;
|
||||
var action = {
|
||||
type: 'variable', id: collisionGenerateActionId(),
|
||||
const operation = document.getElementById('varWizOperation').value;
|
||||
const action = {
|
||||
type: 'variable',
|
||||
id: collisionGenerateActionId(),
|
||||
target_variable: document.getElementById('varWizVariable').value,
|
||||
data_operation: operation,
|
||||
};
|
||||
if (COLLISION_NO_VALUE_OPERATIONS.indexOf(operation) === -1) {
|
||||
action.data_value = document.getElementById('varWizValue').value;
|
||||
}
|
||||
var onChoose = _collisionWizard.onVariableChosen;
|
||||
const onChoose = _collisionWizard.onVariableChosen;
|
||||
_collisionWizard.onVariableChosen = null;
|
||||
onChoose(action);
|
||||
}
|
||||
@@ -461,30 +456,27 @@ function collisionWizardVariableNext() {
|
||||
// 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>'
|
||||
const 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>' +
|
||||
choiceButtons +
|
||||
'</div>';
|
||||
const choiceButtons = COLLISION_CHAIN_CHOICES.map((choice, i) => `<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>${
|
||||
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;
|
||||
const leafAction = _collisionWizard.chainLeaf;
|
||||
const headAction = _collisionWizard.chainHead;
|
||||
const onDone = _collisionWizard.onChainDone;
|
||||
if (leafAction.type === 'dialogue') {
|
||||
if (document.getElementById('condWizMarkCompleted').checked) leafAction.mark_completed = true;
|
||||
else delete leafAction.mark_completed;
|
||||
@@ -493,8 +485,8 @@ function collisionWizardChainDone(choice) {
|
||||
onDone(headAction);
|
||||
return;
|
||||
}
|
||||
collisionWizardBuildLeafAction(choice, function (nextLeaf) {
|
||||
leafAction.then = nextLeaf;
|
||||
collisionWizardBuildLeafAction(choice, (nextLeaf) => {
|
||||
leafAction.then = nextLeaf; // NOSONAR S7739 - chainage d'actions (donnee), jamais une promesse/await
|
||||
renderCollisionWizardChainStep(nextLeaf, onDone, headAction);
|
||||
});
|
||||
}
|
||||
@@ -506,19 +498,16 @@ function collisionWizardChainDone(choice) {
|
||||
// `nested` (interagir -> condition) est reporté jusqu'à la finalisation,
|
||||
// une fois les deux branches Vrai/Faux choisies ci-dessous.
|
||||
function renderCollisionWizardConditionStep(nested) {
|
||||
var variableOptions = (typeof GLOBAL_VARIABLE_NAMES !== 'undefined' ? GLOBAL_VARIABLE_NAMES : [])
|
||||
.map(function (name) { return '<option value="' + name + '">' + name + '</option>'; }).join('');
|
||||
var operatorOptions = Object.keys(CONDITION_OPERATOR_LABELS_MAP).map(function (key) {
|
||||
return '<option value="' + key + '">' + CONDITION_OPERATOR_LABELS_MAP[key] + '</option>';
|
||||
}).join('');
|
||||
collisionWizardBodyEl().innerHTML =
|
||||
'<p class="hint mb-2">Si...</p>' +
|
||||
'<div class="collisionWizardConditionForm">' +
|
||||
'<select id="condWizVariable">' + variableOptions + '</select>' +
|
||||
'<select id="condWizOperator">' + operatorOptions + '</select>' +
|
||||
'<input type="text" id="condWizValue" placeholder="Valeur">' +
|
||||
'</div>' +
|
||||
'<button type="button" class="button primary mt-3" onclick="collisionWizardConditionNext(' + (nested ? 'true' : 'false') + ')">Continuer</button>';
|
||||
const variableOptions = (typeof GLOBAL_VARIABLE_NAMES !== 'undefined' ? GLOBAL_VARIABLE_NAMES : [])
|
||||
.map((name) => `<option value="${name}">${name}</option>`).join('');
|
||||
const operatorOptions = Object.keys(CONDITION_OPERATOR_LABELS_MAP).map((key) => `<option value="${key}">${CONDITION_OPERATOR_LABELS_MAP[key]}</option>`).join('');
|
||||
collisionWizardBodyEl().innerHTML = '<p class="hint mb-2">Si...</p>'
|
||||
+ '<div class="collisionWizardConditionForm">'
|
||||
+ `<select id="condWizVariable">${variableOptions}</select>`
|
||||
+ `<select id="condWizOperator">${operatorOptions}</select>`
|
||||
+ '<input type="text" id="condWizValue" placeholder="Valeur">'
|
||||
+ '</div>'
|
||||
+ `<button type="button" class="button primary mt-3" onclick="collisionWizardConditionNext(${nested ? 'true' : 'false'})">Continuer</button>`;
|
||||
}
|
||||
|
||||
function collisionWizardConditionNext(nested) {
|
||||
@@ -527,7 +516,8 @@ function collisionWizardConditionNext(nested) {
|
||||
variable: document.getElementById('condWizVariable').value,
|
||||
operateur: document.getElementById('condWizOperator').value,
|
||||
valeur: document.getElementById('condWizValue').value,
|
||||
si_vrai: null, si_faux: null,
|
||||
si_vrai: null,
|
||||
si_faux: null,
|
||||
};
|
||||
_collisionWizard.pendingConditionNested = nested;
|
||||
renderCollisionWizardBranchStep('si_vrai');
|
||||
@@ -541,18 +531,15 @@ function collisionWizardConditionNext(nested) {
|
||||
// peuvent chacune déclencher LEUR PROPRE feuille (et sa propre chaîne),
|
||||
// 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">' +
|
||||
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>';
|
||||
const label = branchKey === 'si_vrai' ? '✓ Si VRAI...' : '✕ Si FAUX...';
|
||||
const choiceButtons = COLLISION_CHAIN_CHOICES.map((choice, i) => `<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">${
|
||||
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>';
|
||||
}
|
||||
|
||||
function collisionWizardChooseBranch(branchKey, choice) {
|
||||
@@ -561,8 +548,8 @@ function collisionWizardChooseBranch(branchKey, choice) {
|
||||
collisionWizardBranchDone(branchKey);
|
||||
return;
|
||||
}
|
||||
collisionWizardBuildLeafAction(choice, function (leaf) {
|
||||
renderCollisionWizardChainStep(leaf, function (finalLeaf) {
|
||||
collisionWizardBuildLeafAction(choice, (leaf) => {
|
||||
renderCollisionWizardChainStep(leaf, (finalLeaf) => {
|
||||
_collisionWizard.pendingCondition[branchKey] = finalLeaf;
|
||||
collisionWizardBranchDone(branchKey);
|
||||
});
|
||||
@@ -574,8 +561,8 @@ function collisionWizardBranchDone(branchKey) {
|
||||
renderCollisionWizardBranchStep('si_faux');
|
||||
return;
|
||||
}
|
||||
var nested = _collisionWizard.pendingConditionNested;
|
||||
var condition = _collisionWizard.pendingCondition;
|
||||
const nested = _collisionWizard.pendingConditionNested;
|
||||
const condition = _collisionWizard.pendingCondition;
|
||||
_collisionWizard.pendingCondition = null;
|
||||
collisionWizardFinalizeAction(condition, nested);
|
||||
}
|
||||
|
||||
@@ -7,61 +7,61 @@
|
||||
// besoin d'un bouton "Enregistrer" séparé (demande explicite : "que la
|
||||
// modification s'enregistre automatiquement").
|
||||
function saveGlobalVariable(varId) {
|
||||
var row = document.querySelector('tr[data-var-row="' + varId + '"]');
|
||||
const row = document.querySelector(`tr[data-var-row="${varId}"]`);
|
||||
if (!row) return;
|
||||
var type = row.querySelector('select[name="type"]').value;
|
||||
var value = row.querySelector('input[name="value"]').value;
|
||||
var status = document.getElementById('varSaveStatus' + varId);
|
||||
var body = new FormData();
|
||||
const type = row.querySelector('select[name="type"]').value;
|
||||
const { value } = row.querySelector('input[name="value"]');
|
||||
const status = document.getElementById(`varSaveStatus${varId}`);
|
||||
const body = new FormData();
|
||||
body.append('type', type);
|
||||
body.append('value', value);
|
||||
fetch('/game/' + GAME_SLUG + '/variables/' + varId + '/edit', {
|
||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: body,
|
||||
fetch(`/game/${GAME_SLUG}/variables/${varId}/edit`, {
|
||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body,
|
||||
})
|
||||
.then(function (r) {
|
||||
.then((r) => {
|
||||
if (!r.ok) { window.location.reload(); return; }
|
||||
if (status) {
|
||||
status.textContent = 'Enregistré ✓';
|
||||
setTimeout(function () { status.textContent = ''; }, 2000);
|
||||
setTimeout(() => { status.textContent = ''; }, 2000);
|
||||
}
|
||||
})
|
||||
.catch(function () { window.location.reload(); });
|
||||
.catch(() => { window.location.reload(); });
|
||||
}
|
||||
|
||||
function deleteGlobalVariable(varId, name) {
|
||||
if (!confirm('Supprimer la variable « ' + name + ' » ? Toute condition/action qui la référence encore cessera de fonctionner.')) return;
|
||||
var body = new FormData();
|
||||
fetch('/game/' + GAME_SLUG + '/variables/' + varId + '/delete', {
|
||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: body,
|
||||
if (!window.confirm(`Supprimer la variable « ${name} » ? Toute condition/action qui la référence encore cessera de fonctionner.`)) return;
|
||||
const body = new FormData();
|
||||
fetch(`/game/${GAME_SLUG}/variables/${varId}/delete`, {
|
||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body,
|
||||
})
|
||||
.then(function (r) {
|
||||
.then((r) => {
|
||||
if (!r.ok) { window.location.reload(); return; }
|
||||
var row = document.querySelector('tr[data-var-row="' + varId + '"]');
|
||||
const row = document.querySelector(`tr[data-var-row="${varId}"]`);
|
||||
if (row) row.remove();
|
||||
var body2 = document.getElementById('globalVariablesBody');
|
||||
var emptyHint = document.getElementById('globalVariablesEmptyHint');
|
||||
const body2 = document.getElementById('globalVariablesBody');
|
||||
const emptyHint = document.getElementById('globalVariablesEmptyHint');
|
||||
if (body2 && emptyHint) emptyHint.style.display = body2.children.length ? 'none' : '';
|
||||
})
|
||||
.catch(function () { window.location.reload(); });
|
||||
.catch(() => { window.location.reload(); });
|
||||
}
|
||||
|
||||
function createGlobalVariable(event) {
|
||||
event.preventDefault();
|
||||
var form = event.target;
|
||||
var body = new FormData(form);
|
||||
fetch('/game/' + GAME_SLUG + '/variables/new', {
|
||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body: body,
|
||||
const form = event.target;
|
||||
const body = new FormData(form);
|
||||
fetch(`/game/${GAME_SLUG}/variables/new`, {
|
||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body,
|
||||
})
|
||||
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
|
||||
.then(function (json) {
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||
.then((json) => {
|
||||
if (!json.row_html) { window.location.reload(); return; }
|
||||
var tbody = document.getElementById('globalVariablesBody');
|
||||
const tbody = document.getElementById('globalVariablesBody');
|
||||
tbody.insertAdjacentHTML('beforeend', json.row_html);
|
||||
var emptyHint = document.getElementById('globalVariablesEmptyHint');
|
||||
const emptyHint = document.getElementById('globalVariablesEmptyHint');
|
||||
if (emptyHint) emptyHint.style.display = 'none';
|
||||
form.reset();
|
||||
form.style.display = 'none'; // même comportement que toggleDashCreate() après création
|
||||
})
|
||||
.catch(function () { window.location.reload(); });
|
||||
.catch(() => { window.location.reload(); });
|
||||
return false;
|
||||
}
|
||||
|
||||
+281
-239
@@ -17,7 +17,7 @@
|
||||
// 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, x, y) {
|
||||
var body = new FormData();
|
||||
const body = new FormData();
|
||||
body.append('kind', kind);
|
||||
if (forgeCharacter) body.append('forge_character', forgeCharacter);
|
||||
if (backgroundSlug) body.append('background_slug', backgroundSlug);
|
||||
@@ -28,35 +28,34 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug, assetId, x, y) {
|
||||
// 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,
|
||||
fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/scene-objects/add`, {
|
||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' }, body,
|
||||
})
|
||||
.then(function (r) {
|
||||
.then((r) => {
|
||||
// Secours si le serveur répond encore par une redirection HTML
|
||||
// classique (ex. déploiement pas encore à jour côté serveur alors
|
||||
// que ce script, lui, l'est déjà) : l'objet est de toute façon déjà
|
||||
// posé en base à ce stade (voir screens/scenes/add_scene_object.py),
|
||||
// seul un rechargement peut alors l'afficher.
|
||||
var contentType = r.headers.get('content-type') || '';
|
||||
const contentType = r.headers.get('content-type') || '';
|
||||
if (!r.ok || contentType.indexOf('application/json') === -1) {
|
||||
window.location.reload();
|
||||
return null;
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(function (json) {
|
||||
.then((json) => {
|
||||
if (!json) return;
|
||||
var canvas = document.getElementById('canvas');
|
||||
var el = document.createElement('div');
|
||||
const canvas = document.getElementById('canvas');
|
||||
const el = document.createElement('div');
|
||||
el.className = 'canvasElement';
|
||||
el.id = 'el-' + json.id;
|
||||
el.id = `el-${json.id}`;
|
||||
el.style.position = 'absolute';
|
||||
el.style.left = json.x + 'px'; el.style.top = json.y + 'px';
|
||||
el.style.width = json.width + 'px'; el.style.height = json.height + 'px';
|
||||
el.setAttribute('onmousedown', 'onSceneObjectMouseDown(event, ' + json.id + ')');
|
||||
el.innerHTML =
|
||||
'<div class="canvasElementInner">' + json.rendered_html + '</div>' +
|
||||
'<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, ' + json.id + ')"></div>';
|
||||
el.style.left = `${json.x}px`; el.style.top = `${json.y}px`;
|
||||
el.style.width = `${json.width}px`; el.style.height = `${json.height}px`;
|
||||
el.setAttribute('onmousedown', `onSceneObjectMouseDown(event, ${json.id})`);
|
||||
el.innerHTML = `<div class="canvasElementInner">${json.rendered_html}</div>`
|
||||
+ `<div class="resizeHandle" onmousedown="onSceneObjectResizeMouseDown(event, ${json.id})"></div>`;
|
||||
canvas.appendChild(el);
|
||||
if (json.animations) {
|
||||
ELEMENT_ANIMATIONS_MAP[json.id] = json.animations;
|
||||
@@ -66,7 +65,7 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug, assetId, x, y) {
|
||||
// Filet de sécurité : mieux vaut un rechargement (l'objet est déjà en
|
||||
// base à ce stade) qu'un bouton "+ Ajouter" qui semble ne plus rien
|
||||
// faire du tout si jamais une erreur imprévue survient ici.
|
||||
.catch(function () { window.location.reload(); });
|
||||
.catch(() => { window.location.reload(); });
|
||||
}
|
||||
|
||||
// ---------- Vrai glisser-déposer depuis la galerie (demande explicite :
|
||||
@@ -83,8 +82,10 @@ function addSceneObject(kind, forgeCharacter, backgroundSlug, assetId, x, y) {
|
||||
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,
|
||||
kind,
|
||||
forgeCharacter: forgeCharacter || null,
|
||||
backgroundSlug: backgroundSlug || null,
|
||||
assetId: assetId || null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -98,18 +99,18 @@ function onCanvasDragOver(ev) {
|
||||
|
||||
function onCanvasDrop(ev) {
|
||||
ev.preventDefault();
|
||||
var raw = ev.dataTransfer.getData('application/json');
|
||||
const raw = ev.dataTransfer.getData('application/json');
|
||||
if (!raw) return;
|
||||
var data;
|
||||
let data;
|
||||
try { data = JSON.parse(raw); } catch (e) { return; }
|
||||
var canvas = document.getElementById('canvas');
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
const canvas = document.getElementById('canvas');
|
||||
const 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;
|
||||
const x = ev.clientX - rect.left;
|
||||
const y = ev.clientY - rect.top;
|
||||
addSceneObject(data.kind, data.forgeCharacter, data.backgroundSlug, data.assetId, x, y);
|
||||
}
|
||||
|
||||
@@ -123,24 +124,23 @@ function onCanvasDrop(ev) {
|
||||
// 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];
|
||||
const file = inputEl.files && inputEl.files[0];
|
||||
if (!file) return;
|
||||
var body = new FormData();
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
if (sceneKind) body.append('scene_kind', sceneKind);
|
||||
fetch('/assets/upload', { method: 'POST', body: body })
|
||||
.then(function (r) {
|
||||
fetch('/assets/upload', { method: 'POST', body })
|
||||
.then((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) {
|
||||
r.json().then((json) => ({ ok: r.ok, json })))
|
||||
.then((result) => {
|
||||
if (!result.ok) { alert(result.json.error || "Impossible d'importer ce fichier."); return; }
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function () { window.location.reload(); });
|
||||
.catch(() => { window.location.reload(); });
|
||||
}
|
||||
|
||||
// Suppression d'un fichier de "Mes assets" (demande explicite : "voir,
|
||||
@@ -149,14 +149,14 @@ function uploadUserAsset(inputEl, sceneKind) {
|
||||
// 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; }
|
||||
if (!window.confirm(`Supprimer "${name}" ? Cette action est définitive.`)) return;
|
||||
fetch(`/assets/${assetId}`, { method: 'DELETE' })
|
||||
.then((r) => r.json().then((json) => ({ ok: r.ok, json })))
|
||||
.then((result) => {
|
||||
if (!result.ok) { alert(result.json.error || 'Impossible de supprimer ce fichier.'); return; }
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function () { window.location.reload(); });
|
||||
.catch(() => { window.location.reload(); });
|
||||
}
|
||||
|
||||
// "→ Fond"/"→ Décor" (demande diagnostiquée depuis un "décalage" : une
|
||||
@@ -167,16 +167,17 @@ function deleteUserAsset(assetId, name) {
|
||||
// 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' },
|
||||
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; }
|
||||
.then((r) => r.json().then((json) => ({ ok: r.ok, json })))
|
||||
.then((result) => {
|
||||
if (!result.ok) { alert(result.json.error || 'Reclassement impossible.'); return; }
|
||||
window.location.reload();
|
||||
})
|
||||
.catch(function () { window.location.reload(); });
|
||||
.catch(() => { window.location.reload(); });
|
||||
}
|
||||
|
||||
// AJAX (voir routes/scenes/scene_object_delete.py) : bouton 🗑️ du panneau
|
||||
@@ -187,33 +188,33 @@ function reclassifyUserAsset(assetId, sceneKind) {
|
||||
// comme le faisait la soumission de formulaire classique interceptée par
|
||||
// pjax.js (voir son commentaire d'en-tête).
|
||||
function deleteSceneObject(id) {
|
||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + id + '/delete', {
|
||||
fetch(`/game/${GAME_SLUG}/scene-objects/${id}/delete`, {
|
||||
method: 'POST', headers: { 'X-Requested-With': 'fetch' },
|
||||
})
|
||||
.then(function (r) {
|
||||
var contentType = r.headers.get('content-type') || '';
|
||||
.then((r) => {
|
||||
const contentType = r.headers.get('content-type') || '';
|
||||
if (!r.ok || contentType.indexOf('application/json') === -1) {
|
||||
window.location.reload();
|
||||
return null;
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(function (json) {
|
||||
.then((json) => {
|
||||
if (!json || !json.ok) return;
|
||||
var el = document.getElementById('el-' + id);
|
||||
const el = document.getElementById(`el-${id}`);
|
||||
if (el) el.remove();
|
||||
if (typeof _stopPersonnageIdlePreview === 'function') _stopPersonnageIdlePreview(id);
|
||||
// L'objet supprimé était forcément le sélectionné (seul cas où 🗑️
|
||||
// est affiché) — referme le panneau de propriétés et l'URL, sans
|
||||
// requête supplémentaire au serveur.
|
||||
var propsBody = document.querySelector('#propsPanelFloat .floatPanelBody');
|
||||
const propsBody = document.querySelector('#propsPanelFloat .floatPanelBody');
|
||||
if (propsBody) {
|
||||
propsBody.innerHTML = '<div class="hint" style="padding:20px 0;">Sélectionne un objet (sur la scène ou dans la liste) pour voir et modifier ses propriétés ici.</div>';
|
||||
}
|
||||
CURRENT_SELECTED_ID = null;
|
||||
history.replaceState(null, '', SCREEN_EDIT_URL);
|
||||
window.history.replaceState(null, '', SCREEN_EDIT_URL);
|
||||
})
|
||||
.catch(function () { window.location.reload(); });
|
||||
.catch(() => { window.location.reload(); });
|
||||
}
|
||||
|
||||
// Taille de la caméra (voir "🎮 Jeu 2D" en haut de templates/
|
||||
@@ -224,19 +225,25 @@ function deleteSceneObject(id) {
|
||||
// éventuel doivent tous deux refléter la nouvelle taille, plus simple
|
||||
// qu'une mise à jour partielle en JS.
|
||||
function applyCameraSize() {
|
||||
var wEl = document.getElementById('scene-camera-w'), hEl = document.getElementById('scene-camera-h');
|
||||
var width = parseInt(wEl.value, 10) || 1, height = parseInt(hEl.value, 10) || 1;
|
||||
fetch("/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/scene-size", {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ width: width, height: height }),
|
||||
}).then(function () { window.location.reload(); });
|
||||
const wEl = document.getElementById('scene-camera-w'); const
|
||||
hEl = document.getElementById('scene-camera-h');
|
||||
const width = parseInt(wEl.value, 10) || 1; const
|
||||
height = parseInt(hEl.value, 10) || 1;
|
||||
fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/scene-size`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ width, height }),
|
||||
}).then(() => { window.location.reload(); });
|
||||
}
|
||||
|
||||
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) {
|
||||
return fetch(`/game/${GAME_SLUG}/scene-objects/${id}/geometry`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
x, y, width, height,
|
||||
}),
|
||||
}).then((r) => r.json()).then((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
|
||||
@@ -248,18 +255,20 @@ function saveSceneObjectGeometry(id, x, y, width, height) {
|
||||
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');
|
||||
const canvas = document.getElementById('canvas');
|
||||
if (canvas) {
|
||||
canvas.style.width = data.world_width + 'px';
|
||||
canvas.style.height = data.world_height + 'px';
|
||||
canvas.style.width = `${data.world_width}px`;
|
||||
canvas.style.height = `${data.world_height}px`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateScenePosFields(x, y, w, h) {
|
||||
var xEl = document.getElementById('scene-pos-x'), yEl = document.getElementById('scene-pos-y');
|
||||
var wEl = document.getElementById('scene-pos-w'), hEl = document.getElementById('scene-pos-h');
|
||||
const xEl = document.getElementById('scene-pos-x'); const
|
||||
yEl = document.getElementById('scene-pos-y');
|
||||
const wEl = document.getElementById('scene-pos-w'); const
|
||||
hEl = document.getElementById('scene-pos-h');
|
||||
if (xEl) xEl.value = Math.round(x);
|
||||
if (yEl) yEl.value = Math.round(y);
|
||||
if (wEl) wEl.value = Math.round(w);
|
||||
@@ -279,13 +288,15 @@ function updateScenePosFields(x, y, w, h) {
|
||||
// bouge plus (immobile près du bord) — mirror du comportement standard
|
||||
// d'un glisser-déposer proche d'un bord de zone scrollable.
|
||||
function forgeStartCanvasAutoScroll(getMouse, onNudge) {
|
||||
var MARGIN = 40, SPEED = 14;
|
||||
var timer = setInterval(function () {
|
||||
var frame = document.querySelector('.canvasFrame');
|
||||
const MARGIN = 40; const
|
||||
SPEED = 14;
|
||||
const timer = setInterval(() => {
|
||||
const frame = document.querySelector('.canvasFrame');
|
||||
if (!frame) return;
|
||||
var rect = frame.getBoundingClientRect();
|
||||
var mouse = getMouse();
|
||||
var dx = 0, dy = 0;
|
||||
const rect = frame.getBoundingClientRect();
|
||||
const mouse = getMouse();
|
||||
let dx = 0; let
|
||||
dy = 0;
|
||||
if (mouse.x > rect.right - MARGIN) dx = SPEED;
|
||||
else if (mouse.x < rect.left + MARGIN) dx = -SPEED;
|
||||
if (mouse.y > rect.bottom - MARGIN) dy = SPEED;
|
||||
@@ -309,12 +320,16 @@ function forgeStartCanvasAutoScroll(getMouse, onNudge) {
|
||||
function onSceneObjectMouseDown(e, id) {
|
||||
if (e.target.classList.contains('resizeHandle')) return;
|
||||
e.preventDefault();
|
||||
var el = document.getElementById('el-' + id);
|
||||
var startX = e.clientX, startY = e.clientY;
|
||||
var lastClientX = e.clientX, lastClientY = e.clientY;
|
||||
var curLeft = parseFloat(el.style.left) || 0, curTop = parseFloat(el.style.top) || 0;
|
||||
var width = parseFloat(el.style.width) || 0, height = parseFloat(el.style.height) || 0;
|
||||
var moved = false;
|
||||
const el = document.getElementById(`el-${id}`);
|
||||
const startX = e.clientX; const
|
||||
startY = e.clientY;
|
||||
let lastClientX = e.clientX; let
|
||||
lastClientY = e.clientY;
|
||||
let curLeft = parseFloat(el.style.left) || 0; let
|
||||
curTop = parseFloat(el.style.top) || 0;
|
||||
const width = parseFloat(el.style.width) || 0; const
|
||||
height = parseFloat(el.style.height) || 0;
|
||||
let moved = false;
|
||||
// Les panneaux flottants "🧩 Objets"/"⚙️ Propriétés" (voir static/style.css
|
||||
// .floatPanel, z-index:60) restent DÉLIBÉRÉMENT en position:fixed
|
||||
// PAR-DESSUS le canevas, pour maximiser l'espace utile — mais un objet
|
||||
@@ -324,7 +339,7 @@ function onSceneObjectMouseDown(e, id) {
|
||||
// le geste) : impossible de voir où on le dépose. Passé au-dessus des
|
||||
// panneaux (z-index très élevé) le temps du glisser SEULEMENT, restauré
|
||||
// à sa valeur d'origine à la fin.
|
||||
var previousZIndex = el.style.zIndex;
|
||||
const previousZIndex = el.style.zIndex;
|
||||
|
||||
// Bug corrigé ("toujours bloqué", confirmé lié à l'introduction du fond/
|
||||
// caméra, voir add_scene_object.py) : un "fond" est posé à sa taille
|
||||
@@ -338,15 +353,18 @@ function onSceneObjectMouseDown(e, id) {
|
||||
// à (0,0), impossible à repositionner. min/max sont maintenant calculés
|
||||
// dans le bon ordre quel que soit lequel des deux est le plus grand.
|
||||
function applyDelta(dx, dy) {
|
||||
var minLeft = Math.min(0, SCENE_WIDTH - width), maxLeft = Math.max(0, SCENE_WIDTH - width);
|
||||
var minTop = Math.min(0, SCENE_HEIGHT - height), maxTop = Math.max(0, SCENE_HEIGHT - height);
|
||||
const minLeft = Math.min(0, SCENE_WIDTH - width); const
|
||||
maxLeft = Math.max(0, SCENE_WIDTH - width);
|
||||
const minTop = Math.min(0, SCENE_HEIGHT - height); const
|
||||
maxTop = Math.max(0, SCENE_HEIGHT - height);
|
||||
curLeft = Math.max(minLeft, Math.min(maxLeft, curLeft + dx));
|
||||
curTop = Math.max(minTop, Math.min(maxTop, curTop + dy));
|
||||
el.style.left = curLeft + 'px';
|
||||
el.style.top = curTop + 'px';
|
||||
el.style.left = `${curLeft}px`;
|
||||
el.style.top = `${curTop}px`;
|
||||
}
|
||||
function onMove(ev) {
|
||||
var dx = ev.clientX - lastClientX, dy = ev.clientY - lastClientY;
|
||||
const dx = ev.clientX - lastClientX; const
|
||||
dy = ev.clientY - lastClientY;
|
||||
lastClientX = ev.clientX; lastClientY = ev.clientY;
|
||||
if (Math.abs(ev.clientX - startX) > 3 || Math.abs(ev.clientY - startY) > 3) {
|
||||
if (!moved) el.style.zIndex = 9999;
|
||||
@@ -354,9 +372,7 @@ function onSceneObjectMouseDown(e, id) {
|
||||
}
|
||||
applyDelta(dx, dy);
|
||||
}
|
||||
var stopAutoScroll = forgeStartCanvasAutoScroll(
|
||||
function () { return { x: lastClientX, y: lastClientY }; }, applyDelta
|
||||
);
|
||||
const stopAutoScroll = forgeStartCanvasAutoScroll(() => ({ x: lastClientX, y: lastClientY }), applyDelta);
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
@@ -386,14 +402,17 @@ function onSceneObjectMouseDown(e, id) {
|
||||
function onSceneObjectResizeMouseDown(e, id) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
var el = document.getElementById('el-' + id);
|
||||
var lastClientX = e.clientX, lastClientY = e.clientY;
|
||||
var curWidth = parseFloat(el.style.width) || 0, curHeight = parseFloat(el.style.height) || 0;
|
||||
var left = parseFloat(el.style.left) || 0, top = parseFloat(el.style.top) || 0;
|
||||
const el = document.getElementById(`el-${id}`);
|
||||
let lastClientX = e.clientX; let
|
||||
lastClientY = e.clientY;
|
||||
let curWidth = parseFloat(el.style.width) || 0; let
|
||||
curHeight = parseFloat(el.style.height) || 0;
|
||||
const left = parseFloat(el.style.left) || 0; const
|
||||
top = parseFloat(el.style.top) || 0;
|
||||
// Voir la même note dans onSceneObjectMouseDown ci-dessus : au-dessus
|
||||
// des panneaux flottants le temps du geste, pour rester visible même
|
||||
// en redimensionnant sous l'un d'eux.
|
||||
var previousZIndex = el.style.zIndex;
|
||||
const previousZIndex = el.style.zIndex;
|
||||
el.style.zIndex = 9999;
|
||||
|
||||
// Pas de plafond à "SCENE_WIDTH - left" ici (bug corrigé) : un objet
|
||||
@@ -405,23 +424,23 @@ function onSceneObjectResizeMouseDown(e, id) {
|
||||
function applyDelta(dx, dy) {
|
||||
curWidth = Math.max(8, curWidth + dx);
|
||||
curHeight = Math.max(8, curHeight + dy);
|
||||
el.style.width = curWidth + 'px';
|
||||
el.style.height = curHeight + 'px';
|
||||
el.style.width = `${curWidth}px`;
|
||||
el.style.height = `${curHeight}px`;
|
||||
}
|
||||
function onMove(ev) {
|
||||
var dx = ev.clientX - lastClientX, dy = ev.clientY - lastClientY;
|
||||
const dx = ev.clientX - lastClientX; const
|
||||
dy = ev.clientY - lastClientY;
|
||||
lastClientX = ev.clientX; lastClientY = ev.clientY;
|
||||
applyDelta(dx, dy);
|
||||
}
|
||||
var stopAutoScroll = forgeStartCanvasAutoScroll(
|
||||
function () { return { x: lastClientX, y: lastClientY }; }, applyDelta
|
||||
);
|
||||
const stopAutoScroll = forgeStartCanvasAutoScroll(() => ({ x: lastClientX, y: lastClientY }), applyDelta);
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
stopAutoScroll();
|
||||
el.style.zIndex = previousZIndex;
|
||||
var newWidth = curWidth, newHeight = curHeight;
|
||||
const newWidth = curWidth; const
|
||||
newHeight = curHeight;
|
||||
updateScenePosFields(left, top, newWidth, newHeight);
|
||||
saveSceneObjectGeometry(id, left, top, newWidth, newHeight);
|
||||
}
|
||||
@@ -433,20 +452,24 @@ function onSceneObjectResizeMouseDown(e, id) {
|
||||
// du comportement équivalent pour l'éditeur document (voir l'IIFE dans
|
||||
// initBuilderPanel(), panel-init.js), mais ids/route distincts (pixels).
|
||||
function initScenePosFields() {
|
||||
var xEl = document.getElementById('scene-pos-x'), yEl = document.getElementById('scene-pos-y');
|
||||
var wEl = document.getElementById('scene-pos-w'), hEl = document.getElementById('scene-pos-h');
|
||||
const xEl = document.getElementById('scene-pos-x'); const
|
||||
yEl = document.getElementById('scene-pos-y');
|
||||
const wEl = document.getElementById('scene-pos-w'); const
|
||||
hEl = document.getElementById('scene-pos-h');
|
||||
if (!xEl || !wEl || !CURRENT_SELECTED_ID) 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;
|
||||
const canvasEl = document.getElementById(`el-${CURRENT_SELECTED_ID}`);
|
||||
const x = parseFloat(xEl.value) || 0; const
|
||||
y = parseFloat(yEl.value) || 0;
|
||||
const w = parseFloat(wEl.value) || 1; const
|
||||
h = parseFloat(hEl.value) || 1;
|
||||
if (canvasEl) {
|
||||
canvasEl.style.left = x + 'px'; canvasEl.style.top = y + 'px';
|
||||
canvasEl.style.width = w + 'px'; canvasEl.style.height = h + 'px';
|
||||
canvasEl.style.left = `${x}px`; canvasEl.style.top = `${y}px`;
|
||||
canvasEl.style.width = `${w}px`; canvasEl.style.height = `${h}px`;
|
||||
}
|
||||
saveSceneObjectGeometry(CURRENT_SELECTED_ID, x, y, w, h);
|
||||
}
|
||||
[xEl, yEl, wEl, hEl].forEach(function (el) { el.addEventListener('change', apply); });
|
||||
[xEl, yEl, wEl, hEl].forEach((el) => { el.addEventListener('change', apply); });
|
||||
}
|
||||
initScenePosFields();
|
||||
|
||||
@@ -458,18 +481,19 @@ initScenePosFields();
|
||||
// preview.js, déjà générique : ne dépend que de data-element-id/
|
||||
// data-personnage, présents sur un objet de scène comme sur un élément).
|
||||
function swapSceneObjectCharacter(forgeCharacter) {
|
||||
var body = document.getElementById('personnagePanelBody');
|
||||
const body = document.getElementById('personnagePanelBody');
|
||||
if (!body) return;
|
||||
var objectId = parseInt(body.dataset.elementId, 10);
|
||||
document.querySelectorAll('#personnageGallerySwap .iconGalleryTile').forEach(function (tile) {
|
||||
tile.classList.toggle('selected', tile.getAttribute('onclick') === "swapSceneObjectCharacter('" + forgeCharacter + "')");
|
||||
const objectId = parseInt(body.dataset.elementId, 10);
|
||||
document.querySelectorAll('#personnageGallerySwap .iconGalleryTile').forEach((tile) => {
|
||||
tile.classList.toggle('selected', tile.getAttribute('onclick') === `swapSceneObjectCharacter('${forgeCharacter}')`);
|
||||
});
|
||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/personnage', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/personnage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source: 'forge', forge_character: forgeCharacter }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
if (json.animations) {
|
||||
ELEMENT_ANIMATIONS_MAP[objectId] = json.animations;
|
||||
restartPersonnageIdlePreview(objectId, json.animations);
|
||||
@@ -482,12 +506,13 @@ function swapSceneObjectCharacter(forgeCharacter) {
|
||||
// que swapSceneObjectCharacter() ci-dessus (POST JSON, aucun rechargement
|
||||
// de page), sur routes/scenes/scene_object_role.py.
|
||||
function saveSceneObjectRole(role) {
|
||||
var body = document.getElementById('personnagePanelBody');
|
||||
const body = document.getElementById('personnagePanelBody');
|
||||
if (!body) return;
|
||||
var objectId = parseInt(body.dataset.elementId, 10);
|
||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/role', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role: role }),
|
||||
const objectId = parseInt(body.dataset.elementId, 10);
|
||||
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/role`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ role }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -500,37 +525,43 @@ function saveSceneObjectRole(role) {
|
||||
// viennent d'être tapés à la main ou ajustés au glisser (voir
|
||||
// onCollisionBoxMouseDown/onCollisionBoxResizeMouseDown plus bas).
|
||||
function saveSceneObjectCollision() {
|
||||
var body = document.getElementById('collisionPanelBody');
|
||||
const body = document.getElementById('collisionPanelBody');
|
||||
if (!body) return;
|
||||
var objectId = parseInt(body.dataset.elementId, 10);
|
||||
var enabled = document.getElementById('collision-enabled').checked;
|
||||
var shape = document.getElementById('collision-shape').value;
|
||||
var width = parseFloat(document.getElementById('collision-width').value) || 0;
|
||||
var height = parseFloat(document.getElementById('collision-height').value) || 0;
|
||||
var offsetX = parseFloat(document.getElementById('collision-offset-x').value) || 0;
|
||||
var offsetY = parseFloat(document.getElementById('collision-offset-y').value) || 0;
|
||||
const objectId = parseInt(body.dataset.elementId, 10);
|
||||
const enabled = document.getElementById('collision-enabled').checked;
|
||||
const shape = document.getElementById('collision-shape').value;
|
||||
const width = parseFloat(document.getElementById('collision-width').value) || 0;
|
||||
const height = parseFloat(document.getElementById('collision-height').value) || 0;
|
||||
const offsetX = parseFloat(document.getElementById('collision-offset-x').value) || 0;
|
||||
const offsetY = parseFloat(document.getElementById('collision-offset-y').value) || 0;
|
||||
|
||||
var overlay = document.getElementById('collisionBoxOverlay');
|
||||
var objEl = document.getElementById('el-' + objectId);
|
||||
const overlay = document.getElementById('collisionBoxOverlay');
|
||||
const objEl = document.getElementById(`el-${objectId}`);
|
||||
if (overlay && objEl) {
|
||||
var objWidth = parseFloat(objEl.style.width) || 0, objHeight = parseFloat(objEl.style.height) || 0;
|
||||
overlay.style.width = width + 'px';
|
||||
overlay.style.height = height + 'px';
|
||||
overlay.style.left = ((objWidth - width) / 2 + offsetX) + 'px';
|
||||
overlay.style.top = ((objHeight - height) / 2 + offsetY) + 'px';
|
||||
const objWidth = parseFloat(objEl.style.width) || 0; const
|
||||
objHeight = parseFloat(objEl.style.height) || 0;
|
||||
overlay.style.width = `${width}px`;
|
||||
overlay.style.height = `${height}px`;
|
||||
overlay.style.left = `${(objWidth - width) / 2 + offsetX}px`;
|
||||
overlay.style.top = `${(objHeight - height) / 2 + offsetY}px`;
|
||||
overlay.classList.toggle('is-circle', shape === 'cercle');
|
||||
overlay.classList.toggle('is-disabled', !enabled);
|
||||
}
|
||||
|
||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/collision', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: enabled, shape: shape, width: width, height: height, offset_x: offsetX, offset_y: offsetY }),
|
||||
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/collision`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled, shape, width, height, offset_x: offsetX, offset_y: offsetY,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function updateCollisionFields(width, height, offsetX, offsetY) {
|
||||
var wEl = document.getElementById('collision-width'), hEl = document.getElementById('collision-height');
|
||||
var oxEl = document.getElementById('collision-offset-x'), oyEl = document.getElementById('collision-offset-y');
|
||||
const wEl = document.getElementById('collision-width'); const
|
||||
hEl = document.getElementById('collision-height');
|
||||
const oxEl = document.getElementById('collision-offset-x'); const
|
||||
oyEl = document.getElementById('collision-offset-y');
|
||||
if (wEl) wEl.value = Math.round(width);
|
||||
if (hEl) hEl.value = Math.round(height);
|
||||
if (oxEl) oxEl.value = Math.round(offsetX);
|
||||
@@ -546,22 +577,26 @@ function onCollisionBoxMouseDown(e, id) {
|
||||
e.stopPropagation();
|
||||
if (e.target.classList.contains('collisionResizeHandle')) return;
|
||||
e.preventDefault();
|
||||
var overlay = document.getElementById('collisionBoxOverlay');
|
||||
var objEl = document.getElementById('el-' + id);
|
||||
var objWidth = parseFloat(objEl.style.width) || 0, objHeight = parseFloat(objEl.style.height) || 0;
|
||||
var startX = e.clientX, startY = e.clientY;
|
||||
var startLeft = parseFloat(overlay.style.left) || 0, startTop = parseFloat(overlay.style.top) || 0;
|
||||
var boxWidth = parseFloat(overlay.style.width) || 0, boxHeight = parseFloat(overlay.style.height) || 0;
|
||||
const overlay = document.getElementById('collisionBoxOverlay');
|
||||
const objEl = document.getElementById(`el-${id}`);
|
||||
const objWidth = parseFloat(objEl.style.width) || 0; const
|
||||
objHeight = parseFloat(objEl.style.height) || 0;
|
||||
const startX = e.clientX; const
|
||||
startY = e.clientY;
|
||||
const startLeft = parseFloat(overlay.style.left) || 0; const
|
||||
startTop = parseFloat(overlay.style.top) || 0;
|
||||
const boxWidth = parseFloat(overlay.style.width) || 0; const
|
||||
boxHeight = parseFloat(overlay.style.height) || 0;
|
||||
|
||||
function onMove(ev) {
|
||||
overlay.style.left = (startLeft + (ev.clientX - startX)) + 'px';
|
||||
overlay.style.top = (startTop + (ev.clientY - startY)) + 'px';
|
||||
overlay.style.left = `${startLeft + (ev.clientX - startX)}px`;
|
||||
overlay.style.top = `${startTop + (ev.clientY - startY)}px`;
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
var offsetX = (parseFloat(overlay.style.left) || 0) - (objWidth - boxWidth) / 2;
|
||||
var offsetY = (parseFloat(overlay.style.top) || 0) - (objHeight - boxHeight) / 2;
|
||||
const offsetX = (parseFloat(overlay.style.left) || 0) - (objWidth - boxWidth) / 2;
|
||||
const offsetY = (parseFloat(overlay.style.top) || 0) - (objHeight - boxHeight) / 2;
|
||||
updateCollisionFields(boxWidth, boxHeight, offsetX, offsetY);
|
||||
saveSceneObjectCollision();
|
||||
}
|
||||
@@ -577,23 +612,28 @@ function onCollisionBoxMouseDown(e, id) {
|
||||
function onCollisionBoxResizeMouseDown(e, id) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
var overlay = document.getElementById('collisionBoxOverlay');
|
||||
var objEl = document.getElementById('el-' + id);
|
||||
var objWidth = parseFloat(objEl.style.width) || 0, objHeight = parseFloat(objEl.style.height) || 0;
|
||||
var startX = e.clientX, startY = e.clientY;
|
||||
var startWidth = parseFloat(overlay.style.width) || 0, startHeight = parseFloat(overlay.style.height) || 0;
|
||||
var left = parseFloat(overlay.style.left) || 0, top = parseFloat(overlay.style.top) || 0;
|
||||
const overlay = document.getElementById('collisionBoxOverlay');
|
||||
const objEl = document.getElementById(`el-${id}`);
|
||||
const objWidth = parseFloat(objEl.style.width) || 0; const
|
||||
objHeight = parseFloat(objEl.style.height) || 0;
|
||||
const startX = e.clientX; const
|
||||
startY = e.clientY;
|
||||
const startWidth = parseFloat(overlay.style.width) || 0; const
|
||||
startHeight = parseFloat(overlay.style.height) || 0;
|
||||
const left = parseFloat(overlay.style.left) || 0; const
|
||||
top = parseFloat(overlay.style.top) || 0;
|
||||
|
||||
function onMove(ev) {
|
||||
overlay.style.width = Math.max(4, startWidth + (ev.clientX - startX)) + 'px';
|
||||
overlay.style.height = Math.max(4, startHeight + (ev.clientY - startY)) + 'px';
|
||||
overlay.style.width = `${Math.max(4, startWidth + (ev.clientX - startX))}px`;
|
||||
overlay.style.height = `${Math.max(4, startHeight + (ev.clientY - startY))}px`;
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
var newWidth = parseFloat(overlay.style.width), newHeight = parseFloat(overlay.style.height);
|
||||
var offsetX = left - (objWidth - newWidth) / 2;
|
||||
var offsetY = top - (objHeight - newHeight) / 2;
|
||||
const newWidth = parseFloat(overlay.style.width); const
|
||||
newHeight = parseFloat(overlay.style.height);
|
||||
const offsetX = left - (objWidth - newWidth) / 2;
|
||||
const offsetY = top - (objHeight - newHeight) / 2;
|
||||
updateCollisionFields(newWidth, newHeight, offsetX, offsetY);
|
||||
saveSceneObjectCollision();
|
||||
}
|
||||
@@ -608,19 +648,20 @@ function onCollisionBoxResizeMouseDown(e, id) {
|
||||
// ci-dessus (POST JSON vers routes/scenes/scene_object_commands.py,
|
||||
// aucun rechargement de page).
|
||||
function saveSceneObjectCommands() {
|
||||
var body = document.getElementById('personnageCommandsBody');
|
||||
const body = document.getElementById('personnageCommandsBody');
|
||||
if (!body) return;
|
||||
var objectId = parseInt(body.dataset.elementId, 10);
|
||||
var extra = [];
|
||||
document.querySelectorAll('#personnageCommandsExtra .personnageCommandExtraRow').forEach(function (row) {
|
||||
var toucheEl = row.querySelector('.personnageKeyCapture');
|
||||
var animEl = row.querySelector('.cmdExtraAnim');
|
||||
const objectId = parseInt(body.dataset.elementId, 10);
|
||||
const extra = [];
|
||||
document.querySelectorAll('#personnageCommandsExtra .personnageCommandExtraRow').forEach((row) => {
|
||||
const toucheEl = row.querySelector('.personnageKeyCapture');
|
||||
const animEl = row.querySelector('.cmdExtraAnim');
|
||||
if (toucheEl && toucheEl.value && animEl && animEl.value) {
|
||||
extra.push({ touche: toucheEl.value, animation: animEl.value });
|
||||
}
|
||||
});
|
||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/commandes', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/commandes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
haut: document.getElementById('cmd-haut').value,
|
||||
bas: document.getElementById('cmd-bas').value,
|
||||
@@ -629,7 +670,7 @@ function saveSceneObjectCommands() {
|
||||
interagir: document.getElementById('cmd-interagir').value,
|
||||
axe: document.getElementById('cmd-axe').value,
|
||||
vitesse: document.getElementById('cmd-vitesse').value,
|
||||
extra: extra,
|
||||
extra,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -645,8 +686,8 @@ function saveSceneObjectCommands() {
|
||||
function bindPersonnageKeyCapture(el) {
|
||||
if (!el || el.dataset.keyCaptureBound) return;
|
||||
el.dataset.keyCaptureBound = '1';
|
||||
el.addEventListener('focus', function () {
|
||||
var previous = el.value;
|
||||
el.addEventListener('focus', () => {
|
||||
const previous = el.value;
|
||||
el.value = 'Appuie sur une touche...';
|
||||
el.classList.add('is-warning');
|
||||
function onKey(ev) {
|
||||
@@ -668,17 +709,14 @@ function bindPersonnageKeyCapture(el) {
|
||||
// déjà connu de personnage-panel.js/personnage-preview.js) — jamais un nom
|
||||
// tapé à la main, qui pourrait ne correspondre à aucune pose existante.
|
||||
function personnageCommandExtraRowEl(objectId, touche, animation) {
|
||||
var row = document.createElement('div');
|
||||
const row = document.createElement('div');
|
||||
row.className = 'field is-grouped personnageCommandExtraRow';
|
||||
var animNames = Object.keys(ELEMENT_ANIMATIONS_MAP[objectId] || {});
|
||||
var options = animNames.map(function (name) {
|
||||
return '<option value="' + name + '"' + (name === animation ? ' selected' : '') + '>' + name + '</option>';
|
||||
}).join('');
|
||||
row.innerHTML =
|
||||
'<div class="control"><input class="input is-small personnageKeyCapture" readonly placeholder="Touche..." value="' + (touche || '').replace(/"/g, '"') + '"></div>' +
|
||||
'<div class="control"><div class="select is-small"><select class="cmdExtraAnim">' + options + '</select></div></div>' +
|
||||
'<div class="control"><button type="button" class="button is-small" onclick="this.closest(\'.personnageCommandExtraRow\').remove(); saveSceneObjectCommands();">×</button></div>';
|
||||
var keyInput = row.querySelector('.personnageKeyCapture');
|
||||
const animNames = Object.keys(ELEMENT_ANIMATIONS_MAP[objectId] || {});
|
||||
const options = animNames.map((name) => `<option value="${name}"${name === animation ? ' selected' : ''}>${name}</option>`).join('');
|
||||
row.innerHTML = `<div class="control"><input class="input is-small personnageKeyCapture" readonly placeholder="Touche..." value="${(touche || '').replace(/"/g, '"')}"></div>`
|
||||
+ `<div class="control"><div class="select is-small"><select class="cmdExtraAnim">${options}</select></div></div>`
|
||||
+ '<div class="control"><button type="button" class="button is-small" onclick="this.closest(\'.personnageCommandExtraRow\').remove(); saveSceneObjectCommands();">×</button></div>';
|
||||
const keyInput = row.querySelector('.personnageKeyCapture');
|
||||
bindPersonnageKeyCapture(keyInput);
|
||||
keyInput.addEventListener('change', saveSceneObjectCommands);
|
||||
row.querySelector('.cmdExtraAnim').addEventListener('change', saveSceneObjectCommands);
|
||||
@@ -686,10 +724,10 @@ function personnageCommandExtraRowEl(objectId, touche, animation) {
|
||||
}
|
||||
|
||||
function addPersonnageCommandExtraRow() {
|
||||
var body = document.getElementById('personnageCommandsBody');
|
||||
var container = document.getElementById('personnageCommandsExtra');
|
||||
const body = document.getElementById('personnageCommandsBody');
|
||||
const container = document.getElementById('personnageCommandsExtra');
|
||||
if (!body || !container) return;
|
||||
var objectId = parseInt(body.dataset.elementId, 10);
|
||||
const objectId = parseInt(body.dataset.elementId, 10);
|
||||
container.appendChild(personnageCommandExtraRowEl(objectId, '', ''));
|
||||
}
|
||||
|
||||
@@ -697,9 +735,10 @@ function addPersonnageCommandExtraRow() {
|
||||
// personnage, réutilisé comme "qui parle" dans l'éditeur de dialogue d'un
|
||||
// déclencheur (voir static/js/triggers/trigger-editor.js).
|
||||
function saveSceneObjectName(objectId, name) {
|
||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/name', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name }),
|
||||
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/name`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
}).then(refreshSceneObjectNames);
|
||||
}
|
||||
|
||||
@@ -714,9 +753,9 @@ function saveSceneObjectName(objectId, name) {
|
||||
// lieu entre temps.
|
||||
function refreshSceneObjectNames() {
|
||||
if (typeof SCENE_OBJECT_NAMES === 'undefined') return;
|
||||
fetch('/game/' + GAME_SLUG + '/scene-object-names')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) { SCENE_OBJECT_NAMES = json; });
|
||||
fetch(`/game/${GAME_SLUG}/scene-object-names`)
|
||||
.then((r) => r.json())
|
||||
.then((json) => { SCENE_OBJECT_NAMES = json; });
|
||||
}
|
||||
|
||||
// RGAA/WCAG 1.4.3 (contraste minimum) : les couleurs du "🎨 Style"
|
||||
@@ -727,16 +766,16 @@ function refreshSceneObjectNames() {
|
||||
// son choix esthétique, ex. un jeu volontairement sombre/stylisé).
|
||||
function forgeHexToRgb(hex) {
|
||||
hex = String(hex || '').replace('#', '');
|
||||
if (hex.length === 3) hex = hex.split('').map(function (c) { return c + c; }).join('');
|
||||
var num = parseInt(hex, 16);
|
||||
if (isNaN(num) || hex.length !== 6) return [0, 0, 0];
|
||||
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
|
||||
const num = parseInt(hex, 16);
|
||||
if (Number.isNaN(num) || hex.length !== 6) return [0, 0, 0];
|
||||
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
|
||||
}
|
||||
|
||||
function forgeRelativeLuminance(rgb) {
|
||||
var srgb = rgb.map(function (c) {
|
||||
c = c / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
const srgb = rgb.map((c) => {
|
||||
c /= 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
|
||||
}
|
||||
@@ -744,23 +783,24 @@ function forgeRelativeLuminance(rgb) {
|
||||
// Formule de contraste WCAG standard (relative luminance) — 4.5:1 est le
|
||||
// seuil "AA" pour du texte normal (RGAA 3.2/critère 1.4.3).
|
||||
function forgeContrastRatio(hex1, hex2) {
|
||||
var l1 = forgeRelativeLuminance(forgeHexToRgb(hex1));
|
||||
var l2 = forgeRelativeLuminance(forgeHexToRgb(hex2));
|
||||
var lighter = Math.max(l1, l2), darker = Math.min(l1, l2);
|
||||
const l1 = forgeRelativeLuminance(forgeHexToRgb(hex1));
|
||||
const l2 = forgeRelativeLuminance(forgeHexToRgb(hex2));
|
||||
const lighter = Math.max(l1, l2); const
|
||||
darker = Math.min(l1, l2);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function checkDialogueBoxContrast(style) {
|
||||
var warningEl = document.getElementById('dboxContrastWarning');
|
||||
const warningEl = document.getElementById('dboxContrastWarning');
|
||||
if (!warningEl) return;
|
||||
var ratios = [
|
||||
const ratios = [
|
||||
forgeContrastRatio(style.text_color, style.header_bg),
|
||||
forgeContrastRatio(style.text_color, style.body_bg),
|
||||
forgeContrastRatio(style.text_color, style.footer_bg),
|
||||
];
|
||||
var minRatio = Math.min.apply(null, ratios);
|
||||
const minRatio = Math.min.apply(null, ratios);
|
||||
warningEl.textContent = minRatio < 4.5
|
||||
? '⚠️ Contraste insuffisant (' + minRatio.toFixed(1) + ':1, minimum recommandé 4.5:1) — le texte risque d\'être difficile à lire.'
|
||||
? `⚠️ Contraste insuffisant (${minRatio.toFixed(1)}:1, minimum recommandé 4.5:1) — le texte risque d'être difficile à lire.`
|
||||
: '';
|
||||
}
|
||||
|
||||
@@ -769,7 +809,7 @@ function checkDialogueBoxContrast(style) {
|
||||
// convention "replace-all" que saveSceneObjectCollision), jamais un seul
|
||||
// champ isolé.
|
||||
function saveDialogueBoxStyle(objectId) {
|
||||
var style = {
|
||||
const style = {
|
||||
font_family: document.getElementById('dbox-font-family').value,
|
||||
font_size: Number(document.getElementById('dbox-font-size').value),
|
||||
font_weight: document.getElementById('dbox-font-weight').value,
|
||||
@@ -779,20 +819,21 @@ function saveDialogueBoxStyle(objectId) {
|
||||
footer_bg: document.getElementById('dbox-footer-bg').value,
|
||||
};
|
||||
checkDialogueBoxContrast(style);
|
||||
var el = document.getElementById('el-' + objectId);
|
||||
var widget = el && el.querySelector('.dialogueBoxWidget');
|
||||
const el = document.getElementById(`el-${objectId}`);
|
||||
const widget = el && el.querySelector('.dialogueBoxWidget');
|
||||
if (widget) {
|
||||
widget.style.fontFamily = style.font_family;
|
||||
widget.style.fontSize = style.font_size + 'px';
|
||||
widget.style.fontSize = `${style.font_size}px`;
|
||||
widget.style.fontWeight = style.font_weight;
|
||||
widget.style.color = style.text_color;
|
||||
var header = widget.querySelector('.dialogueBoxHeader'); if (header) header.style.background = style.header_bg;
|
||||
var b = widget.querySelector('.dialogueBoxBody'); if (b) b.style.background = style.body_bg;
|
||||
var footer = widget.querySelector('.dialogueBoxFooter'); if (footer) footer.style.background = style.footer_bg;
|
||||
const header = widget.querySelector('.dialogueBoxHeader'); if (header) header.style.background = style.header_bg;
|
||||
const b = widget.querySelector('.dialogueBoxBody'); if (b) b.style.background = style.body_bg;
|
||||
const footer = widget.querySelector('.dialogueBoxFooter'); if (footer) footer.style.background = style.footer_bg;
|
||||
}
|
||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/dialogue-box-style', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ style: style }),
|
||||
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/dialogue-box-style`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ style }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -804,11 +845,11 @@ function saveDialogueBoxStyle(objectId) {
|
||||
// grille (data-selected-template) puis sauvegarde tout de suite (même
|
||||
// geste qu'un clic sur une tuile de personnage/décor).
|
||||
function _selectQuizBoxTemplate(objectId, key, gridId) {
|
||||
var grid = document.getElementById(gridId);
|
||||
const grid = document.getElementById(gridId);
|
||||
if (grid) {
|
||||
grid.setAttribute('data-selected-template', key);
|
||||
var tiles = grid.querySelectorAll('.quizTemplateTile');
|
||||
for (var i = 0; i < tiles.length; i++) {
|
||||
const tiles = grid.querySelectorAll('.quizTemplateTile');
|
||||
for (let i = 0; i < tiles.length; i++) {
|
||||
tiles[i].classList.toggle('is-selected', tiles[i].getAttribute('data-template') === key);
|
||||
}
|
||||
}
|
||||
@@ -841,47 +882,48 @@ function selectQuizBoxPageTemplate(objectId, key) { _selectQuizBoxTemplate(objec
|
||||
// galerie de modèles à afficher change entièrement (boîte <-> page) :
|
||||
// un simple patch DOM ne pourrait recalculer ni l'un ni l'autre ici.
|
||||
function saveQuizBoxConfig(objectId) {
|
||||
var meta = document.getElementById('quizBoxTemplateMeta');
|
||||
const meta = document.getElementById('quizBoxTemplateMeta');
|
||||
function currentTemplate(gridId, metaAttr) {
|
||||
var grid = document.getElementById(gridId);
|
||||
const grid = document.getElementById(gridId);
|
||||
if (!grid) return meta ? meta.getAttribute(metaAttr) : null;
|
||||
var selectedTile = grid.querySelector('.quizTemplateTile.is-selected');
|
||||
const selectedTile = grid.querySelector('.quizTemplateTile.is-selected');
|
||||
return grid.getAttribute('data-selected-template') || (selectedTile && selectedTile.getAttribute('data-template'));
|
||||
}
|
||||
var fullscreenEl = document.getElementById('quiz-fullscreen');
|
||||
var fullscreenChanged = fullscreenEl.checked !== (fullscreenEl.getAttribute('data-was-checked') === '1');
|
||||
var config = {
|
||||
const fullscreenEl = document.getElementById('quiz-fullscreen');
|
||||
const fullscreenChanged = fullscreenEl.checked !== (fullscreenEl.getAttribute('data-was-checked') === '1');
|
||||
const config = {
|
||||
fullscreen: fullscreenEl.checked,
|
||||
timer_mode: document.getElementById('quiz-timer-mode').value,
|
||||
timer_seconds: Number(document.getElementById('quiz-timer-seconds').value),
|
||||
dialog_template: currentTemplate('quizDialogTemplateGrid', 'data-dialog-template') || 'defaut',
|
||||
page_template: currentTemplate('quizPageTemplateGrid', 'data-page-template') || 'manga',
|
||||
};
|
||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/quiz-config', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config: config }),
|
||||
fetch(`/game/${GAME_SLUG}/scene-objects/${objectId}/quiz-config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (fullscreenChanged) { window.location.reload(); return; }
|
||||
var el = document.getElementById('el-' + objectId);
|
||||
var widget = el && el.querySelector('.quizBoxWidget');
|
||||
const el = document.getElementById(`el-${objectId}`);
|
||||
const widget = el && el.querySelector('.quizBoxWidget');
|
||||
if (widget && data.rendered_html) widget.outerHTML = data.rendered_html;
|
||||
});
|
||||
}
|
||||
|
||||
function initPersonnageCommandsPanel() {
|
||||
var body = document.getElementById('personnageCommandsBody');
|
||||
const body = document.getElementById('personnageCommandsBody');
|
||||
if (!body) return;
|
||||
var objectId = parseInt(body.dataset.elementId, 10);
|
||||
['cmd-haut', 'cmd-bas', 'cmd-gauche', 'cmd-droite', 'cmd-interagir'].forEach(function (id) {
|
||||
const objectId = parseInt(body.dataset.elementId, 10);
|
||||
['cmd-haut', 'cmd-bas', 'cmd-gauche', 'cmd-droite', 'cmd-interagir'].forEach((id) => {
|
||||
bindPersonnageKeyCapture(document.getElementById(id));
|
||||
});
|
||||
['cmd-haut', 'cmd-bas', 'cmd-gauche', 'cmd-droite', 'cmd-interagir', 'cmd-axe', 'cmd-vitesse'].forEach(function (id) {
|
||||
var el = document.getElementById(id);
|
||||
['cmd-haut', 'cmd-bas', 'cmd-gauche', 'cmd-droite', 'cmd-interagir', 'cmd-axe', 'cmd-vitesse'].forEach((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.addEventListener('change', saveSceneObjectCommands);
|
||||
});
|
||||
var container = document.getElementById('personnageCommandsExtra');
|
||||
const container = document.getElementById('personnageCommandsExtra');
|
||||
// Lu depuis data-commands-extra (posé sur CET élément par le serveur,
|
||||
// voir templates/scene_edit.html) plutôt que la variable globale
|
||||
// PERSONNAGE_COMMANDS_EXTRA (déclarée dans le <script> inline en bas de
|
||||
@@ -890,9 +932,9 @@ function initPersonnageCommandsPanel() {
|
||||
// ci-dessous), qui ne remplace que #builder3 — cette variable globale
|
||||
// resterait alors bloquée sur les commandes du PRÉCÉDENT objet
|
||||
// sélectionné.
|
||||
var extra = [];
|
||||
let extra = [];
|
||||
try { extra = JSON.parse(body.dataset.commandsExtra || '[]'); } catch (e) { extra = []; }
|
||||
extra.forEach(function (e) {
|
||||
extra.forEach((e) => {
|
||||
container.appendChild(personnageCommandExtraRowEl(objectId, e.touche, e.animation));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,19 +8,19 @@
|
||||
// collision" — ce dernier n'a donc plus besoin de charger
|
||||
// tabs-and-blocks.js/flow-editor.js/animation-timeline.js du tout). ----------
|
||||
function switchBuilderTab(tab) {
|
||||
document.querySelectorAll('.builderTabBtn').forEach(function (btn) {
|
||||
document.querySelectorAll('.builderTabBtn').forEach((btn) => {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tab);
|
||||
});
|
||||
document.querySelectorAll('.builderTabPanel').forEach(function (panel) {
|
||||
document.querySelectorAll('.builderTabPanel').forEach((panel) => {
|
||||
panel.classList.toggle('active', panel.dataset.tabPanel === tab);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDashCreate(tab) {
|
||||
var bar = document.getElementById('dashCreate-' + tab);
|
||||
const bar = document.getElementById(`dashCreate-${tab}`);
|
||||
bar.style.display = bar.style.display === 'none' ? 'flex' : 'none';
|
||||
if (bar.style.display === 'flex') {
|
||||
var firstInput = bar.querySelector('input, select');
|
||||
const firstInput = bar.querySelector('input, select');
|
||||
if (firstInput) firstInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
+161
-165
@@ -1,49 +1,52 @@
|
||||
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');
|
||||
const xEl = document.getElementById('pos-x'); const
|
||||
yEl = document.getElementById('pos-y');
|
||||
const wEl = document.getElementById('pos-w'); const
|
||||
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;
|
||||
const canvasEl = document.getElementById(`el-${CURRENT_SELECTED_ID}`);
|
||||
const x = parseFloat(xEl.value) || 0; const
|
||||
y = parseFloat(yEl.value) || 0;
|
||||
const w = parseFloat(wEl.value) || 1; const
|
||||
h = parseFloat(hEl.value) || 1;
|
||||
if (canvasEl) {
|
||||
canvasEl.style.left = x + '%'; canvasEl.style.top = y + '%';
|
||||
canvasEl.style.width = w + '%'; canvasEl.style.height = h + '%';
|
||||
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); });
|
||||
})();
|
||||
[xEl, yEl, wEl, hEl].forEach((el) => { el.addEventListener('change', apply); });
|
||||
}());
|
||||
|
||||
bindAspectButtons();
|
||||
bindScreenBackgroundMusicField();
|
||||
|
||||
document.querySelectorAll('.uploadInput').forEach(function (input) {
|
||||
input.addEventListener('change', function () {
|
||||
document.querySelectorAll('.uploadInput').forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
if (!input.files.length) return;
|
||||
var status = input.parentElement.querySelector('.uploadStatus');
|
||||
var targetField = document.getElementById(input.dataset.target);
|
||||
const status = input.parentElement.querySelector('.uploadStatus');
|
||||
const targetField = document.getElementById(input.dataset.target);
|
||||
status.textContent = 'Envoi en cours...';
|
||||
var data = new FormData();
|
||||
const 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) {
|
||||
fetch(`/game/${GAME_SLUG}/upload`, { method: 'POST', body: data })
|
||||
.then((r) => r.json())
|
||||
.then((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."; }
|
||||
submitPropsForm().then(() => { status.textContent = 'Fichier envoyé — Enregistré.'; });
|
||||
} else { status.textContent = "Échec de l'envoi."; }
|
||||
})
|
||||
.catch(function () { status.textContent = "Échec de l'envoi."; });
|
||||
.catch(() => { status.textContent = "Échec de l'envoi."; });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,21 +55,20 @@ function initBuilderPanel() {
|
||||
// é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 () {
|
||||
document.querySelectorAll('.nodeFormUploadInput').forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
if (!input.files.length) return;
|
||||
var status = input.parentElement.querySelector('.uploadStatus');
|
||||
var targetField = document.getElementById(input.dataset.target);
|
||||
const status = input.parentElement.querySelector('.uploadStatus');
|
||||
const targetField = document.getElementById(input.dataset.target);
|
||||
status.textContent = 'Envoi en cours...';
|
||||
var data = new FormData();
|
||||
const 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."; }
|
||||
fetch(`/game/${GAME_SLUG}/upload`, { method: 'POST', body: data })
|
||||
.then((r) => r.json())
|
||||
.then((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."; });
|
||||
.catch(() => { status.textContent = "Échec de l'envoi."; });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,7 +79,7 @@ function initBuilderPanel() {
|
||||
bindClauseListDefinitionSelect('field-data_definition_id');
|
||||
bindClauseListRows();
|
||||
initFilterValuePickers();
|
||||
var visCondModeSel = document.getElementById('field-visibilite_cond_mode');
|
||||
const visCondModeSel = document.getElementById('field-visibilite_cond_mode');
|
||||
if (visCondModeSel) toggleVisCondFields(visCondModeSel);
|
||||
restoreTreeCollapsedState();
|
||||
applySelectionHighlight(CURRENT_SELECTED_ID);
|
||||
@@ -97,27 +99,27 @@ function initBuilderPanel() {
|
||||
// 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;
|
||||
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');
|
||||
const defSel = document.getElementById('field-definition_id');
|
||||
const rowSel = document.getElementById('field-row_id');
|
||||
const champSel = document.getElementById('field-champ');
|
||||
const 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: [] };
|
||||
defSel.addEventListener('change', () => {
|
||||
const 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('')
|
||||
? info.rows.map((r) => `<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('');
|
||||
const simpleFields = info.fields.filter((f) => f.type !== 'relation');
|
||||
const fieldOptions = simpleFields.map((f) => `<option value="${f.name}">${f.name}</option>`).join('');
|
||||
champSel.innerHTML = fieldOptions || '<option value="">Aucun champ simple</option>';
|
||||
champNomSel.innerHTML = '<option value="">— aucun —</option>' + fieldOptions;
|
||||
champNomSel.innerHTML = `<option value="">— aucun —</option>${fieldOptions}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -133,24 +135,24 @@ function bindJaugeDefinitionSelect() {
|
||||
// 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;
|
||||
const mode = sel.value;
|
||||
document.querySelectorAll('.visCondRow').forEach((row) => {
|
||||
const scope = row.dataset.visCondScope;
|
||||
const show = scope === 'any' ? !!mode : scope === mode;
|
||||
row.style.display = show ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function bindDefinitionFieldSelects(defSelId, fieldSelIds) {
|
||||
var defSel = document.getElementById(defSelId);
|
||||
const defSel = document.getElementById(defSelId);
|
||||
if (!defSel) return;
|
||||
var fieldSels = fieldSelIds.map(function (id) { return document.getElementById(id); }).filter(Boolean);
|
||||
const fieldSels = fieldSelIds.map((id) => 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; });
|
||||
defSel.addEventListener('change', () => {
|
||||
const info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
|
||||
const opts = `<option value="">(sélectionner un objet d'abord)</option>${
|
||||
info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('')}`;
|
||||
fieldSels.forEach((sel) => { sel.innerHTML = opts; });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,15 +164,15 @@ function bindDefinitionFieldSelects(defSelId, fieldSelIds) {
|
||||
// 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 () {
|
||||
row.querySelectorAll('input, select').forEach((field) => {
|
||||
const debounced = field.tagName === 'INPUT';
|
||||
field.addEventListener(debounced ? 'input' : 'change', () => {
|
||||
if (debounced) debouncedSubmitPropsForm(); else submitPropsForm();
|
||||
});
|
||||
});
|
||||
var removeBtn = row.querySelector('.clauseRemoveBtn');
|
||||
const removeBtn = row.querySelector('.clauseRemoveBtn');
|
||||
if (removeBtn) {
|
||||
removeBtn.addEventListener('click', function () {
|
||||
removeBtn.addEventListener('click', () => {
|
||||
row.remove();
|
||||
updateClauseCombinatorVisibility(key);
|
||||
submitPropsForm();
|
||||
@@ -179,25 +181,25 @@ function wireClauseRow(row, key) {
|
||||
}
|
||||
|
||||
function updateClauseCombinatorVisibility(key) {
|
||||
var wrap = document.getElementById('clauseListWrap-' + key);
|
||||
var combRow = document.getElementById('clauseCombinatorRow-' + key);
|
||||
const wrap = document.getElementById(`clauseListWrap-${key}`);
|
||||
const 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);
|
||||
const tmpl = document.getElementById(`clauseRowTemplate-${key}`);
|
||||
const wrap = document.getElementById(`clauseListWrap-${key}`);
|
||||
if (!tmpl || !wrap) return;
|
||||
var clone = tmpl.content.cloneNode(true);
|
||||
var row = clone.querySelector('.clauseRow');
|
||||
const clone = tmpl.content.cloneNode(true);
|
||||
const 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');
|
||||
const 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.
|
||||
@@ -210,9 +212,9 @@ function addClauseRow(key) {
|
||||
// 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); });
|
||||
document.querySelectorAll('.clauseListControl').forEach((control) => {
|
||||
const key = control.dataset.clauseKey;
|
||||
control.querySelectorAll('.clauseRow').forEach((row) => { wireClauseRow(row, key); });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -222,14 +224,14 @@ function bindClauseListRows() {
|
||||
// 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);
|
||||
const 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;
|
||||
defSel.addEventListener('change', () => {
|
||||
const info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
|
||||
const opts = `<option value="">(sélectionner un objet d'abord)</option>${
|
||||
info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('')}`;
|
||||
document.querySelectorAll('.clauseChampSelect').forEach((sel) => {
|
||||
const current = sel.value;
|
||||
sel.innerHTML = opts;
|
||||
sel.value = current; // reste sélectionné si ce champ existe encore dans le nouvel objet
|
||||
});
|
||||
@@ -247,51 +249,49 @@ function bindClauseListDefinitionSelect(defSelId) {
|
||||
// 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*\\}\\}$');
|
||||
const _FILTER_REF_OPEN = '{' + '{';
|
||||
const _FILTER_REF_CLOSE = '}' + '}';
|
||||
const _FILTER_REF_RE = /^\{\{\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*\\}\\}$');
|
||||
const _VAR_REF_RE = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/;
|
||||
|
||||
function _filterValueDefinitionId(objName) {
|
||||
var opt = document.querySelector('.filterValueObjSel option[value="' + CSS.escape(objName) + '"]');
|
||||
const 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('');
|
||||
const info = (DEFINITIONS_DATA || {})[defId] || { fields: [] };
|
||||
fieldSel.innerHTML = `<option value="">— champ —</option>${info.fields.map((f) => `<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) : '';
|
||||
const objSel = wrap.querySelector('.filterValueObjSel');
|
||||
const fieldSel = wrap.querySelector('.filterValueFieldSel');
|
||||
const 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) : '';
|
||||
const varSel = wrap.querySelector('.filterValueVarSel');
|
||||
const pathInput = wrap.querySelector('.filterValueVarPath');
|
||||
const input = wrap.querySelector('.filterValueFixe input');
|
||||
const 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');
|
||||
const wrap = sel.closest('.filterValueControl');
|
||||
const fixe = wrap.querySelector('.filterValueFixe');
|
||||
const dyn = wrap.querySelector('.filterValueDynamique');
|
||||
const 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';
|
||||
@@ -300,7 +300,7 @@ function onFilterValueModeChange(sel) {
|
||||
}
|
||||
|
||||
function onFilterValueObjChange(sel) {
|
||||
var wrap = sel.closest('.filterValueControl');
|
||||
const wrap = sel.closest('.filterValueControl');
|
||||
populateFilterValueFields(wrap.querySelector('.filterValueFieldSel'), _filterValueDefinitionId(sel.value), '');
|
||||
updateFilterValueFromDynamic(wrap);
|
||||
}
|
||||
@@ -314,15 +314,15 @@ function onFilterValueVarChange(sel) {
|
||||
}
|
||||
|
||||
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);
|
||||
document.querySelectorAll('.filterValueControl').forEach((wrap) => {
|
||||
const modeSel = wrap.querySelector('.filterValueMode');
|
||||
const objSel = wrap.querySelector('.filterValueObjSel');
|
||||
const fieldSel = wrap.querySelector('.filterValueFieldSel');
|
||||
const varSel = wrap.querySelector('.filterValueVarSel');
|
||||
const varPathInput = wrap.querySelector('.filterValueVarPath');
|
||||
const current = (wrap.dataset.currentValue || '').trim();
|
||||
const varMatch = _VAR_REF_RE.exec(current);
|
||||
const objMatch = _FILTER_REF_RE.exec(current);
|
||||
if (varMatch) {
|
||||
modeSel.value = 'variable';
|
||||
wrap.querySelector('.filterValueFixe').style.display = 'none';
|
||||
@@ -349,34 +349,31 @@ function initFilterValuePickers() {
|
||||
// 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');
|
||||
const form = sel.closest('.actionAddForm');
|
||||
const screenSel = form.querySelector('.targetScreenSelect');
|
||||
const elementFields = form.querySelector('.targetElementFields');
|
||||
const 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');
|
||||
const propSel = elementFields.querySelector('.elementPropertySelect');
|
||||
toggleElementPropertyValue(propSel);
|
||||
}
|
||||
if (sel.value === 'modifier_donnee') {
|
||||
var defSel = dataFields.querySelector('.targetDefinitionSelect');
|
||||
const 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'); });
|
||||
const wrap = sel.closest('.targetElementFields');
|
||||
const visSel = wrap.querySelector('.elementValueVisibility');
|
||||
const colorInp = wrap.querySelector('.elementValueColor');
|
||||
const numInp = wrap.querySelector('.elementValueNumber');
|
||||
[visSel, colorInp, numInp].forEach((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') {
|
||||
@@ -388,22 +385,22 @@ function toggleElementPropertyValue(sel) {
|
||||
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');
|
||||
const wrap = sel.closest('.targetDataFields');
|
||||
const info = DEFINITIONS_DATA[sel.value] || { rows: [], fields: [] };
|
||||
const rowSel = wrap.querySelector('.targetRowSelect');
|
||||
const 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('')
|
||||
? info.rows.map((r) => `<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('')
|
||||
? info.fields.map((f) => `<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('.alignGroup').querySelectorAll('.alignOption').forEach((o) => { o.classList.remove('active', 'is-primary'); });
|
||||
radio.closest('.alignOption').classList.add('active', 'is-primary');
|
||||
}
|
||||
|
||||
@@ -418,69 +415,70 @@ function syncAlign(radio) {
|
||||
// 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 }),
|
||||
return fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/background-music`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
}
|
||||
|
||||
function bindScreenBackgroundMusicField() {
|
||||
var urlInput = document.getElementById('screen-bg-music-url');
|
||||
const urlInput = document.getElementById('screen-bg-music-url');
|
||||
if (urlInput) {
|
||||
urlInput.addEventListener('change', function () { saveScreenBackgroundMusic(urlInput.value); });
|
||||
urlInput.addEventListener('change', () => { saveScreenBackgroundMusic(urlInput.value); });
|
||||
}
|
||||
document.querySelectorAll('.screenUploadInput').forEach(function (input) {
|
||||
input.addEventListener('change', function () {
|
||||
document.querySelectorAll('.screenUploadInput').forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
if (!input.files.length) return;
|
||||
var status = input.parentElement.querySelector('.uploadStatus');
|
||||
var targetField = document.getElementById(input.dataset.target);
|
||||
const status = input.parentElement.querySelector('.uploadStatus');
|
||||
const targetField = document.getElementById(input.dataset.target);
|
||||
status.textContent = 'Envoi en cours...';
|
||||
var data = new FormData();
|
||||
const 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) {
|
||||
fetch(`/game/${GAME_SLUG}/upload`, { method: 'POST', body: data })
|
||||
.then((r) => r.json())
|
||||
.then((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."; }
|
||||
saveScreenBackgroundMusic(json.url).then(() => { status.textContent = 'Fichier envoyé — Enregistré.'; });
|
||||
} else { status.textContent = "Échec de l'envoi."; }
|
||||
})
|
||||
.catch(function () { status.textContent = "Échec de l'envoi."; });
|
||||
.catch(() => { 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'); });
|
||||
document.querySelectorAll('.aspectBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.aspectBtn').forEach((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' },
|
||||
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(){
|
||||
document.querySelectorAll('.uploadInput').forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
if (!input.files.length) return;
|
||||
var status = input.parentElement.querySelector('.uploadStatus');
|
||||
var targetField = document.getElementById(input.dataset.target);
|
||||
const status = input.parentElement.querySelector('.uploadStatus');
|
||||
const targetField = document.getElementById(input.dataset.target);
|
||||
status.textContent = 'Envoi en cours...';
|
||||
var data = new FormData();
|
||||
const 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){
|
||||
fetch(`/game/${GAME_SLUG}/upload`, { method: 'POST', body: data })
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
if (json.url) {
|
||||
targetField.value = json.url;
|
||||
status.textContent = 'Fichier envoyé — Enregistrement...';
|
||||
@@ -488,12 +486,10 @@ document.querySelectorAll('.uploadInput').forEach(function(input){
|
||||
// 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');
|
||||
const form = document.getElementById('propsForm');
|
||||
if (form) { form.requestSubmit ? form.requestSubmit() : form.submit(); }
|
||||
}
|
||||
else { status.textContent = "Échec de l'envoi."; }
|
||||
} else { status.textContent = "Échec de l'envoi."; }
|
||||
})
|
||||
.catch(function(){ status.textContent = "Échec de l'envoi."; });
|
||||
.catch(() => { status.textContent = "Échec de l'envoi."; });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,18 +8,18 @@
|
||||
// Forge (SPRITE_LIBRARY) sont proposés.
|
||||
|
||||
function _personnageElementId() {
|
||||
var body = document.getElementById('personnagePanelBody');
|
||||
const body = document.getElementById('personnagePanelBody');
|
||||
return body ? parseInt(body.dataset.elementId, 10) : null;
|
||||
}
|
||||
|
||||
function _savePersonnageData(data) {
|
||||
var elementId = _personnageElementId();
|
||||
const elementId = _personnageElementId();
|
||||
if (!elementId) return Promise.resolve();
|
||||
return fetch('/game/' + GAME_SLUG + '/elements/' + elementId + '/personnage', {
|
||||
return fetch(`/game/${GAME_SLUG}/elements/${elementId}/personnage`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
if (json.animations) {
|
||||
// Reste en phase avec le sélecteur d'animation du nœud de flow/clip
|
||||
// de Timeline (voir flow-editor.js/animation-timeline.js), sans
|
||||
@@ -32,8 +32,8 @@ function _savePersonnageData(data) {
|
||||
}
|
||||
|
||||
function swapPersonnageCharacter(forgeCharacter) {
|
||||
document.querySelectorAll('#personnageGallerySwap .iconGalleryTile').forEach(function (tile) {
|
||||
tile.classList.toggle('selected', tile.getAttribute('onclick') === "swapPersonnageCharacter('" + forgeCharacter + "')");
|
||||
document.querySelectorAll('#personnageGallerySwap .iconGalleryTile').forEach((tile) => {
|
||||
tile.classList.toggle('selected', tile.getAttribute('onclick') === `swapPersonnageCharacter('${forgeCharacter}')`);
|
||||
});
|
||||
_savePersonnageData({ source: 'forge', forge_character: forgeCharacter });
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// dans l'éditeur — mirror allégé de son moteur, scopé à l'idle de chaque
|
||||
// personnage du canevas (un seul intervalle actif par élément, comme le
|
||||
// moteur de jeu).
|
||||
var _personnageIdleIntervals = {};
|
||||
const _personnageIdleIntervals = {};
|
||||
|
||||
function _stopPersonnageIdlePreview(elementId) {
|
||||
if (_personnageIdleIntervals[elementId]) {
|
||||
@@ -19,8 +19,8 @@ function _startPersonnageIdlePreview(targetEl, elementId, frames, fps) {
|
||||
if (!frames || !frames.length) return;
|
||||
targetEl.src = frames[0];
|
||||
if (frames.length === 1) return;
|
||||
var frameIndex = 0;
|
||||
_personnageIdleIntervals[elementId] = setInterval(function () {
|
||||
let frameIndex = 0;
|
||||
_personnageIdleIntervals[elementId] = setInterval(() => {
|
||||
// Navigation pjax (voir pjax.js) : le canevas précédent est remplacé
|
||||
// sans que ce script ne soit prévenu — s'auto-arrête dès que son
|
||||
// élément a quitté le DOM plutôt que de tourner indéfiniment dans le
|
||||
@@ -34,17 +34,17 @@ function _startPersonnageIdlePreview(targetEl, elementId, frames, fps) {
|
||||
// Appelée après tout changement de personnage/animations (voir
|
||||
// personnage-panel.js) pour relancer l'aperçu sans recharger la page.
|
||||
function restartPersonnageIdlePreview(elementId, animations) {
|
||||
var targetEl = document.querySelector('#canvas [data-element-id="' + elementId + '"][data-personnage="1"]');
|
||||
const targetEl = document.querySelector(`#canvas [data-element-id="${elementId}"][data-personnage="1"]`);
|
||||
if (!targetEl) return;
|
||||
var frames = (animations && (animations.idle || Object.values(animations)[0])) || [];
|
||||
const frames = (animations && (animations.idle || Object.values(animations)[0])) || [];
|
||||
_startPersonnageIdlePreview(targetEl, elementId, frames, 8);
|
||||
}
|
||||
|
||||
function startAllPersonnagePreviews() {
|
||||
document.querySelectorAll('#canvas [data-personnage="1"]').forEach(function (targetEl) {
|
||||
var elementId = parseInt(targetEl.dataset.elementId, 10);
|
||||
var animations = ELEMENT_ANIMATIONS_MAP[elementId];
|
||||
var frames = (animations && (animations.idle || Object.values(animations)[0])) || null;
|
||||
document.querySelectorAll('#canvas [data-personnage="1"]').forEach((targetEl) => {
|
||||
const elementId = parseInt(targetEl.dataset.elementId, 10);
|
||||
const animations = ELEMENT_ANIMATIONS_MAP[elementId];
|
||||
const frames = (animations && (animations.idle || Object.values(animations)[0])) || null;
|
||||
// Pas d'entrée dans ELEMENT_ANIMATIONS_MAP (personnage vidé/sprite
|
||||
// personnalisé sans frame) : garde l'image déjà posée par le rendu
|
||||
// serveur (ou rien), pas d'intervalle à démarrer.
|
||||
|
||||
@@ -4,59 +4,15 @@
|
||||
// passe jamais par le moteur de templates, voir static/js/play/ pour le
|
||||
// même patron déjà en place côté moteur de jeu.
|
||||
function saveGeometry(id, x, y, width, height) {
|
||||
return fetch("/game/" + GAME_SLUG + "/elements/" + id + "/geometry", {
|
||||
return fetch(`/game/${GAME_SLUG}/elements/${id}/geometry`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({x: x, y: y, width: width, height: height})
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
x, y, width, height,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Menu contextuel (clic droit) de l'arborescence des éléments ----------
|
||||
//
|
||||
// Une seule action pour l'instant : dupliquer l'élément dans son conteneur
|
||||
// actuel (même parent, ou même écran si posé directement dessus) — voir
|
||||
// screens/elements/duplicate_element.py. La duplication passe par un vrai
|
||||
// <form method="post"> soumis dynamiquement : pjax.js intercepte tous les
|
||||
// formulaires internes, donc pas besoin de gérer le fetch/la redirection
|
||||
// nous-mêmes ici.
|
||||
var contextMenuElementId = null;
|
||||
|
||||
function showElementContextMenu(event, elementId) {
|
||||
event.preventDefault();
|
||||
contextMenuElementId = elementId;
|
||||
var menu = document.getElementById('elementContextMenu');
|
||||
menu.style.left = event.pageX + 'px';
|
||||
menu.style.top = event.pageY + 'px';
|
||||
menu.style.display = 'block';
|
||||
return false;
|
||||
}
|
||||
|
||||
function hideElementContextMenu() {
|
||||
var menu = document.getElementById('elementContextMenu');
|
||||
if (menu) menu.style.display = 'none';
|
||||
contextMenuElementId = null;
|
||||
}
|
||||
|
||||
function contextMenuDuplicate() {
|
||||
var id = contextMenuElementId;
|
||||
hideElementContextMenu();
|
||||
if (!id) return;
|
||||
var form = document.createElement('form');
|
||||
form.method = 'post';
|
||||
form.action = '/game/' + GAME_SLUG + '/elements/' + id + '/duplicate';
|
||||
document.body.appendChild(form);
|
||||
form.requestSubmit();
|
||||
}
|
||||
|
||||
if (!window.__forgeContextMenuBound) {
|
||||
window.__forgeContextMenuBound = true;
|
||||
document.addEventListener('click', function (e) {
|
||||
var menu = document.getElementById('elementContextMenu');
|
||||
if (menu && menu.style.display === 'block' && !menu.contains(e.target)) hideElementContextMenu();
|
||||
});
|
||||
document.addEventListener('scroll', hideElementContextMenu, true);
|
||||
}
|
||||
|
||||
// ---------- Arborescence : replier/déplier un conteneur ----------
|
||||
//
|
||||
// Le triangle (.treeToggle) replie/déplie SANS toucher à la sélection : le
|
||||
@@ -66,13 +22,13 @@ if (!window.__forgeContextMenuBound) {
|
||||
// replié/déplié est mémorisé par élément dans localStorage pour survivre à
|
||||
// un changement de sélection ou un rechargement de l'éditeur.
|
||||
function treeCollapseKey(nodeId) {
|
||||
return 'forgeTreeCollapsed:' + GAME_SLUG + ':' + SCREEN_ID + ':' + nodeId;
|
||||
return `forgeTreeCollapsed:${GAME_SLUG}:${SCREEN_ID}:${nodeId}`;
|
||||
}
|
||||
|
||||
function restoreTreeCollapsedState() {
|
||||
document.querySelectorAll('.elementTree .treeNode').forEach(function (node) {
|
||||
var id = node.dataset.elementId;
|
||||
var collapsed = false;
|
||||
document.querySelectorAll('.elementTree .treeNode').forEach((node) => {
|
||||
const id = node.dataset.elementId;
|
||||
let collapsed = false;
|
||||
try { collapsed = localStorage.getItem(treeCollapseKey(id)) === '1'; } catch (e) {}
|
||||
node.classList.toggle('collapsed', collapsed);
|
||||
});
|
||||
@@ -91,12 +47,12 @@ function restoreTreeCollapsedState() {
|
||||
// refreshBuilderPanel) ou un rechargement — voir les appels à
|
||||
// restoreFloatPanelState() dans initBuilderPanel().
|
||||
function floatPanelStateKey(key) {
|
||||
return 'forgeFloatPanel:' + GAME_SLUG + ':' + SCREEN_ID + ':' + key;
|
||||
return `forgeFloatPanel:${GAME_SLUG}:${SCREEN_ID}:${key}`;
|
||||
}
|
||||
|
||||
function saveFloatPanelState(key, state) {
|
||||
try {
|
||||
var current = JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}');
|
||||
const current = JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}');
|
||||
localStorage.setItem(floatPanelStateKey(key), JSON.stringify(Object.assign(current, state)));
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -106,31 +62,31 @@ function loadFloatPanelState(key) {
|
||||
}
|
||||
|
||||
function restoreFloatPanelState(key, panelId, reopenBtnId) {
|
||||
var panel = document.getElementById(panelId);
|
||||
var reopenBtn = document.getElementById(reopenBtnId);
|
||||
const panel = document.getElementById(panelId);
|
||||
const reopenBtn = document.getElementById(reopenBtnId);
|
||||
if (!panel) return;
|
||||
var state = loadFloatPanelState(key);
|
||||
const state = loadFloatPanelState(key);
|
||||
if (state.left != null && state.top != null) {
|
||||
panel.style.left = state.left + 'px';
|
||||
panel.style.top = state.top + 'px';
|
||||
panel.style.left = `${state.left}px`;
|
||||
panel.style.top = `${state.top}px`;
|
||||
panel.style.right = 'auto';
|
||||
}
|
||||
var open = state.open !== false; // ouvert par défaut tant que rien n'a été fermé
|
||||
const open = state.open !== false; // ouvert par défaut tant que rien n'a été fermé
|
||||
panel.classList.toggle('hidden', !open);
|
||||
if (reopenBtn) reopenBtn.classList.toggle('visible', !open);
|
||||
}
|
||||
|
||||
function closeFloatPanel(key, panelId, reopenBtnId) {
|
||||
var panel = document.getElementById(panelId);
|
||||
var reopenBtn = document.getElementById(reopenBtnId);
|
||||
const panel = document.getElementById(panelId);
|
||||
const reopenBtn = document.getElementById(reopenBtnId);
|
||||
if (panel) panel.classList.add('hidden');
|
||||
if (reopenBtn) reopenBtn.classList.add('visible');
|
||||
saveFloatPanelState(key, { open: false });
|
||||
}
|
||||
|
||||
function openFloatPanel(key, panelId, reopenBtnId) {
|
||||
var panel = document.getElementById(panelId);
|
||||
var reopenBtn = document.getElementById(reopenBtnId);
|
||||
const panel = document.getElementById(panelId);
|
||||
const reopenBtn = document.getElementById(reopenBtnId);
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
if (reopenBtn) reopenBtn.classList.remove('visible');
|
||||
saveFloatPanelState(key, { open: true });
|
||||
@@ -144,23 +100,25 @@ function openLeftPanel() { openFloatPanel('left', 'panelLeftFloat', 'panelLeftRe
|
||||
|
||||
if (!window.__forgeFloatPanelDragBound) {
|
||||
window.__forgeFloatPanelDragBound = true;
|
||||
document.addEventListener('mousedown', function (e) {
|
||||
var handle = e.target.closest('.floatPanelHeader');
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
const handle = e.target.closest('.floatPanelHeader');
|
||||
if (!handle || e.target.closest('button')) return;
|
||||
var panel = document.getElementById(handle.dataset.dragPanel);
|
||||
var key = handle.dataset.dragKey;
|
||||
const panel = document.getElementById(handle.dataset.dragPanel);
|
||||
const key = handle.dataset.dragKey;
|
||||
if (!panel) return;
|
||||
var rect = panel.getBoundingClientRect();
|
||||
var startX = e.clientX, startY = e.clientY;
|
||||
var startLeft = rect.left, startTop = rect.top;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
const startX = e.clientX; const
|
||||
startY = e.clientY;
|
||||
const startLeft = rect.left; const
|
||||
startTop = rect.top;
|
||||
panel.style.right = 'auto';
|
||||
function onMove(ev) {
|
||||
var maxLeft = window.innerWidth - panel.offsetWidth - 4;
|
||||
var maxTop = window.innerHeight - 40;
|
||||
var left = Math.max(4, Math.min(maxLeft, startLeft + (ev.clientX - startX)));
|
||||
var top = Math.max(4, Math.min(maxTop, startTop + (ev.clientY - startY)));
|
||||
panel.style.left = left + 'px';
|
||||
panel.style.top = top + 'px';
|
||||
const maxLeft = window.innerWidth - panel.offsetWidth - 4;
|
||||
const maxTop = window.innerHeight - 40;
|
||||
const left = Math.max(4, Math.min(maxLeft, startLeft + (ev.clientX - startX)));
|
||||
const top = Math.max(4, Math.min(maxTop, startTop + (ev.clientY - startY)));
|
||||
panel.style.left = `${left}px`;
|
||||
panel.style.top = `${top}px`;
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
@@ -182,137 +140,21 @@ if (!window.__forgeFloatPanelDragBound) {
|
||||
// ajout "à la position par défaut" que le clic, l'utilisateur repositionne
|
||||
// ensuite comme pour tout nouvel élément.
|
||||
function addIconElement(iconClass) {
|
||||
var form = document.createElement('form');
|
||||
const form = document.createElement('form');
|
||||
form.method = 'post';
|
||||
form.action = ELEMENT_ADD_URL;
|
||||
form.innerHTML =
|
||||
'<input type="hidden" name="widget" value="icone">' +
|
||||
'<input type="hidden" name="icon_class" value="' + iconClass.replace(/"/g, '"') + '">';
|
||||
form.innerHTML = '<input type="hidden" name="widget" value="icone">'
|
||||
+ `<input type="hidden" name="icon_class" value="${iconClass.replace(/"/g, '"')}">`;
|
||||
document.body.appendChild(form);
|
||||
form.requestSubmit();
|
||||
}
|
||||
|
||||
// Phase 8 — galerie "🎭 Personnages" (panneau gauche) : mirror exact
|
||||
// d'addIconElement() ci-dessus, pose un personnage Forge déjà configuré
|
||||
// (voir element_add.py).
|
||||
function addPersonnageElement(forgeCharacter) {
|
||||
var form = document.createElement('form');
|
||||
form.method = 'post';
|
||||
form.action = ELEMENT_ADD_URL;
|
||||
form.innerHTML =
|
||||
'<input type="hidden" name="widget" value="personnage">' +
|
||||
'<input type="hidden" name="forge_character" value="' + forgeCharacter.replace(/"/g, '"') + '">';
|
||||
document.body.appendChild(form);
|
||||
form.requestSubmit();
|
||||
}
|
||||
|
||||
function filterIconGallery(query) {
|
||||
var q = query.trim().toLowerCase();
|
||||
document.querySelectorAll('#iconGallery .iconGalleryTile').forEach(function (tile) {
|
||||
var match = !q || tile.dataset.iconSearch.indexOf(q) !== -1;
|
||||
tile.classList.toggle('iconHidden', !match);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Sélecteur visuel d'icône (réglage "Icône" d'un élément déjà
|
||||
// posé) — même liste que la galerie d'ajout ci-dessus, mais choisir une
|
||||
// icône ici remplace celle de l'élément SÉLECTIONNÉ (champ caché
|
||||
// "field-icon_class") au lieu d'en créer un nouveau. ----------
|
||||
// ---------- Confirmation de suppression d'un élément (modale custom, pas
|
||||
// le confirm() natif du navigateur) — voir deleteConfirmModal plus haut.
|
||||
// Interroge element_delete_impact.py pour savoir si des nœuds de la
|
||||
// Logique de la scène seraient aussi supprimés, et l'affiche si oui,
|
||||
// avant que l'utilisateur confirme quoi que ce soit. ----------
|
||||
var _pendingDeleteForm = null;
|
||||
|
||||
function handleDeleteElementSubmit(event, form) {
|
||||
if (form.dataset.confirmed === '1') {
|
||||
// Deuxième passage, après clic sur "Supprimer" dans la modale (voir
|
||||
// confirmDeleteElement()) : laisse cette fois la soumission suivre son
|
||||
// cours normalement (pjax.js l'intercepte comme n'importe quel autre
|
||||
// formulaire, voir static/pjax.js).
|
||||
form.dataset.confirmed = '';
|
||||
return true;
|
||||
}
|
||||
event.preventDefault();
|
||||
_pendingDeleteForm = form;
|
||||
document.getElementById('deleteConfirmMessage').textContent = form.dataset.confirmMessage || 'Supprimer cet élément ?';
|
||||
var warnEl = document.getElementById('deleteConfirmFlowWarning');
|
||||
warnEl.style.display = 'none';
|
||||
warnEl.textContent = '';
|
||||
document.getElementById('deleteConfirmModal').classList.add('is-active');
|
||||
var m = form.getAttribute('action').match(/\/elements\/(\d+)\/delete$/);
|
||||
if (m) {
|
||||
fetch("/game/" + GAME_SLUG + "/elements/" + m[1] + "/delete-impact")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.flow_node_count) return;
|
||||
var n = data.flow_node_count;
|
||||
var plural = n > 1 ? 's' : '';
|
||||
warnEl.textContent = n + ' connexion' + plural + ' de la Logique de la scène (déclencheur/action) utilise' +
|
||||
(n > 1 ? 'nt' : '') + ' cet élément et sera' + (n > 1 ? 'ont' : '') + ' aussi supprimée' + plural + '.';
|
||||
warnEl.style.display = 'block';
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function closeDeleteConfirmModal() {
|
||||
var modal = document.getElementById('deleteConfirmModal');
|
||||
if (modal) modal.classList.remove('is-active');
|
||||
_pendingDeleteForm = null;
|
||||
}
|
||||
|
||||
function confirmDeleteElement() {
|
||||
if (!_pendingDeleteForm) return;
|
||||
var form = _pendingDeleteForm;
|
||||
_pendingDeleteForm = null;
|
||||
closeDeleteConfirmModal();
|
||||
form.dataset.confirmed = '1';
|
||||
if (form.requestSubmit) form.requestSubmit(); else form.submit();
|
||||
}
|
||||
|
||||
function openIconPicker() {
|
||||
var modal = document.getElementById('iconPickerModal');
|
||||
if (modal) modal.classList.add('is-active');
|
||||
}
|
||||
|
||||
function closeIconPicker() {
|
||||
var modal = document.getElementById('iconPickerModal');
|
||||
if (modal) modal.classList.remove('is-active');
|
||||
}
|
||||
|
||||
function chooseIcon(iconSlug) {
|
||||
var input = document.getElementById('field-icon_class');
|
||||
var preview = document.getElementById('iconPickerPreview');
|
||||
if (input) input.value = iconSlug;
|
||||
if (preview) preview.style.setProperty('--icon-url', "url('/static/icons/" + iconSlug + ".svg')");
|
||||
closeIconPicker();
|
||||
submitPropsForm();
|
||||
}
|
||||
|
||||
function filterIconPickerModal(query) {
|
||||
var q = query.trim().toLowerCase();
|
||||
document.querySelectorAll('#iconPickerGrid .iconGalleryTile').forEach(function (tile) {
|
||||
var match = !q || tile.dataset.iconSearch.indexOf(q) !== -1;
|
||||
tile.classList.toggle('iconHidden', !match);
|
||||
});
|
||||
}
|
||||
|
||||
if (!window.__forgeIconPickerEscBound) {
|
||||
window.__forgeIconPickerEscBound = true;
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeIconPicker();
|
||||
});
|
||||
}
|
||||
|
||||
if (!window.__forgeIconGalleryDragBound) {
|
||||
window.__forgeIconGalleryDragBound = true;
|
||||
var draggedIconClass = null;
|
||||
let draggedIconClass = null;
|
||||
|
||||
document.addEventListener('dragstart', function (e) {
|
||||
var tile = e.target.closest('.iconGalleryTile');
|
||||
document.addEventListener('dragstart', (e) => {
|
||||
const tile = e.target.closest('.iconGalleryTile');
|
||||
if (!tile) return;
|
||||
draggedIconClass = tile.dataset.iconClass;
|
||||
tile.classList.add('dragging');
|
||||
@@ -320,19 +162,19 @@ if (!window.__forgeIconGalleryDragBound) {
|
||||
try { e.dataTransfer.setData('text/plain', draggedIconClass); } catch (err) {}
|
||||
});
|
||||
|
||||
document.addEventListener('dragend', function (e) {
|
||||
var tile = e.target.closest('.iconGalleryTile');
|
||||
document.addEventListener('dragend', (e) => {
|
||||
const tile = e.target.closest('.iconGalleryTile');
|
||||
if (tile) tile.classList.remove('dragging');
|
||||
draggedIconClass = null;
|
||||
});
|
||||
|
||||
document.addEventListener('dragover', function (e) {
|
||||
document.addEventListener('dragover', (e) => {
|
||||
if (!draggedIconClass || !e.target.closest('#canvas')) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
|
||||
document.addEventListener('drop', function (e) {
|
||||
document.addEventListener('drop', (e) => {
|
||||
if (!draggedIconClass || !e.target.closest('#canvas')) return;
|
||||
e.preventDefault();
|
||||
addIconElement(draggedIconClass);
|
||||
@@ -350,13 +192,13 @@ if (!window.__forgeIconGalleryDragBound) {
|
||||
// l'arborescence ne se voyait pas du tout sur le canevas. Ce contour
|
||||
// générique cible directement cette balise, quelle que soit sa profondeur.
|
||||
function applySelectionHighlight(id) {
|
||||
var canvas = document.getElementById('canvas');
|
||||
const canvas = document.getElementById('canvas');
|
||||
if (!canvas) return;
|
||||
canvas.querySelectorAll('.elementHighlighted').forEach(function (n) {
|
||||
canvas.querySelectorAll('.elementHighlighted').forEach((n) => {
|
||||
n.classList.remove('elementHighlighted');
|
||||
});
|
||||
if (!id) return;
|
||||
var node = canvas.querySelector('[data-element-id="' + id + '"]');
|
||||
const node = canvas.querySelector(`[data-element-id="${id}"]`);
|
||||
if (node) node.classList.add('elementHighlighted');
|
||||
}
|
||||
|
||||
@@ -366,25 +208,25 @@ function applySelectionHighlight(id) {
|
||||
// plutôt que par un aller-retour serveur, pour un rendu instantané aussi
|
||||
// bien dans l'éditeur qu'en jeu (voir la même fonction dans play.html).
|
||||
function forgeShowTab(btn) {
|
||||
var groupId = btn.dataset.tabBtn;
|
||||
var targetId = btn.dataset.tabTarget;
|
||||
document.querySelectorAll('[data-tab-btn="' + groupId + '"]').forEach(function (b) {
|
||||
const groupId = btn.dataset.tabBtn;
|
||||
const targetId = btn.dataset.tabTarget;
|
||||
document.querySelectorAll(`[data-tab-btn="${groupId}"]`).forEach((b) => {
|
||||
b.classList.toggle('is-active', b === btn);
|
||||
});
|
||||
document.querySelectorAll('[data-tab-group="' + groupId + '"]').forEach(function (p) {
|
||||
document.querySelectorAll(`[data-tab-group="${groupId}"]`).forEach((p) => {
|
||||
p.style.display = (p.id === targetId) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
if (!window.__forgeTreeToggleBound) {
|
||||
window.__forgeTreeToggleBound = true;
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.treeToggle');
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('.treeToggle');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var node = btn.closest('.treeNode');
|
||||
var collapsed = node.classList.toggle('collapsed');
|
||||
const node = btn.closest('.treeNode');
|
||||
const collapsed = node.classList.toggle('collapsed');
|
||||
try { localStorage.setItem(treeCollapseKey(node.dataset.elementId), collapsed ? '1' : '0'); } catch (err) {}
|
||||
});
|
||||
}
|
||||
@@ -406,11 +248,11 @@ restoreTreeCollapsedState();
|
||||
// Écouteurs posés une seule fois sur document (délégation) : ils survivent
|
||||
// au remplacement de #builder3 par refreshBuilderPanel/refreshCanvasOnly,
|
||||
// pas besoin de les rebrancher.
|
||||
var treeDragElementId = null;
|
||||
var treeDragParentId = null;
|
||||
let treeDragElementId = null;
|
||||
let treeDragParentId = null;
|
||||
|
||||
function treeRowDropInfo(row) {
|
||||
var node = row.closest('.treeNode');
|
||||
const node = row.closest('.treeNode');
|
||||
return {
|
||||
elementId: node ? node.dataset.elementId : null,
|
||||
parentId: row.dataset.parentId || '',
|
||||
@@ -419,7 +261,7 @@ function treeRowDropInfo(row) {
|
||||
}
|
||||
|
||||
function clearTreeDropIndicators() {
|
||||
document.querySelectorAll('.treeRow.dropBefore, .treeRow.dropAfter, .treeRow.dropInside').forEach(function (r) {
|
||||
document.querySelectorAll('.treeRow.dropBefore, .treeRow.dropAfter, .treeRow.dropInside').forEach((r) => {
|
||||
r.classList.remove('dropBefore', 'dropAfter', 'dropInside');
|
||||
});
|
||||
}
|
||||
@@ -430,8 +272,8 @@ function clearTreeDropIndicators() {
|
||||
// conteneur n'est PAS un frère/sœur du même parent, où seul "dedans" a un
|
||||
// sens (il n'y a pas d'ordre à changer avec un élément d'un autre parent).
|
||||
function treeDropZone(row, clientY, sameParent, isContainer) {
|
||||
var rect = row.getBoundingClientRect();
|
||||
var ratio = (clientY - rect.top) / rect.height;
|
||||
const rect = row.getBoundingClientRect();
|
||||
const ratio = (clientY - rect.top) / rect.height;
|
||||
if (!sameParent) return isContainer ? 'inside' : null;
|
||||
if (!isContainer) return ratio < 0.5 ? 'before' : 'after';
|
||||
if (ratio < 1 / 3) return 'before';
|
||||
@@ -442,10 +284,10 @@ function treeDropZone(row, clientY, sameParent, isContainer) {
|
||||
if (!window.__forgeTreeDragBound) {
|
||||
window.__forgeTreeDragBound = true;
|
||||
|
||||
document.addEventListener('dragstart', function (e) {
|
||||
var row = e.target.closest('.elementTree .treeRow');
|
||||
document.addEventListener('dragstart', (e) => {
|
||||
const row = e.target.closest('.elementTree .treeRow');
|
||||
if (!row) return;
|
||||
var info = treeRowDropInfo(row);
|
||||
const info = treeRowDropInfo(row);
|
||||
treeDragElementId = info.elementId;
|
||||
treeDragParentId = info.parentId;
|
||||
row.classList.add('dragging');
|
||||
@@ -453,21 +295,21 @@ if (!window.__forgeTreeDragBound) {
|
||||
try { e.dataTransfer.setData('text/plain', String(info.elementId)); } catch (err) {}
|
||||
});
|
||||
|
||||
document.addEventListener('dragend', function (e) {
|
||||
var row = e.target.closest('.elementTree .treeRow');
|
||||
document.addEventListener('dragend', (e) => {
|
||||
const row = e.target.closest('.elementTree .treeRow');
|
||||
if (row) row.classList.remove('dragging');
|
||||
clearTreeDropIndicators();
|
||||
treeDragElementId = null;
|
||||
treeDragParentId = null;
|
||||
});
|
||||
|
||||
document.addEventListener('dragover', function (e) {
|
||||
var row = e.target.closest('.elementTree .treeRow');
|
||||
document.addEventListener('dragover', (e) => {
|
||||
const row = e.target.closest('.elementTree .treeRow');
|
||||
if (!row || treeDragElementId == null) return;
|
||||
var info = treeRowDropInfo(row);
|
||||
const info = treeRowDropInfo(row);
|
||||
if (info.elementId == null || String(info.elementId) === String(treeDragElementId)) return;
|
||||
var sameParent = info.parentId === treeDragParentId;
|
||||
var zone = treeDropZone(row, e.clientY, sameParent, info.isContainer);
|
||||
const sameParent = info.parentId === treeDragParentId;
|
||||
const zone = treeDropZone(row, e.clientY, sameParent, info.isContainer);
|
||||
if (!zone) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
@@ -475,47 +317,47 @@ if (!window.__forgeTreeDragBound) {
|
||||
row.classList.add(zone === 'before' ? 'dropBefore' : zone === 'after' ? 'dropAfter' : 'dropInside');
|
||||
});
|
||||
|
||||
document.addEventListener('drop', function (e) {
|
||||
var row = e.target.closest('.elementTree .treeRow');
|
||||
document.addEventListener('drop', (e) => {
|
||||
const row = e.target.closest('.elementTree .treeRow');
|
||||
clearTreeDropIndicators();
|
||||
if (!row || treeDragElementId == null) return;
|
||||
var info = treeRowDropInfo(row);
|
||||
const info = treeRowDropInfo(row);
|
||||
if (info.elementId == null || String(info.elementId) === String(treeDragElementId)) return;
|
||||
var sameParent = info.parentId === treeDragParentId;
|
||||
var zone = treeDropZone(row, e.clientY, sameParent, info.isContainer);
|
||||
const sameParent = info.parentId === treeDragParentId;
|
||||
const zone = treeDropZone(row, e.clientY, sameParent, info.isContainer);
|
||||
if (!zone) return;
|
||||
e.preventDefault();
|
||||
var draggedId = treeDragElementId;
|
||||
const draggedId = treeDragElementId;
|
||||
if (zone === 'inside') {
|
||||
fetch('/game/' + GAME_SLUG + '/elements/' + draggedId + '/move-to-container', {
|
||||
fetch(`/game/${GAME_SLUG}/elements/${draggedId}/move-to-container`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ parent_id: info.elementId })
|
||||
body: JSON.stringify({ parent_id: info.elementId }),
|
||||
}).then(refreshAfterReorder);
|
||||
} else {
|
||||
fetch('/game/' + GAME_SLUG + '/elements/' + draggedId + '/reorder', {
|
||||
fetch(`/game/${GAME_SLUG}/elements/${draggedId}/reorder`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target_id: info.elementId, position: zone })
|
||||
body: JSON.stringify({ target_id: info.elementId, position: zone }),
|
||||
}).then(refreshAfterReorder);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshAfterReorder() {
|
||||
var url = SCREEN_EDIT_URL + (CURRENT_SELECTED_ID ? ('?selected=' + CURRENT_SELECTED_ID) : '');
|
||||
const url = SCREEN_EDIT_URL + (CURRENT_SELECTED_ID ? (`?selected=${CURRENT_SELECTED_ID}`) : '');
|
||||
return fetch(url, { headers: { 'X-Requested-With': 'pjax' } })
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (html) {
|
||||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
var newCanvas = doc.getElementById('canvas');
|
||||
var curCanvas = document.getElementById('canvas');
|
||||
.then((r) => r.text())
|
||||
.then((html) => {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const newCanvas = doc.getElementById('canvas');
|
||||
const curCanvas = document.getElementById('canvas');
|
||||
if (newCanvas && curCanvas) {
|
||||
curCanvas.innerHTML = newCanvas.innerHTML;
|
||||
applySelectionHighlight(CURRENT_SELECTED_ID);
|
||||
}
|
||||
var newTree = doc.querySelector('#panelLeftFloat .elementTree');
|
||||
var curTree = document.querySelector('#panelLeftFloat .elementTree');
|
||||
const newTree = doc.querySelector('#panelLeftFloat .elementTree');
|
||||
const curTree = document.querySelector('#panelLeftFloat .elementTree');
|
||||
if (newTree && curTree) { curTree.innerHTML = newTree.innerHTML; }
|
||||
restoreTreeCollapsedState();
|
||||
});
|
||||
@@ -537,34 +379,34 @@ function refreshBuilderPanel(selectedId) {
|
||||
// plutôt que de garder une sélection (ex. après suppression de l'objet
|
||||
// sélectionné, voir deleteSceneObject(), static/js/scenes/scene-editor.js)
|
||||
// — jamais "?selected=null/undefined" dans l'URL.
|
||||
var url = SCREEN_EDIT_URL + (selectedId ? ("?selected=" + selectedId) : "");
|
||||
return fetch(url, { headers: { "X-Requested-With": "pjax" } })
|
||||
.then(function (r) { return r.text().then(function (html) { return { html: html, url: r.url || url }; }); })
|
||||
.then(function (result) {
|
||||
var doc = new DOMParser().parseFromString(result.html, "text/html");
|
||||
var newBuilder = doc.getElementById("builder3");
|
||||
var curBuilder = document.getElementById("builder3");
|
||||
const url = SCREEN_EDIT_URL + (selectedId ? (`?selected=${selectedId}`) : '');
|
||||
return fetch(url, { headers: { 'X-Requested-With': 'pjax' } })
|
||||
.then((r) => r.text().then((html) => ({ html, url: r.url || url })))
|
||||
.then((result) => {
|
||||
const doc = new DOMParser().parseFromString(result.html, 'text/html');
|
||||
const newBuilder = doc.getElementById('builder3');
|
||||
const curBuilder = document.getElementById('builder3');
|
||||
if (newBuilder && curBuilder) {
|
||||
curBuilder.innerHTML = newBuilder.innerHTML;
|
||||
}
|
||||
CURRENT_SELECTED_ID = selectedId || null;
|
||||
history.pushState({ pjax: true }, "", result.url);
|
||||
window.history.pushState({ pjax: true }, '', result.url);
|
||||
initBuilderPanel();
|
||||
// Éditeur de scène 2D (voir static/js/scenes/scene-editor.js) : ce
|
||||
// fichier générique (partagé avec l'éditeur document) ne connaît pas
|
||||
// ses réglages propres (position en pixels, panneau "Commandes",
|
||||
// aperçu animé des personnages) — rien à faire ici pour l'éditeur
|
||||
// document, où cette fonction n'existe simplement pas.
|
||||
if (typeof initSceneBuilderPanel === "function") initSceneBuilderPanel();
|
||||
if (typeof initSceneBuilderPanel === 'function') initSceneBuilderPanel();
|
||||
// Bug corrigé : #builder3 contient AUSSI les onglets "Déclencheurs"
|
||||
// et "Générer avec l'IA" (composants JS, pas de simple HTML statique)
|
||||
// — curBuilder.innerHTML = ... ne réexécute aucun script, et sans cet
|
||||
// appel explicite leur contenu restait vide (jusqu'à un vrai
|
||||
// rechargement de page) dès qu'on sélectionnait un objet de la scène.
|
||||
if (typeof initTriggersTab === "function") initTriggersTab();
|
||||
if (typeof initIaTab === "function") initIaTab();
|
||||
if (typeof initTriggersTab === 'function') initTriggersTab();
|
||||
if (typeof initIaTab === 'function') initIaTab();
|
||||
})
|
||||
.catch(function () {
|
||||
.catch(() => {
|
||||
// Échec réseau : on retombe sur une vraie navigation plutôt que de
|
||||
// laisser l'éditeur dans un état incohérent.
|
||||
window.location.href = url;
|
||||
@@ -573,13 +415,13 @@ function refreshBuilderPanel(selectedId) {
|
||||
|
||||
function refreshCanvasOnly() {
|
||||
if (!CURRENT_SELECTED_ID) return Promise.resolve();
|
||||
var url = SCREEN_EDIT_URL + "?selected=" + CURRENT_SELECTED_ID;
|
||||
return fetch(url, { headers: { "X-Requested-With": "pjax" } })
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (html) {
|
||||
var doc = new DOMParser().parseFromString(html, "text/html");
|
||||
var newCanvas = doc.getElementById("canvas");
|
||||
var curCanvas = document.getElementById("canvas");
|
||||
const url = `${SCREEN_EDIT_URL}?selected=${CURRENT_SELECTED_ID}`;
|
||||
return fetch(url, { headers: { 'X-Requested-With': 'pjax' } })
|
||||
.then((r) => r.text())
|
||||
.then((html) => {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const newCanvas = doc.getElementById('canvas');
|
||||
const curCanvas = document.getElementById('canvas');
|
||||
if (newCanvas && curCanvas) {
|
||||
curCanvas.innerHTML = newCanvas.innerHTML;
|
||||
applySelectionHighlight(CURRENT_SELECTED_ID);
|
||||
@@ -587,8 +429,8 @@ function refreshCanvasOnly() {
|
||||
// Aperçu du contenu dans l'arborescence de gauche (ex: texte tronqué) —
|
||||
// sans toucher au panneau de droite, pour ne jamais faire perdre le
|
||||
// focus/curseur pendant qu'on modifie un réglage.
|
||||
var newTree = doc.querySelector('#panelLeftFloat .elementTree');
|
||||
var curTree = document.querySelector('#panelLeftFloat .elementTree');
|
||||
const newTree = doc.querySelector('#panelLeftFloat .elementTree');
|
||||
const curTree = document.querySelector('#panelLeftFloat .elementTree');
|
||||
if (newTree && curTree) { curTree.innerHTML = newTree.innerHTML; restoreTreeCollapsedState(); }
|
||||
});
|
||||
}
|
||||
@@ -609,33 +451,38 @@ function goToSelected(id) {
|
||||
function onElementMouseDown(e, id) {
|
||||
if (e.target.classList.contains('resizeHandle')) return;
|
||||
e.preventDefault();
|
||||
var el = document.getElementById('el-' + id);
|
||||
var canvas = document.getElementById('canvas');
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
var startX = e.clientX, startY = e.clientY;
|
||||
var startLeft = parseFloat(el.style.left), startTop = parseFloat(el.style.top);
|
||||
var width = parseFloat(el.style.width), height = parseFloat(el.style.height);
|
||||
var moved = false;
|
||||
const el = document.getElementById(`el-${id}`);
|
||||
const canvas = document.getElementById('canvas');
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const startX = e.clientX; const
|
||||
startY = e.clientY;
|
||||
const startLeft = parseFloat(el.style.left); const
|
||||
startTop = parseFloat(el.style.top);
|
||||
const width = parseFloat(el.style.width); const
|
||||
height = parseFloat(el.style.height);
|
||||
let moved = false;
|
||||
// Un clic (sans glisser) sélectionne l'élément précis qu'on a touché —
|
||||
// même s'il est physiquement imbriqué dans ce conteneur — pour éviter de
|
||||
// devoir repasser par l'arborescence à chaque fois. Le glisser-déposer,
|
||||
// lui, reste toujours celui de la boîte de plus haut niveau (id).
|
||||
var clickedTarget = e.target.closest('[data-element-id]');
|
||||
var clickedId = clickedTarget ? clickedTarget.dataset.elementId : id;
|
||||
const clickedTarget = e.target.closest('[data-element-id]');
|
||||
const clickedId = clickedTarget ? clickedTarget.dataset.elementId : id;
|
||||
|
||||
function onMove(ev) {
|
||||
var dx = ev.clientX - startX, dy = ev.clientY - startY;
|
||||
const dx = ev.clientX - startX; const
|
||||
dy = ev.clientY - startY;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;
|
||||
var newLeft = Math.max(0, Math.min(100 - width, startLeft + (dx / rect.width) * 100));
|
||||
var newTop = Math.max(0, Math.min(100 - height, startTop + (dy / rect.height) * 100));
|
||||
el.style.left = newLeft + '%';
|
||||
el.style.top = newTop + '%';
|
||||
const newLeft = Math.max(0, Math.min(100 - width, startLeft + (dx / rect.width) * 100));
|
||||
const newTop = Math.max(0, Math.min(100 - height, startTop + (dy / rect.height) * 100));
|
||||
el.style.left = `${newLeft}%`;
|
||||
el.style.top = `${newTop}%`;
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
if (moved) {
|
||||
var newLeft = parseFloat(el.style.left), newTop = parseFloat(el.style.top);
|
||||
const newLeft = parseFloat(el.style.left); const
|
||||
newTop = parseFloat(el.style.top);
|
||||
updatePosFields(newLeft, newTop, width, height);
|
||||
saveGeometry(id, newLeft, newTop, width, height);
|
||||
} else if (String(clickedId) !== String(CURRENT_SELECTED_ID)) {
|
||||
@@ -649,24 +496,29 @@ function onElementMouseDown(e, id) {
|
||||
function onResizeMouseDown(e, id) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
var el = document.getElementById('el-' + id);
|
||||
var canvas = document.getElementById('canvas');
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
var startX = e.clientX, startY = e.clientY;
|
||||
var startWidth = parseFloat(el.style.width), startHeight = parseFloat(el.style.height);
|
||||
var left = parseFloat(el.style.left), top = parseFloat(el.style.top);
|
||||
const el = document.getElementById(`el-${id}`);
|
||||
const canvas = document.getElementById('canvas');
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const startX = e.clientX; const
|
||||
startY = e.clientY;
|
||||
const startWidth = parseFloat(el.style.width); const
|
||||
startHeight = parseFloat(el.style.height);
|
||||
const left = parseFloat(el.style.left); const
|
||||
top = parseFloat(el.style.top);
|
||||
|
||||
function onMove(ev) {
|
||||
var dx = ev.clientX - startX, dy = ev.clientY - startY;
|
||||
var newWidth = Math.max(5, Math.min(100 - left, startWidth + (dx / rect.width) * 100));
|
||||
var newHeight = Math.max(5, Math.min(100 - top, startHeight + (dy / rect.height) * 100));
|
||||
el.style.width = newWidth + '%';
|
||||
el.style.height = newHeight + '%';
|
||||
const dx = ev.clientX - startX; const
|
||||
dy = ev.clientY - startY;
|
||||
const newWidth = Math.max(5, Math.min(100 - left, startWidth + (dx / rect.width) * 100));
|
||||
const newHeight = Math.max(5, Math.min(100 - top, startHeight + (dy / rect.height) * 100));
|
||||
el.style.width = `${newWidth}%`;
|
||||
el.style.height = `${newHeight}%`;
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
var newWidth = parseFloat(el.style.width), newHeight = parseFloat(el.style.height);
|
||||
const newWidth = parseFloat(el.style.width); const
|
||||
newHeight = parseFloat(el.style.height);
|
||||
updatePosFields(left, top, newWidth, newHeight);
|
||||
saveGeometry(id, left, top, newWidth, newHeight);
|
||||
}
|
||||
@@ -675,8 +527,10 @@ function onResizeMouseDown(e, id) {
|
||||
}
|
||||
|
||||
function updatePosFields(x, y, w, h) {
|
||||
var xEl = document.getElementById('pos-x'), yEl = document.getElementById('pos-y');
|
||||
var wEl = document.getElementById('pos-w'), hEl = document.getElementById('pos-h');
|
||||
const xEl = document.getElementById('pos-x'); const
|
||||
yEl = document.getElementById('pos-y');
|
||||
const wEl = document.getElementById('pos-w'); const
|
||||
hEl = document.getElementById('pos-h');
|
||||
if (xEl) xEl.value = Math.round(x * 10) / 10;
|
||||
if (yEl) yEl.value = Math.round(y * 10) / 10;
|
||||
if (wEl) wEl.value = Math.round(w * 10) / 10;
|
||||
@@ -690,8 +544,8 @@ function updatePosFields(x, y, w, h) {
|
||||
// liste déroulante, après une courte pause pour un champ texte ou un
|
||||
// slider qu'on est en train de glisser, pour éviter d'envoyer une requête à
|
||||
// chaque caractère tapé ou à chaque pixel de glissement).
|
||||
var autosaveTimer = null;
|
||||
var autosaveIndicator = null;
|
||||
let autosaveTimer = null;
|
||||
let autosaveIndicator = null;
|
||||
|
||||
function flashAutosave(state) {
|
||||
if (!autosaveIndicator) return;
|
||||
@@ -706,13 +560,13 @@ function flashAutosave(state) {
|
||||
}
|
||||
|
||||
function submitPropsForm() {
|
||||
var form = document.getElementById('propsForm');
|
||||
const form = document.getElementById('propsForm');
|
||||
if (!form) return Promise.resolve();
|
||||
flashAutosave('saving');
|
||||
var data = new FormData(form);
|
||||
const data = new FormData(form);
|
||||
return fetch(form.getAttribute('action'), {
|
||||
method: 'POST', body: data, headers: { "X-Requested-With": "pjax" }
|
||||
}).then(function () {
|
||||
method: 'POST', body: data, headers: { 'X-Requested-With': 'pjax' },
|
||||
}).then(() => {
|
||||
flashAutosave('saved');
|
||||
return refreshCanvasOnly();
|
||||
});
|
||||
@@ -723,37 +577,14 @@ function debouncedSubmitPropsForm() {
|
||||
autosaveTimer = setTimeout(submitPropsForm, 500);
|
||||
}
|
||||
|
||||
// Insère "{{champ}}" à l'emplacement du curseur dans le champ "Contenu"
|
||||
// (voir controls_with_values.py, control["field_options"] posé une fois
|
||||
// "Lier à un objet de données" réglé) — évite d'avoir à taper la syntaxe
|
||||
// à la main sans savoir quels noms de champs existent. OPEN/CLOSE en deux
|
||||
// morceaux : Jinja interpréterait "{{"/"}}" littéralement adjacents dans
|
||||
// le SOURCE de ce template comme le début d'une expression s'il les
|
||||
// rencontrait tels quels (même à l'intérieur d'un <script>), voir la même
|
||||
// astuce pour _FILTER_REF_OPEN/_FILTER_REF_CLOSE plus bas.
|
||||
function insertFieldAtCursor(fieldId, pickerId) {
|
||||
var field = document.getElementById(fieldId);
|
||||
var picker = document.getElementById(pickerId);
|
||||
if (!field || !picker || !picker.value) return;
|
||||
var open = '{' + '{', close = '}' + '}';
|
||||
var text = open + picker.value + close;
|
||||
var start = field.selectionStart || 0, end = field.selectionEnd || 0;
|
||||
field.value = field.value.substring(0, start) + text + field.value.substring(end);
|
||||
var newPos = start + text.length;
|
||||
field.focus();
|
||||
field.setSelectionRange(newPos, newPos);
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
picker.value = '';
|
||||
}
|
||||
|
||||
function bindPropsAutosave() {
|
||||
var form = document.getElementById('propsForm');
|
||||
const form = document.getElementById('propsForm');
|
||||
autosaveIndicator = document.getElementById('autosaveStatus');
|
||||
if (!form) return;
|
||||
form.querySelectorAll('input, textarea, select').forEach(function (field) {
|
||||
form.querySelectorAll('input, textarea, select').forEach((field) => {
|
||||
if (field.classList.contains('uploadInput')) return; // géré séparément (upload de fichier)
|
||||
var debounced = field.type === 'text' || field.type === 'range' || field.tagName === 'TEXTAREA';
|
||||
field.addEventListener(debounced ? 'input' : 'change', function () {
|
||||
const debounced = field.type === 'text' || field.type === 'range' || field.tagName === 'TEXTAREA';
|
||||
field.addEventListener(debounced ? 'input' : 'change', () => {
|
||||
// Le bascule visuelle de l'alignement (case active) est déjà gérée
|
||||
// par l'attribut onchange="syncAlign(this)" posé sur chaque radio.
|
||||
if (debounced) debouncedSubmitPropsForm(); else submitPropsForm();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user