Éditeur de collision (jeu 2D) : règles trigger -> action par objet
Build and deploy / test-python (push) Successful in 6m31s
Build and deploy / test-js (push) Successful in 50s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Nouvel onglet "🧩 Collision" dans l'éditeur de scène 2D : chaque objet
(hors joueur et fond) peut porter des règles "à la collision" ou "dans
un périmètre (px)" déclenchant une action (quête, attaque, événement,
ou interagir - qui affiche "Appuie sur [touche]" puis exécute une
sous-action à l'appui, un seul niveau d'imbrication). Backend
(sanitisation, route de persistance, exposition dans
full_game_payload) + assistant modal en cartes empilées côté client.

Ajoute le moteur d'exécution runtime (collision-rules-controller.js) :
détecte l'entrée en collision/périmètre avec le joueur à chaque tick,
déclenche l'action une seule fois par entrée, pur JS client sans appel
serveur (fonctionne à l'identique en ligne et en export SCORM).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-02 17:11:42 +02:00
co-authored by Claude Sonnet 5
parent dfaf85fac1
commit ee270d0286
15 changed files with 989 additions and 0 deletions
@@ -0,0 +1,135 @@
// Moteur d'exécution des règles "🧩 Collision" (voir
// static/js/play/collision-rules-controller.js) : déclenche une action une
// seule fois par ENTRÉE en collision/périmètre, jamais en continu tant que
// la condition reste vraie — et gère "interagir" à part (bulle + attente
// de touche).
const test = require('node:test');
const assert = require('node:assert/strict');
global.window = global.window || {};
global.gameData = global.gameData || {};
global.screensData = [];
global.heldKeys = new Set();
const actionsMod = require('../actions.js');
Object.assign(global, actionsMod);
require('../personnage-controller.js');
const {
forgeCollisionRuleTriggerActive, forgeRunCollisionRuleAction, forgeCollisionRuleTick,
forgeStartCollisionRuleControllers, forgeStopCollisionRuleControllers,
} = require('../collision-rules-controller.js');
function fakeEl() {
const classes = new Set();
const children = [];
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) },
children,
appendChild(child) { children.push(child); },
querySelector(sel) { return sel === '.collisionInteractBubble' ? children.find(c => c.className === 'collisionInteractBubble') : undefined; },
};
}
function fakeBubbleEl() {
return { className: 'collisionInteractBubble', style: {}, textContent: '' };
}
function fakeDocument(elByObjectId) {
return {
querySelector: sel => { const m = sel.match(/"(\d+)"/); return m ? elByObjectId[m[1]] : undefined; },
createElement: () => fakeBubbleEl(),
};
}
test.afterEach(() => { forgeStopCollisionRuleControllers(); });
function rect(left, top, width, height) {
return { left, top, width, height, right: left + width, bottom: top + height, shape: 'rectangle' };
}
test('forgeCollisionRuleTriggerActive — trigger "collision" : vrai seulement si les boîtes se chevauchent', () => {
const objRect = rect(0, 0, 20, 20);
const overlapping = rect(10, 10, 20, 20);
const far = rect(200, 200, 20, 20);
assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'collision' }, objRect, overlapping), true);
assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'collision' }, objRect, far), false);
});
test('forgeCollisionRuleTriggerActive — trigger "perimetre" : distance entre centres <= perimetre_px', () => {
const objRect = rect(0, 0, 10, 10); // centre (5,5)
const near = rect(10, 0, 10, 10); // centre (15,5) -> distance 10
assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'perimetre', perimetre_px: 12 }, objRect, near), true);
assert.equal(forgeCollisionRuleTriggerActive({ trigger: 'perimetre', perimetre_px: 5 }, objRect, near), false);
});
test('forgeRunCollisionRuleAction — "evenement" appelle window.dispatchGameEvent avec evenement_id', () => {
const calls = [];
global.window.dispatchGameEvent = id => calls.push(id);
forgeRunCollisionRuleAction({ type: 'evenement', evenement_id: 42 }, fakeEl(), { id: 1 });
assert.deepEqual(calls, [42]);
});
test('forgeRunCollisionRuleAction — "quete" mémorise l\'id dans window.forgeQuestLog (pas d\'éditeur de quête pour le moment)', () => {
window.forgeQuestLog = [];
forgeRunCollisionRuleAction({ type: 'quete', quete_id: 'q-1' }, fakeEl(), { id: 1 });
assert.deepEqual(window.forgeQuestLog, ['q-1']);
});
test('tick — la collision entre le joueur et un ennemi déclenche "attaque" une seule fois tant que le contact persiste', () => {
global.window.dispatchGameEvent = () => {};
const player = { id: 1, kind: 'personnage', personnage_role: 'joueur', personnage_commandes: { interagir: 'e' } };
const enemy = { id: 2, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'attaque' } }] };
const playerEl = fakeEl();
const enemyEl = fakeEl();
enemyEl.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: enemyEl });
global.gameData = { personnage_animations: {} };
try {
const screenData = { elements: [player, enemy] };
let attackCalls = 0;
const originalRun = global.runSpriteAnimation;
global.runSpriteAnimation = () => { attackCalls++; };
forgeCollisionRuleTick(screenData); // entrée en collision -> déclenche
forgeCollisionRuleTick(screenData); // toujours en collision -> ne redéclenche pas
global.runSpriteAnimation = originalRun;
// "attaque" ne joue une animation que si le personnage a une pose
// "attaque" (voir forgePersonnageAnimExists) — absente ici, donc 0
// appel réel à runSpriteAnimation, mais le test vérifie surtout
// l'absence de double déclenchement plutôt que l'appel lui-même.
assert.equal(attackCalls, 0);
} finally { global.document = previousDoc; }
});
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 pnj = { id: 3, kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'interagir', sub_action: { type: 'quete', quete_id: 'q-2' } } }] };
const playerEl = fakeEl();
const pnjElNear = fakeEl();
pnjElNear.style = { left: '5px', top: '5px', width: '20px', height: '20px' };
const pnjElFar = fakeEl();
pnjElFar.style = { left: '300px', top: '300px', width: '20px', height: '20px' };
const previousDoc = global.document;
global.gameData = { personnage_animations: {} };
try {
global.document = fakeDocument({ 1: playerEl, 3: pnjElNear });
forgeCollisionRuleTick({ elements: [player, pnj] });
let bubble = pnjElNear.querySelector('.collisionInteractBubble');
assert.equal(bubble.style.display, 'block');
assert.equal(bubble.textContent, 'Appuie sur e');
global.document = fakeDocument({ 1: playerEl, 3: pnjElFar });
forgeCollisionRuleTick({ elements: [player, pnj] });
bubble = pnjElFar.querySelector('.collisionInteractBubble');
assert.equal(bubble, undefined); // jamais créée pour cet élément-là (jamais actif dessus)
} finally { global.document = previousDoc; }
});
test('forgeStartCollisionRuleControllers — inactif sans joueur ou sans règle (aucun intervalle démarré)', () => {
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', personnage_role: 'joueur' }] }));
assert.doesNotThrow(() => forgeStartCollisionRuleControllers({ kind: 'jeu_2d', elements: [{ kind: 'personnage', collision_rules: [{ trigger: 'collision', action: { type: 'attaque' } }] }] }));
});
@@ -0,0 +1,188 @@
// ---------- Éditeur de collision — moteur d'exécution en jeu ----------
// Chaque objet de scène AUTRE que le joueur (jamais un "fond", jamais le
// personnage "joueur" — voir screens/rendering/collision_rules.py) peut
// porter des règles "déclencheur -> action" posées dans l'onglet
// "🧩 Collision" (static/js/scenes/collision-rules-editor.js). Ce fichier
// les FAIT VIVRE pendant le jeu : à chaque tick, teste chaque règle contre
// la position du joueur, déclenche l'action une seule fois par entrée
// (pas en continu tant que la condition reste vraie), même cadence que
// personnage-controller.js (50ms) et réutilise ses mêmes briques
// (forgeCollisionRectFromBox/forgeShapesOverlap de conditions.js).
// Pur JS client, sans le moindre appel serveur — fonctionne à l'identique
// 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;
// É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 = {};
function forgeCollisionRuleStateKey(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);
}
// "collision" : même chevauchement de boîtes que forgeWouldCollide
// (personnage-controller.js). "perimetre" : distance entre les CENTRES
// des deux boîtes <= rule.perimetre_px (pas entre les bords — plus simple
// et suffisant pour une portée approximative, cohérent avec le reste du
// moteur qui ne vise pas une physique précise).
function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) {
if (rule.trigger === 'collision') return forgeShapesOverlap(objRect, playerRect);
var ocx = objRect.left + objRect.width / 2, ocy = objRect.top + objRect.height / 2;
var pcx = playerRect.left + playerRect.width / 2, pcy = playerRect.top + playerRect.height / 2;
var dist = Math.hypot(ocx - pcx, ocy - pcy);
return dist <= (rule.perimetre_px || 0);
}
function forgeCollisionInteractBubbleEl(objectEl) {
var bubble = objectEl.querySelector('.collisionInteractBubble');
if (!bubble) {
bubble = document.createElement('div');
bubble.className = 'collisionInteractBubble';
objectEl.appendChild(bubble);
}
return bubble;
}
function forgeShowCollisionInteractBubble(objectEl, key) {
var bubble = forgeCollisionInteractBubbleEl(objectEl);
bubble.textContent = 'Appuie sur ' + key;
bubble.style.display = 'block';
}
function forgeHideCollisionInteractBubble(objectEl) {
var bubble = objectEl.querySelector('.collisionInteractBubble');
if (bubble) bubble.style.display = 'none';
}
// Exécute une action (top-level ou sub_action d'un "interagir") — jamais
// appelée pour un "interagir" lui-même à ce niveau, voir forgeCollisionRuleTick
// qui gère "interagir" à part (affichage de bulle + attente de touche).
function forgeRunCollisionRuleAction(action, targetEl, obj) {
if (action.type === 'evenement') {
window.dispatchGameEvent(action.evenement_id);
return;
}
if (action.type === 'attaque') {
var attackAnim = forgePersonnageAnimExists(obj.id, 'attaque') ? 'attaque' : null;
if (attackAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: attackAnim, fps: FORGE_PERSONNAGE_FPS, loop: false }));
return;
}
if (action.type === 'quete') {
// Éditeur de quête pas encore construit (voir plan) — mémorise
// l'intention pour un futur système de quêtes plutôt que de la perdre.
window.forgeQuestLog = window.forgeQuestLog || [];
window.forgeQuestLog.push(action.quete_id);
return;
}
}
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 + '"]');
if (!player || !playerEl) return;
var playerRect = forgeElementBoxRect(playerEl, player.collision);
(screenData.elements || []).forEach(function (obj) {
var rules = obj.collision_rules;
if (!rules || !rules.length) return;
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
if (!targetEl) return;
var objRect = forgeElementBoxRect(targetEl, obj.collision);
var anyInteractActive = false;
rules.forEach(function (rule, ruleIndex) {
var stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex);
var isActive = forgeCollisionRuleTriggerActive(rule, objRect, playerRect);
var wasActive = !!forgeCollisionRuleActiveState[stateKey];
if (rule.action.type === 'interagir') {
if (isActive) {
anyInteractActive = true;
forgeShowCollisionInteractBubble(targetEl, (player.personnage_commandes && player.personnage_commandes.interagir) || 'E');
}
forgeCollisionRuleActiveState[stateKey] = isActive;
return;
}
if (isActive && !wasActive) forgeRunCollisionRuleAction(rule.action, targetEl, obj);
forgeCollisionRuleActiveState[stateKey] = isActive;
});
if (!anyInteractActive) forgeHideCollisionInteractBubble(targetEl);
});
}
// Ponctuel (keydown), comme forgeStartPersonnageInteractListener — un
// "interagir" se déclenche à l'appui de la touche, pas en continu tant
// 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'; });
if (!player || !player.personnage_commandes || ev.key !== player.personnage_commandes.interagir) return;
(screenData.elements || []).forEach(function (obj) {
var rules = obj.collision_rules;
if (!rules || !rules.length) return;
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
if (!targetEl) return;
rules.forEach(function (rule, ruleIndex) {
if (rule.action.type !== 'interagir') return;
var stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex);
if (!forgeCollisionRuleActiveState[stateKey]) return;
forgeRunCollisionRuleAction(rule.action.sub_action, targetEl, obj);
});
});
};
window.addEventListener('keydown', activeCollisionRuleInteractListener);
}
function forgeStopCollisionRuleControllers() {
if (activeCollisionRuleInterval) {
clearInterval(activeCollisionRuleInterval);
activeCollisionRuleInterval = null;
}
if (activeCollisionRuleInteractListener) {
window.removeEventListener('keydown', activeCollisionRuleInteractListener);
activeCollisionRuleInteractListener = null;
}
forgeCollisionRuleActiveState = {};
}
// Même patron par écran que forgeStartPersonnageControllers — appelé
// juste après lui (voir static/js/play/screens.js), n'a d'effet que si
// l'écran a un joueur ET au moins un objet avec des règles.
function forgeStartCollisionRuleControllers(screenData) {
forgeStopCollisionRuleControllers();
if (!screenData || screenData.kind !== 'jeu_2d') return;
var elements = screenData.elements || [];
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; });
if (!hasPlayer || !hasRules) return;
activeCollisionRuleInterval = setInterval(function () { 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,
};
const conditionsMod = require('./conditions.js');
global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox;
global.forgeShapesOverlap = global.forgeShapesOverlap || conditionsMod.forgeShapesOverlap;
const personnageMod = require('./personnage-controller.js');
global.forgePersonnageAnimExists = global.forgePersonnageAnimExists || personnageMod.forgePersonnageAnimExists;
global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8;
}
+4
View File
@@ -53,6 +53,10 @@ function showScreen(id) {
// é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; }));
// 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; }));
}
// Phase 6 — musique de fond par écran (voir screen-bg-music-url dans
+242
View File
@@ -0,0 +1,242 @@
// ---------- É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',
};
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') return COLLISION_ACTION_LABELS.quete + ' « ' + 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" (déclencheur -> action -> sous-action
// éventuelle) — chacun sa propre petite carte, visuellement emboîtées.
function collisionRuleChainHtml(rule, ruleIndex, objectId) {
var links = [collisionRuleTriggerSummary(rule), collisionRuleActionSummary(rule.action)];
var action = rule.action;
while (action.type === 'interagir' && action.sub_action) {
links.push(collisionRuleActionSummary(action.sub_action));
action = action.sub_action;
}
var linksHtml = links.map(function (text) {
return '<span class="collisionRuleLink">' + text.replace(/</g, '&lt;') + '</span>';
}).join('<span class="collisionRuleArrow">→</span>');
return (
'<div class="collisionRuleChain">' +
'<div class="collisionRuleChainLinks">' + linksHtml + '</div>' +
'<button type="button" class="button is-small is-danger" 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="button is-small primary" 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;
}
container.innerHTML = COLLISION_RULE_OBJECTS.map(collisionRuleCardHtml).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="button is-fullwidth mb-2" onclick="collisionWizardChooseTrigger(\'collision\')">' + COLLISION_TRIGGER_LABELS.collision + '</button>' +
'<div class="fieldRow" style="align-items:center; gap:8px;">' +
'<button type="button" class="button is-fullwidth" onclick="collisionWizardChooseTrigger(\'perimetre\')">' + COLLISION_TRIGGER_LABELS.perimetre + '</button>' +
'<input type="number" min="1" id="collisionWizardPerimetrePx" value="' + COLLISION_DEFAULT_PERIMETRE_PX + '" style="width:80px;" class="input is-small">' +
'<span class="hint">px</span>' +
'</div>' +
'</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_ACTION_TYPES.filter(function (t) { return t !== 'interagir'; }) : COLLISION_ACTION_TYPES;
var buttons = types.map(function (type) {
return '<button type="button" class="button is-fullwidth mb-2" onclick="collisionWizardChooseAction(\'' + type + '\', ' + (nested ? 'true' : 'false') + ')">' + COLLISION_ACTION_LABELS[type] + '</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 -> son id ; é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') {
collisionWizardBodyEl().innerHTML =
'<p class="hint mb-2">Identifiant de la quête (l\'éditeur de quête reste à construire — cette règle ne fait que mémoriser la référence pour l\'instant) :</p>' +
'<div class="field is-grouped">' +
'<div class="control is-expanded"><input type="text" id="collisionWizardQueteId" class="input" placeholder="ex. quete-001"></div>' +
'<div class="control"><button type="button" class="button primary" onclick="collisionWizardSubmitQuete(' + (nested ? 'true' : 'false') + ')">Valider</button></div>' +
'</div>';
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) {
return '<button type="button" class="button is-fullwidth mb-2" onclick="collisionWizardFinalizeAction({type:\'evenement\', evenement_id:' + id + '}, ' + (nested ? 'true' : 'false') + ')">📣 ' + CUSTOM_EVENTS_MAP[id].name + '</button>';
}).join('');
collisionWizardBodyEl().innerHTML = '<p class="hint mb-2">Quel événement ?</p><div class="collisionWizardChoices">' + buttons + '</div>';
}
}
function collisionWizardSubmitQuete(nested) {
var input = document.getElementById('collisionWizardQueteId');
var queteId = input && input.value.trim();
if (!queteId) { input.focus(); return; }
collisionWizardFinalizeAction({ type: 'quete', quete_id: queteId }, nested);
}
document.addEventListener('DOMContentLoaded', function () {
if (typeof COLLISION_RULE_OBJECTS !== 'undefined') renderCollisionRuleCards();
});