É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' } }] }] }));
});