// ---------- É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 depuis l'onglet
// "Déclencheurs" (static/js/triggers/trigger-editor.js::openTriggerWizard).
// 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);
}
// Une marge (px) appliquée SEULEMENT à la détection du déclencheur
// "collision" ci-dessous — jamais au blocage physique du déplacement
// (forgeWouldCollide, personnage-controller.js, qui reste strict). Bug
// corrigé : un objet est presque toujours aussi un OBSTACLE SOLIDE
// (collision activée par défaut) — le joueur ne peut donc jamais aller
// au-delà d'un contact tangent, et un chevauchement STRICT
// (forgeShapesOverlap nu) n'était donc jamais atteint : la règle "à la
// collision" ne se déclenchait jamais. forgeWouldCollide bloque le
// déplacement PAR PAS ENTIER (cmd.vitesse px/tick, 4 par défaut mais
// réglable plus haut dans "Commandes") : le joueur peut donc rester
// bloqué jusqu'à PRESQUE un pas entier de distance de l'obstacle, pas
// seulement 1-2px — une marge trop petite (ex. 3px) restait donc
// insuffisante pour une vitesse par défaut déjà. 24px couvre
// confortablement toute vitesse raisonnablement configurée, tout en
// restant petit face à la taille d'un sprite (souvent 64-128px) pour ne
// jamais déclencher "à la collision" alors que les objets sont encore
// visiblement séparés.
var FORGE_COLLISION_RULE_TOUCH_MARGIN = 24;
function forgeExpandRect(rect, margin) {
return {
left: rect.left - margin, top: rect.top - margin,
right: rect.right + margin, bottom: rect.bottom + margin,
width: rect.width + margin * 2, height: rect.height + margin * 2,
shape: rect.shape,
};
}
// "collision" : chevauchement des boîtes, ÉLARGI d'une petite marge pour
// détecter un simple contact (voir FORGE_COLLISION_RULE_TOUCH_MARGIN
// ci-dessus) — seul déclencheur encore proposé ("perimetre" retiré, ne
// servait à rien pour l'instant, demande explicite).
function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) {
return forgeShapesOverlap(forgeExpandRect(objRect, FORGE_COLLISION_RULE_TOUCH_MARGIN), playerRect);
}
// La bulle "Appuie sur X" ne peut PAS être un enfant de objectEl : un
// objet de scène (voir render_scene_object.py) est un
tout simple
// pour "personnage"/"decor"/"fond" — un élément REMPLACÉ, sans modèle de
// contenu, dont les enfants ajoutés en DOM ne sont JAMAIS affichés par
// un navigateur (bug corrigé : la bulle existait bien dans le DOM mais
// restait invisible). Ajoutée comme SŒUR de objectEl (dans son parent,
// déjà position:relative — .sceneWorld/.sceneUI, voir static/style.css),
// positionnée en JS aux mêmes coordonnées que l'objet.
function forgeCollisionInteractBubbleEl(objectId, parentEl) {
var bubble = parentEl.querySelector('.collisionInteractBubble[data-for-object-id="' + objectId + '"]');
if (!bubble) {
bubble = document.createElement('div');
bubble.className = 'collisionInteractBubble';
bubble.setAttribute('data-for-object-id', objectId);
parentEl.appendChild(bubble);
}
return bubble;
}
// Positionnée sur la BOÎTE DE COLLISION (objRect, déjà résolue via
// forgeElementBoxRect — largeur/hauteur/décalage réglables dans "🧱
// Collision") plutôt que sur le cadre brut du
: beaucoup de
// sprites (notamment les animaux CraftPix, voir screens/labels/
// animal_sprite_library.py) ont un TRÈS GRAND canevas très padé autour
// d'une silhouette bien plus petite — ancrée sur le cadre brut, la
// bulle flottait loin au-dessus du personnage visible (bug corrigé :
// "c'est trop haut"). La boîte de collision, que l'auteur ajuste déjà
// pour épouser la silhouette réelle, donne une ancre bien plus fidèle.
function forgeShowCollisionInteractBubble(objectEl, objectId, objRect, key) {
var parentEl = objectEl.parentElement;
if (!parentEl) return;
var bubble = forgeCollisionInteractBubbleEl(objectId, parentEl);
bubble.textContent = 'Appuie sur ' + key;
bubble.style.display = 'block';
bubble.style.left = (objRect.left + objRect.width / 2) + 'px'; // centrée horizontalement (voir transform:translateX(-50%), static/style.css)
bubble.style.top = objRect.top + 'px'; // AU-DESSUS de la boîte de collision (voir transform:translateY(-100%), static/style.css)
}
function forgeHideCollisionInteractBubble(objectId, objectEl) {
var parentEl = objectEl && objectEl.parentElement;
var bubble = parentEl && parentEl.querySelector('.collisionInteractBubble[data-for-object-id="' + objectId + '"]');
if (bubble) bubble.style.display = 'none';
}
// Exécute une action (top-level, sub_action d'un "interagir", ou branche
// si_vrai/si_faux d'une "condition") — jamais appelée pour un "interagir"
// lui-même à ce niveau, voir forgeCollisionRuleTick qui le gère à part
// (affichage de bulle + attente de touche).
function forgeRunCollisionRuleAction(action, targetEl, obj) {
if (action.type === 'dialogue') {
// Affiche LE DIALOGUE DE CETTE ACTION PRÉCISE (action.dialogue.lines
// — chaque occurrence de "Déclencher ce dialogue" porte le sien,
// voir screens/rendering/collision_rules.py) dans le widget "💬 Boîte
// de dialogue" posé sur l'écran (voir static/js/play/
// dialogue-box-controller.js) — sans effet si aucun widget n'est
// posé, comme le reste de ce moteur. Le nom affiché (en-tête du
// quiz, xAPI) est celui de l'objet déclencheur (voir "ℹ️
// Informations") — plus de "quête" pour le porter. mark_completed
// (optionnel) : marque ce dialogue terminé une fois épuisé.
// `action.then` (chaînage, voir maquette validée : "pouvoir modifier
// un déclencheur pour lui ajouter des actions") NE PEUT PAS
// s'exécuter immédiatement — un dialogue attend des clics du joueur
// — transmis à forgeShowDialogueBox, exécuté par dialogue-box-
// controller.js une fois le dialogue épuisé.
var lines = (action.dialogue && action.dialogue.lines) || [];
var dialogueId = action.dialogue && action.dialogue.id;
forgeShowDialogueBox(dialogueId, obj.name || 'PNJ', lines, action.mark_completed, action.then);
} else if (action.type === 'variable') {
// Modifie une variable globale — même vocabulaire/calcul que l'action
// "Modifier une variable" du graphe de logique document (voir
// screens/data_actions/apply_variable_action.py), portée ici par
// forgeApplyVariableActionOffline (static/js/play/offline/apply-actions.js,
// TOUJOURS chargée — voir templates/play.html — pas seulement en
// export, donc aucun branchement FORGE_OFFLINE nécessaire, même
// philosophie que le reste de ce fichier). Instantané : `then`
// s'exécute tout de suite, contrairement à un dialogue.
forgeApplyVariableActionOffline(gameData, action);
if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj);
} else if (action.type === 'condition') {
// Réutilise evaluateConditionClause (static/js/play/conditions.js),
// même comparaison qu'un nœud Condition de l'éditeur de logique, sur
// une VARIABLE GLOBALE (gameData.variables) — jamais un champ d'objet
// ici (voir maquette validée : le losange ne teste qu'une variable).
var isTrue = evaluateConditionClause({
source: 'variable', variable: action.variable, operator: action.operateur, value: action.valeur,
});
var branch = isTrue ? action.si_vrai : action.si_faux;
if (branch) forgeRunCollisionRuleAction(branch, targetEl, obj);
}
}
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, obj.id, objRect, (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(obj.id, 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, forgeExpandRect,
};
const conditionsMod = require('./conditions.js');
global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox;
global.forgeShapesOverlap = global.forgeShapesOverlap || conditionsMod.forgeShapesOverlap;
global.evaluateConditionClause = global.evaluateConditionClause || conditionsMod.evaluateConditionClause;
const personnageMod = require('./personnage-controller.js');
global.forgePersonnageAnimExists = global.forgePersonnageAnimExists || personnageMod.forgePersonnageAnimExists;
global.FORGE_PERSONNAGE_FPS = global.FORGE_PERSONNAGE_FPS || 8;
const dialogueBoxMod = require('./dialogue-box-controller.js');
global.forgeShowDialogueBox = global.forgeShowDialogueBox || dialogueBoxMod.forgeShowDialogueBox;
}