// ---------- É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; }