Files
Forge-Engine/static/js/scenes/collision-rules-editor.js
T
william 50835a18e2
Build and deploy / test-python (push) Successful in 9m41s
Build and deploy / test-js (push) Successful in 1m0s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Supprime le type d'écran "document" et recentre le produit sur le jeu 2D
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.
2026-09-04 20:56:32 +02:00

294 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------- É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, '&lt;') + '</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, '&lt;') + '</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, '&lt;') + '</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();