Files
Forge-Engine/static/js/scenes/collision-rules-editor.js
T
williamandClaude Sonnet 5 00f9cf33e9
Build and deploy / test-python (push) Successful in 6m12s
Build and deploy / test-js (push) Successful in 52s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Fix bulle "Appuie sur X" invisible + retire Attaque/Événement du picker
Bug corrigé : la bulle "Appuie sur X" était ajoutée comme ENFANT de
l'objet de scène (un <img> pour "personnage"/"decor"/"fond") — un
élément REMPLACÉ dont les enfants DOM ajoutés en JS ne sont JAMAIS
affichés par un navigateur, quel que soit son CSS. La bulle existait
donc bien dans le DOM (aucune erreur) mais restait invisible à l'écran.
Elle est maintenant ajoutée comme SŒUR de l'objet dans son parent
(.sceneWorld/.sceneUI), positionnée en JS aux mêmes coordonnées.

Assistant "+ Action" de l'éditeur de collision : "⚔️ Attaquer la cible"
et "📣 Déclencher un événement" retirés des choix proposés (pas besoin
pour le moment) — le serveur continue d'accepter/d'exécuter une règle
déjà enregistrée avec l'un des deux, rien ne casse pour l'existant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:58:00 +02:00

271 lines
13 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) },
];
var action = rule.action;
while (action.type === 'interagir' && action.sub_action) {
links.push({ kind: action.sub_action.type, text: collisionRuleActionSummary(action.sub_action) });
action = action.sub_action;
}
var linksHtml = links.map(function (link, i) {
return '<span class="collisionRuleLink" data-kind="' + link.kind + '" style="animation-delay:' + (i * 60) + 'ms">' +
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>'
);
}
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">' + obj.thumbnail_html + '</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).
// Sépare "emoji" et "libellé" d'un texte "🗺️ Déclencher une quête" pour
// les afficher sur deux lignes dans une carte-choix (voir .collisionWizardChoice).
function collisionSplitEmojiLabel(text) {
var m = text.match(/^(\S+)\s+(.*)$/);
return m ? { emoji: m[1], label: m[2] } : { emoji: '', label: text };
}
function renderCollisionWizardTriggerStep() {
var collision = collisionSplitEmojiLabel(COLLISION_TRIGGER_LABELS.collision);
var perimetre = collisionSplitEmojiLabel(COLLISION_TRIGGER_LABELS.perimetre);
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 class="emoji">' + collision.emoji + '</span><span>' + collision.label + '</span>' +
'</button>' +
'<button type="button" class="collisionWizardChoice" style="animation-delay:40ms" onclick="collisionWizardChooseTrigger(\'perimetre\')">' +
'<span class="emoji">' + perimetre.emoji + '</span><span>' + perimetre.label + '</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) {
var split = collisionSplitEmojiLabel(COLLISION_ACTION_LABELS[type]);
return '<button type="button" class="collisionWizardChoice" style="animation-delay:' + (i * 40) + 'ms" onclick="collisionWizardChooseAction(\'' + type + '\', ' + (nested ? 'true' : 'false') + ')">' +
'<span class="emoji">' + split.emoji + '</span><span>' + split.label + '</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 class="emoji">📣</span><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>';
}
}
document.addEventListener('DOMContentLoaded', function () {
if (typeof COLLISION_RULE_OBJECTS !== 'undefined') renderCollisionRuleCards();
});