// ---------- É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. let activeCollisionRuleInterval = null; let 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). let forgeCollisionRuleActiveState = {}; function forgeCollisionRuleStateKey(objectId, ruleIndex) { return `${objectId}:${ruleIndex}`; } function forgeElementBoxRect(el, collision) { const left = Number.parseFloat(el.style.left) || 0; const top = Number.parseFloat(el.style.top) || 0; const width = Number.parseFloat(el.style.width) || 0; const height = Number.parseFloat(el.style.height) || 0; return forgeCollisionRectFromBox({ left, top, width, 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. const 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) { let bubble = parentEl.querySelector(`.collisionInteractBubble[data-for-object-id="${objectId}"]`); if (!bubble) { bubble = document.createElement('div'); bubble.className = 'collisionInteractBubble'; bubble.dataset.forObjectId = 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) { const parentEl = objectEl.parentElement; if (!parentEl) return; const 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) { const parentEl = objectEl?.parentElement; const bubble = 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). const FORGE_INDICATION_DEFAULT_DURATION_MS = 3000; const forgeIndicationTimeouts = {}; function forgeIndicationBubbleEl(objectId, parentEl) { let bubble = parentEl.querySelector(`.collisionIndicationBubble[data-for-object-id="${objectId}"]`); if (!bubble) { bubble = document.createElement('div'); bubble.className = 'collisionIndicationBubble'; bubble.dataset.forObjectId = objectId; parentEl.appendChild(bubble); } return bubble; } function forgeShowIndicationBubble(objectId, texte, dureeMs) { const targetEl = document.querySelector(`[data-object-id="${objectId}"]`); const parentEl = targetEl?.parentElement; if (!targetEl || !parentEl || !texte) return; const objRect = forgeElementBoxRect(targetEl); const 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(() => { bubble.style.display = 'none'; }, dureeMs || FORGE_INDICATION_DEFAULT_DURATION_MS); } // 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é. function runDialogueCollisionAction(action, targetEl, obj) { const lines = action.dialogue?.lines || []; const dialogueId = action.dialogue?.id; forgeShowDialogueBox(dialogueId, obj.name || 'PNJ', lines, action.mark_completed, action.then); } // 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. function runVariableCollisionAction(action, targetEl, obj) { forgeApplyVariableActionOffline(gameData, action); if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj); } // 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). function runConditionCollisionAction(action, targetEl, obj) { const isTrue = evaluateConditionClause({ source: 'variable', variable: action.variable, operator: action.operateur, value: action.valeur, }); const branch = isTrue ? action.si_vrai : action.si_faux; if (branch) forgeRunCollisionRuleAction(branch, targetEl, obj); } // 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. Partagée entre "surbrillance" et // "visibilite", qui suivent exactement le même patron. function runSurbrillanceOuVisibiliteCollisionAction(action, targetEl, obj) { const 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); } // 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. function runSonCollisionAction(action, targetEl, obj) { if (action.asset_url) new Audio(action.asset_url).play().catch(() => {}); if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj); } // 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. function runVideoCollisionAction(action, targetEl, obj) { forgeShowVideoOverlay(action.asset_url, action.mode, () => { if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj); }); } function runIndicationCollisionAction(action, targetEl, obj) { forgeShowIndicationBubble(action.object_id, action.texte, action.duree_ms); if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj); } // 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. function runAttendreCollisionAction(action, targetEl, obj) { const seconds = Number.parseFloat(action.data_value); const ms = Number.isNaN(seconds) ? 0 : Math.max(0, seconds) * 1000; setTimeout(() => { if (action.then) forgeRunCollisionRuleAction(action.then, targetEl, obj); }, ms); } const COLLISION_RULE_ACTION_HANDLERS = { dialogue: runDialogueCollisionAction, variable: runVariableCollisionAction, condition: runConditionCollisionAction, surbrillance: runSurbrillanceOuVisibiliteCollisionAction, visibilite: runSurbrillanceOuVisibiliteCollisionAction, son: runSonCollisionAction, video: runVideoCollisionAction, indication: runIndicationCollisionAction, attendre: runAttendreCollisionAction, }; // 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) { const handler = COLLISION_RULE_ACTION_HANDLERS[action.type]; if (handler) handler(action, targetEl, obj); } function forgeCollisionRuleTick(screenData) { const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur'); const playerEl = player && document.querySelector(`[data-object-id="${player.id}"]`); if (!player || !playerEl) return; const playerRect = forgeElementBoxRect(playerEl, player.collision); (screenData.elements || []).forEach((obj) => { const rules = obj.collision_rules; if (!rules?.length) return; const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`); if (!targetEl) return; const objRect = forgeElementBoxRect(targetEl, obj.collision); let anyInteractActive = false; rules.forEach((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; const stateKey = forgeCollisionRuleStateKey(obj.id, ruleIndex); const isActive = forgeCollisionRuleTriggerActive(rule, objRect, playerRect); const wasActive = !!forgeCollisionRuleActiveState[stateKey]; if (rule.action.type === 'interagir') { if (isActive) { anyInteractActive = true; forgeShowCollisionInteractBubble(targetEl, obj.id, objRect, 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) { const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur'); if (!player?.personnage_commandes || ev.key !== player.personnage_commandes.interagir) return; (screenData.elements || []).forEach((obj) => { const rules = obj.collision_rules; if (!rules?.length) return; const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`); if (!targetEl) return; rules.forEach((rule, ruleIndex) => { if (rule.action.type !== 'interagir') return; const 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. let activeClicSurvolListeners = []; function forgeUnbindClicSurvolRules() { activeClicSurvolListeners.forEach((l) => { l.el.removeEventListener(l.event, l.handler); }); activeClicSurvolListeners = []; } function forgeBindClicSurvolRules(screenData) { forgeUnbindClicSurvolRules(); (screenData.elements || []).forEach((obj) => { const rules = obj.collision_rules; if (!rules?.length) return; const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`); if (!targetEl) return; rules.forEach((rule) => { if (rule.trigger !== 'clic' && rule.trigger !== 'survol') return; const eventName = rule.trigger === 'clic' ? 'click' : 'mouseenter'; const handler = function () { forgeRunCollisionRuleAction(rule.action, targetEl, obj); }; targetEl.addEventListener(eventName, handler); activeClicSurvolListeners.push({ el: targetEl, event: eventName, 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?.kind !== 'jeu_2d') return; const elements = screenData.elements || []; forgeBindClicSurvolRules(screenData); const hasPlayer = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur'); const hasRules = elements.some((o) => o.collision_rules?.length); if (!hasPlayer || !hasRules) return; activeCollisionRuleInterval = setInterval(() => { 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; }