Éditeur de collision (jeu 2D) : règles trigger -> action par objet
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:
co-authored by
Claude Sonnet 5
parent
dfaf85fac1
commit
ee270d0286
@@ -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' } }] }] }));
|
||||
});
|
||||
Reference in New Issue
Block a user