// ---------- É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 dans l'onglet // "🧩 Collision" (static/js/scenes/collision-rules-editor.js). 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 petite 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 (bords qui se touchent // SANS se chevaucher), et un chevauchement STRICT (forgeShapesOverlap // nu) n'était donc jamais atteint : la règle "à la collision" ne se // déclenchait jamais. Cette marge élargit la boîte testée pour que ce // simple contact compte comme une collision. var FORGE_COLLISION_RULE_TOUCH_MARGIN = 3; 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). "perimetre" : distance entre les CENTRES des deux boîtes // <= rule.perimetre_px (pas entre les bords — plus simple et suffisant // pour une portée approximative, cohérent avec le reste du moteur qui ne // vise pas une physique précise). function forgeCollisionRuleTriggerActive(rule, objRect, playerRect) { if (rule.trigger === 'collision') return forgeShapesOverlap(forgeExpandRect(objRect, FORGE_COLLISION_RULE_TOUCH_MARGIN), playerRect); var ocx = objRect.left + objRect.width / 2, ocy = objRect.top + objRect.height / 2; var pcx = playerRect.left + playerRect.width / 2, pcy = playerRect.top + playerRect.height / 2; var dist = Math.hypot(ocx - pcx, ocy - pcy); return dist <= (rule.perimetre_px || 0); } function forgeCollisionInteractBubbleEl(objectEl) { var bubble = objectEl.querySelector('.collisionInteractBubble'); if (!bubble) { bubble = document.createElement('div'); bubble.className = 'collisionInteractBubble'; objectEl.appendChild(bubble); } return bubble; } function forgeShowCollisionInteractBubble(objectEl, key) { var bubble = forgeCollisionInteractBubbleEl(objectEl); bubble.textContent = 'Appuie sur ' + key; bubble.style.display = 'block'; } function forgeHideCollisionInteractBubble(objectEl) { var bubble = objectEl.querySelector('.collisionInteractBubble'); if (bubble) bubble.style.display = 'none'; } // Exécute une action (top-level ou sub_action d'un "interagir") — jamais // appelée pour un "interagir" lui-même à ce niveau, voir forgeCollisionRuleTick // qui gère "interagir" à part (affichage de bulle + attente de touche). function forgeRunCollisionRuleAction(action, targetEl, obj) { if (action.type === 'evenement') { window.dispatchGameEvent(action.evenement_id); return; } if (action.type === 'attaque') { var attackAnim = forgePersonnageAnimExists(obj.id, 'attaque') ? 'attaque' : null; if (attackAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: attackAnim, fps: FORGE_PERSONNAGE_FPS, loop: false })); return; } if (action.type === 'quete') { // Affiche la réplique en cours du dialogue de cette quête, selon son // statut actuel, 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é ou si la quête n'a aucune // réplique pour son statut, comme le reste de ce moteur. forgeShowDialogueBox(action.quete_id); return; } } 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, (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(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; 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; }