// ---------- É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'; } // ---------- Action "indication" — bulle de texte libre, temporisée ---------- // Distincte de la bulle "Appuie sur X" ci-dessus (sa propre classe CSS, // sa propre minuterie) : ne dépend jamais du cycle "actif tant que la // collision dure" d'un déclencheur "interagir", peut être posée sur // N'IMPORTE QUEL objet de l'écran (voir action.object_id, pas // nécessairement l'objet qui porte le déclencheur). var FORGE_INDICATION_DEFAULT_DURATION_MS = 3000; var forgeIndicationTimeouts = {}; function forgeIndicationBubbleEl(objectId, parentEl) { var bubble = parentEl.querySelector('.collisionIndicationBubble[data-for-object-id="' + objectId + '"]'); if (!bubble) { bubble = document.createElement('div'); bubble.className = 'collisionIndicationBubble'; bubble.setAttribute('data-for-object-id', objectId); parentEl.appendChild(bubble); } return bubble; } function forgeShowIndicationBubble(objectId, texte, dureeMs) { var targetEl = document.querySelector('[data-object-id="' + objectId + '"]'); var parentEl = targetEl && targetEl.parentElement; if (!targetEl || !parentEl || !texte) return; var objRect = forgeElementBoxRect(targetEl); var bubble = forgeIndicationBubbleEl(objectId, parentEl); bubble.textContent = texte; bubble.style.display = 'block'; bubble.style.left = (objRect.left + objRect.width / 2) + 'px'; bubble.style.top = objRect.top + 'px'; if (forgeIndicationTimeouts[objectId]) clearTimeout(forgeIndicationTimeouts[objectId]); forgeIndicationTimeouts[objectId] = setTimeout(function () { bubble.style.display = 'none'; }, dureeMs || FORGE_INDICATION_DEFAULT_DURATION_MS); } // 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); } else if (action.type === 'surbrillance' || action.type === 'visibilite') { // Cible N'IMPORTE QUEL objet de l'écran (action.object_id, pas // forcément celui qui porte le déclencheur) — réutilise TEL QUEL // applyObjectProperty (static/js/play/actions.js, équivalent scène 2D // d'applyElementProperty), plutôt que de dupliquer cette logique. var targetObjEl = document.querySelector('[data-object-id="' + action.object_id + '"]'); if (targetObjEl) applyObjectProperty(targetObjEl, action.type, action.valeur); if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj); } else if (action.type === 'son') { // Effet sonore ponctuel, non bouclé — même comportement que l'action // "Jouer un son" du Flow (static/js/play/actions.js), instantané : // `then` s'exécute tout de suite, sans attendre la fin du son. if (action.asset_url) new Audio(action.asset_url).play().catch(function () {}); if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj); } else if (action.type === 'video') { // Bloque la suite de la chaîne "then" jusqu'à la fin de la vidéo (ou // "Passer") — voir static/js/play/video-overlay.js, même esprit // qu'un dialogue ci-dessus. forgeShowVideoOverlay(action.asset_url, action.mode, function () { if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj); }); } else if (action.type === 'indication') { forgeShowIndicationBubble(action.object_id, action.texte, action.duree_ms); if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj); } else if (action.type === 'attendre') { // Suspend la chaîne "then" pendant data_value SECONDES — même // vocabulaire que l'action "attendre" du Flow (static/js/play/ // actions.js), utile ex. pour laisser un temps de lecture après une // narration à l'affichage de l'écran. var seconds = parseFloat(action.data_value); var ms = isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000; setTimeout(function () { if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj); }, ms); } } 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) { // "clic"/"survol" sont liés à part (voir forgeBindClicSurvolRules, // écouteurs DOM directs) — jamais évalués ici, sans quoi ils // s'exécuteraient EN PLUS, à la proximité du joueur, en plus de // leur propre déclencheur. if (rule.trigger !== 'collision') return; 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); } // ---------- Déclencheurs "clic"/"survol" — écouteurs DOM directs ---------- // Contrairement à "collision" (tick + proximité du joueur), ces deux // déclencheurs n'ont besoin NI d'un joueur NI d'un tick : un écouteur // posé une fois par objet suffit (voir screens/rendering/ // collision_rules.py). Re-attachés à chaque affichage d'écran, comme le // reste de ce fichier. var activeClicSurvolListeners = []; function forgeUnbindClicSurvolRules() { activeClicSurvolListeners.forEach(function (l) { l.el.removeEventListener(l.event, l.handler); }); activeClicSurvolListeners = []; } function forgeBindClicSurvolRules(screenData) { forgeUnbindClicSurvolRules(); (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) { if (rule.trigger !== 'clic' && rule.trigger !== 'survol') return; var eventName = rule.trigger === 'clic' ? 'click' : 'mouseenter'; var handler = function () { forgeRunCollisionRuleAction(rule.action, targetEl, obj); }; targetEl.addEventListener(eventName, handler); activeClicSurvolListeners.push({ el: targetEl, event: eventName, handler: handler }); }); }); } function forgeStopCollisionRuleControllers() { if (activeCollisionRuleInterval) { clearInterval(activeCollisionRuleInterval); activeCollisionRuleInterval = null; } if (activeCollisionRuleInteractListener) { window.removeEventListener('keydown', activeCollisionRuleInteractListener); activeCollisionRuleInteractListener = null; } forgeUnbindClicSurvolRules(); forgeCollisionRuleActiveState = {}; } // Même patron par écran que forgeStartPersonnageControllers — appelé // juste après lui (voir static/js/play/screens.js). "clic"/"survol" sont // liés inconditionnellement (aucun joueur requis) ; le tick de collision // + l'écouteur "interagir" ne démarrent, eux, 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 || []; forgeBindClicSurvolRules(screenData); 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, forgeBindClicSurvolRules, forgeUnbindClicSurvolRules, forgeShowIndicationBubble, }; 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; const actionsMod = require('./actions.js'); global.applyObjectProperty = global.applyObjectProperty || actionsMod.applyObjectProperty; const videoOverlayMod = require('./video-overlay.js'); global.forgeShowVideoOverlay = global.forgeShowVideoOverlay || videoOverlayMod.forgeShowVideoOverlay; }