Le document de cadrage produit cible des formateurs non techniques créant des serious games/quiz gamifiés — l'éditeur générique "document" (blocs de logique en nœuds, timeline d'animation, définitions d'objets/relations, templates réutilisables) est une complexité hors cible que l'effort d'ingénierie récent avait déjà abandonnée au profit du jeu_2d. - Onboarding : ne garde que le parcours "RPG" (jeu_2d), retire Quiz/Embranchement/Créer mon jeu de A à Z (tous document-only) - Suppression en bloc des modules exclusifs au document : routes/elements, routes/element_types, routes/objects, routes/legacy_actions, screens/elements, screens/element_types, screens/widgets, screens/legacy_actions, le rendu render_element_html.py et son cluster, templates/screen_edit.html, templates/game_dashboard.html, flow-editor.js/tabs-and-blocks.js/animation-timeline.js - Dashboard toujours simplifié (un seul mode possible désormais) - Tests document-only supprimés, tests de logique partagée (flow, événements personnalisés, animations) retargetés sur des écrans jeu_2d - Aucune régression jeu_2d : 299 tests passent Carte d'onboarding retravaillée : argumentaire RH non technique (liste à coche, badge "Compatible LMS"), taille et interaction de retournement ajustées.
294 lines
14 KiB
JavaScript
294 lines
14 KiB
JavaScript
// ---------- Éditeur de collision (nouvel onglet "🧩 Collision") ----------
|
||
// Une carte par objet ÉLIGIBLE (jamais un "fond", jamais le personnage
|
||
// "joueur" — voir COLLISION_RULE_OBJECTS, construit par routes/scenes/
|
||
// scene_edit_view.py), listant ses règles "déclencheur -> action" déjà
|
||
// posées + un bouton "+ Action" qui ouvre un assistant pas à pas (jamais
|
||
// un vrai formulaire à plusieurs champs) : un clic choisit une option, la
|
||
// carte suivante s'emboîte aussitôt. Voir screens/rendering/
|
||
// collision_rules.py pour la forme 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 = {
|
||
quete: 'Déclencher une quête', attaque: 'Attaquer la cible',
|
||
evenement: 'Déclencher un événement', interagir: 'Interagir avec une touche',
|
||
};
|
||
// "attaque"/"evenement" retirés de l'assistant (pas besoin pour le
|
||
// moment, demande explicite) — server-side (screens/rendering/
|
||
// collision_rules.py::ACTION_TYPES) les accepte encore telles quelles,
|
||
// pour ne jamais casser une règle déjà enregistrée avec l'un des deux.
|
||
var COLLISION_WIZARD_ACTION_TYPES = ['quete', 'interagir'];
|
||
var COLLISION_TRIGGER_LABELS = { collision: 'À la collision', perimetre: 'Dans un périmètre' };
|
||
|
||
function collisionRuleObjectLabel(obj) {
|
||
var kindLabel = obj.kind === 'personnage' ? (obj.role === 'ennemie' ? 'Ennemi' : 'PNJ') : 'Décor';
|
||
return (obj.name || kindLabel);
|
||
}
|
||
|
||
// Un maillon = un segment de la "chaîne de pièces" affichée dans la carte
|
||
// (déclencheur, puis action, puis éventuellement sous-action) — texte
|
||
// seul, la logique de lecture vit dans collisionRuleActionSummary/
|
||
// collisionRuleTriggerSummary ci-dessous.
|
||
function collisionRuleTriggerSummary(rule) {
|
||
if (rule.trigger === 'perimetre') return COLLISION_TRIGGER_LABELS.perimetre + ' (' + rule.perimetre_px + 'px)';
|
||
return COLLISION_TRIGGER_LABELS.collision;
|
||
}
|
||
|
||
function collisionRuleActionSummary(action) {
|
||
if (action.type === 'quete') {
|
||
var quest = (typeof questById === 'function') ? questById(action.quete_id) : null;
|
||
return COLLISION_ACTION_LABELS.quete + ' « ' + (quest ? quest.title : '#' + action.quete_id) + ' »';
|
||
}
|
||
if (action.type === 'evenement') {
|
||
var ev = CUSTOM_EVENTS_MAP[action.evenement_id];
|
||
return COLLISION_ACTION_LABELS.evenement + ' « ' + (ev ? ev.name : '?') + ' »';
|
||
}
|
||
return COLLISION_ACTION_LABELS[action.type] || action.type;
|
||
}
|
||
|
||
// Une règle = une chaîne de "maillons" en forme de pièces qui s'emboîtent
|
||
// (déclencheur -> action -> sous-action éventuelle), pas une liste plate —
|
||
// chaque maillon porte un data-kind (couleur en CSS) et un délai d'entrée
|
||
// en cascade (voir @keyframes collisionLinkIn, static/style.css).
|
||
function collisionRuleChainHtml(rule, ruleIndex, objectId) {
|
||
var links = [
|
||
{ kind: 'trigger', text: collisionRuleTriggerSummary(rule) },
|
||
{ kind: rule.action.type, text: collisionRuleActionSummary(rule.action), questId: rule.action.quete_id },
|
||
];
|
||
var action = rule.action;
|
||
while (action.type === 'interagir' && action.sub_action) {
|
||
links.push({ kind: action.sub_action.type, text: collisionRuleActionSummary(action.sub_action), questId: action.sub_action.quete_id });
|
||
action = action.sub_action;
|
||
}
|
||
// Un maillon "quete" (kind==='quete') ouvre directement sa modale de
|
||
// dialogue (voir openQuestDialogueModal(), static/js/quests/
|
||
// quest-editor.js) au clic — demande explicite, plutôt qu'un simple
|
||
// texte informatif comme les autres maillons. Reste un <span> comme les
|
||
// autres (jamais un <button>, dont le style natif — apparence système,
|
||
// marges par défaut — grignotait la pointe en clip-path malgré les
|
||
// resets CSS) : un onclick sur un <span> suffit à le rendre cliquable,
|
||
// ".collisionRuleLink[data-kind='quete']" ajoute juste le curseur/hover
|
||
// qui le distingue visuellement (voir static/style.css).
|
||
var linksHtml = links.map(function (link, i) {
|
||
var clickable = link.kind === 'quete' && link.questId != null;
|
||
var onclick = clickable ? ' onclick="openQuestDialogueModal(' + link.questId + ')"' : '';
|
||
return '<span class="collisionRuleLink" data-kind="' + link.kind + '" style="animation-delay:' + (i * 60) + 'ms"' + onclick + '>' +
|
||
link.text.replace(/</g, '<') + '</span>';
|
||
}).join('');
|
||
return (
|
||
'<div class="collisionRuleChain">' +
|
||
'<div class="collisionRuleChainLinks">' + linksHtml + '</div>' +
|
||
'<button type="button" class="collisionRuleChainDelete" title="Supprimer cette règle" ' +
|
||
'onclick="deleteCollisionRule(' + objectId + ', ' + ruleIndex + ')">🗑️</button>' +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
// obj.thumbnail_html (screens.render_scene_object) porte un style inline
|
||
// PENSÉ POUR LE CANEVAS (position:absolute + left/top en dur, voir
|
||
// screens/scenes/render_scene_object.py) — un personnage déplacé loin
|
||
// dans la scène (left/top de plusieurs centaines de px) ressortait donc
|
||
// hors du petit cadre 120×120 de cette miniature : la règle CSS
|
||
// `.collisionRuleCardThumb img` (voir static/style.css) est censée le
|
||
// neutraliser (position:static !important etc.), mais s'appuyer sur un
|
||
// forçage CSS pour annuler un style inline entier reste fragile. Plus
|
||
// robuste : reconstruire ici une balise <img> "propre", sans AUCUN style
|
||
// hérité du canevas — seul le src (l'image elle-même) est repris.
|
||
function collisionRuleThumbHtml(obj) {
|
||
var match = /\ssrc="([^"]*)"/.exec(obj.thumbnail_html || '');
|
||
return match ? '<img src="' + match[1] + '" alt="">' : '';
|
||
}
|
||
|
||
function collisionRuleCardHtml(obj) {
|
||
var rulesHtml = obj.rules.length
|
||
? obj.rules.map(function (rule, i) { return collisionRuleChainHtml(rule, i, obj.id); }).join('')
|
||
: '<p class="hint">Aucune règle pour l\'instant.</p>';
|
||
return (
|
||
'<div class="collisionRuleCard" id="collisionRuleCard-' + obj.id + '">' +
|
||
'<div class="collisionRuleCardHeader">' +
|
||
'<div class="collisionRuleCardThumb">' + collisionRuleThumbHtml(obj) + '</div>' +
|
||
'<h3 class="collisionRuleCardTitle">' + collisionRuleObjectLabel(obj).replace(/</g, '<') + '</h3>' +
|
||
'</div>' +
|
||
'<div class="collisionRuleCardRules">' + rulesHtml + '</div>' +
|
||
'<button type="button" class="collisionRuleAddBtn" onclick="openCollisionRuleModal(' + obj.id + ')">Action</button>' +
|
||
'</div>'
|
||
);
|
||
}
|
||
|
||
function renderCollisionRuleCards() {
|
||
var container = document.getElementById('collisionRulesCards');
|
||
if (!container) return;
|
||
if (!COLLISION_RULE_OBJECTS.length) {
|
||
container.innerHTML = '<p class="hint">Aucun objet éligible pour l\'instant — un décor, un ennemi ou un PNJ (jamais le joueur lui-même).</p>';
|
||
return;
|
||
}
|
||
// Chaque carte entre en cascade (voir @keyframes collisionCardIn,
|
||
// static/style.css) plutôt que toutes en même temps.
|
||
container.innerHTML = COLLISION_RULE_OBJECTS.map(function (obj, i) {
|
||
return collisionRuleCardHtml(obj).replace('<div class="collisionRuleCard"', '<div style="animation-delay:' + (i * 50) + 'ms" class="collisionRuleCard"');
|
||
}).join('');
|
||
}
|
||
|
||
function collisionRuleObjectById(objectId) {
|
||
return COLLISION_RULE_OBJECTS.find(function (o) { return o.id === objectId; });
|
||
}
|
||
|
||
// Enregistre la liste COMPLÈTE des règles de cet objet (voir
|
||
// routes/scenes/scene_object_collision_rules.py — toujours la liste
|
||
// entière, jamais une règle isolée) puis redessine SA carte.
|
||
function saveCollisionRules(objectId) {
|
||
var obj = collisionRuleObjectById(objectId);
|
||
if (!obj) return;
|
||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/collision-rules', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ rules: obj.rules }),
|
||
})
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (json) {
|
||
if (json.rules) obj.rules = json.rules; // reflète la validation serveur (voir sanitize_collision_rules)
|
||
renderCollisionRuleCards();
|
||
});
|
||
}
|
||
|
||
function deleteCollisionRule(objectId, ruleIndex) {
|
||
var obj = collisionRuleObjectById(objectId);
|
||
if (!obj) return;
|
||
obj.rules.splice(ruleIndex, 1);
|
||
saveCollisionRules(objectId);
|
||
}
|
||
|
||
// ---------- Assistant "+ Action" ----------
|
||
// État du parcours en cours — un seul à la fois (la modale n'autorise
|
||
// qu'une règle en construction) : l'objet ciblé, le déclencheur choisi,
|
||
// et l'action en cours de construction (posée directement dans
|
||
// `action`, ou dans `action.sub_action` au second niveau pour "interagir").
|
||
var _collisionWizard = null;
|
||
|
||
function openCollisionRuleModal(objectId) {
|
||
_collisionWizard = { objectId: objectId, trigger: null, perimetrePx: COLLISION_DEFAULT_PERIMETRE_PX };
|
||
document.getElementById('collisionRuleModal').classList.add('is-active');
|
||
renderCollisionWizardTriggerStep();
|
||
}
|
||
|
||
function closeCollisionRuleModal() {
|
||
document.getElementById('collisionRuleModal').classList.remove('is-active');
|
||
_collisionWizard = null;
|
||
}
|
||
|
||
function collisionWizardBodyEl() {
|
||
return document.getElementById('collisionRuleModalBody');
|
||
}
|
||
|
||
// Étape 1 — déclencheur : collision directe, ou périmètre (px réglable,
|
||
// valeur par défaut COLLISION_DEFAULT_PERIMETRE_PX).
|
||
function renderCollisionWizardTriggerStep() {
|
||
collisionWizardBodyEl().innerHTML =
|
||
'<p class="hint mb-2">Cette règle se déclenche...</p>' +
|
||
'<div class="collisionWizardChoices">' +
|
||
'<button type="button" class="collisionWizardChoice" style="animation-delay:0ms" onclick="collisionWizardChooseTrigger(\'collision\')">' +
|
||
'<span>' + COLLISION_TRIGGER_LABELS.collision + '</span>' +
|
||
'</button>' +
|
||
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="collisionWizardChooseTrigger(\'perimetre\')">' +
|
||
'<span>' + COLLISION_TRIGGER_LABELS.perimetre + '</span>' +
|
||
'<span class="fieldRow" style="align-items:center; gap:6px;" onclick="event.stopPropagation()">' +
|
||
'<input type="number" min="1" id="collisionWizardPerimetrePx" value="' + COLLISION_DEFAULT_PERIMETRE_PX + '" style="width:64px;" class="input is-small">' +
|
||
'<span class="hint">px</span>' +
|
||
'</span>' +
|
||
'</button>' +
|
||
'</div>';
|
||
}
|
||
|
||
function collisionWizardChooseTrigger(trigger) {
|
||
_collisionWizard.trigger = trigger;
|
||
if (trigger === 'perimetre') {
|
||
var pxEl = document.getElementById('collisionWizardPerimetrePx');
|
||
_collisionWizard.perimetrePx = (pxEl && parseFloat(pxEl.value)) || COLLISION_DEFAULT_PERIMETRE_PX;
|
||
}
|
||
renderCollisionWizardActionStep(false);
|
||
}
|
||
|
||
// Étape 2 (et, pour "interagir", étape 3 en second niveau — `nested`
|
||
// distingue les deux, "interagir" n'étant proposé qu'au premier niveau,
|
||
// voir screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH) :
|
||
// quelle action exécuter.
|
||
function renderCollisionWizardActionStep(nested) {
|
||
var types = nested ? COLLISION_WIZARD_ACTION_TYPES.filter(function (t) { return t !== 'interagir'; }) : COLLISION_WIZARD_ACTION_TYPES;
|
||
var buttons = types.map(function (type, i) {
|
||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardChooseAction(\'' + type + '\', ' + (nested ? 'true' : 'false') + ')">' +
|
||
'<span>' + COLLISION_ACTION_LABELS[type] + '</span>' +
|
||
'</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) {
|
||
if (nested) {
|
||
// Deuxième niveau ("interagir" -> sous-action) : referme la maille
|
||
// sur l'action de premier niveau, déjà mémorisée sur le wizard.
|
||
_collisionWizard.pendingInteragir.sub_action = action;
|
||
collisionWizardSaveRule(_collisionWizard.pendingInteragir);
|
||
return;
|
||
}
|
||
if (action.type === 'interagir') {
|
||
// "interagir" n'est jamais final : une carte supplémentaire s'emboîte
|
||
// pour choisir CE QUI se passe une fois la touche pressée.
|
||
_collisionWizard.pendingInteragir = action;
|
||
renderCollisionWizardActionStep(true);
|
||
return;
|
||
}
|
||
collisionWizardSaveRule(action);
|
||
}
|
||
|
||
function collisionWizardSaveRule(action) {
|
||
var rule = { trigger: _collisionWizard.trigger, action: action };
|
||
if (_collisionWizard.trigger === 'perimetre') rule.perimetre_px = _collisionWizard.perimetrePx;
|
||
var obj = collisionRuleObjectById(_collisionWizard.objectId);
|
||
obj.rules.push(rule);
|
||
var objectId = _collisionWizard.objectId;
|
||
closeCollisionRuleModal();
|
||
saveCollisionRules(objectId);
|
||
}
|
||
|
||
// Chaque type d'action demande éventuellement une précision avant de
|
||
// pouvoir se finaliser ("quête" ouvre l'éditeur de quête lui-même — voir
|
||
// static/js/quests/quest-editor.js::renderQuestPickerStep, pas un simple
|
||
// champ texte ; événement -> lequel) — "attaque" n'a besoin de rien de
|
||
// plus, se finalise directement.
|
||
function collisionWizardChooseAction(type, nested) {
|
||
if (type === 'attaque' || type === 'interagir') {
|
||
collisionWizardFinalizeAction({ type: type }, nested);
|
||
return;
|
||
}
|
||
if (type === 'quete') {
|
||
renderQuestPickerStep(function (quest) {
|
||
collisionWizardFinalizeAction({ type: 'quete', quete_id: quest.id }, nested);
|
||
});
|
||
return;
|
||
}
|
||
if (type === 'evenement') {
|
||
var eventIds = Object.keys(CUSTOM_EVENTS_MAP);
|
||
if (!eventIds.length) {
|
||
collisionWizardBodyEl().innerHTML = '<p class="hint">Aucun événement personnalisé pour l\'instant — crée-en un dans l\'onglet "Événements", puis reviens ici.</p>';
|
||
return;
|
||
}
|
||
var buttons = eventIds.map(function (id, i) {
|
||
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardFinalizeAction({type:\'evenement\', evenement_id:' + id + '}, ' + (nested ? 'true' : 'false') + ')">' +
|
||
'<span>' + CUSTOM_EVENTS_MAP[id].name.replace(/</g, '<') + '</span>' +
|
||
'</button>';
|
||
}).join('');
|
||
collisionWizardBodyEl().innerHTML = '<p class="hint mb-2">Quel événement ?</p><div class="collisionWizardChoices">' + buttons + '</div>';
|
||
}
|
||
}
|
||
|
||
// Appelé directement (pas de DOMContentLoaded) : ce fichier est chargé par
|
||
// un <script src> posé à la fin du contenu de la page — les éléments dont
|
||
// on a besoin existent déjà à ce stade. Nécessaire aussi pour la
|
||
// navigation pjax (voir static/pjax.js — runScriptsIn ré-exécute ce
|
||
// script à chaque page swappée, mais DOMContentLoaded, lui, ne se
|
||
// redéclenche jamais après le tout premier chargement réel) — sans ça,
|
||
// l'onglet "🧩 Collision" restait vide après un remplacement de <main>
|
||
// (ex. suppression d'un objet de scène, voir scene_object_delete.py) qui
|
||
// ne redéclenche jamais cet événement.
|
||
if (typeof COLLISION_RULE_OBJECTS !== 'undefined') renderCollisionRuleCards();
|