Files
Forge-Engine/static/js/play/collision-rules-controller.js
T
williamandClaude Sonnet 5 00f9cf33e9
Build and deploy / test-python (push) Successful in 6m12s
Build and deploy / test-js (push) Successful in 52s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Fix bulle "Appuie sur X" invisible + retire Attaque/Événement du picker
Bug corrigé : la bulle "Appuie sur X" était ajoutée comme ENFANT de
l'objet de scène (un <img> pour "personnage"/"decor"/"fond") — un
élément REMPLACÉ dont les enfants DOM ajoutés en JS ne sont JAMAIS
affichés par un navigateur, quel que soit son CSS. La bulle existait
donc bien dans le DOM (aucune erreur) mais restait invisible à l'écran.
Elle est maintenant ajoutée comme SŒUR de l'objet dans son parent
(.sceneWorld/.sceneUI), positionnée en JS aux mêmes coordonnées.

Assistant "+ Action" de l'éditeur de collision : "⚔️ Attaquer la cible"
et "📣 Déclencher un événement" retirés des choix proposés (pas besoin
pour le moment) — le serveur continue d'accepter/d'exécuter une règle
déjà enregistrée avec l'un des deux, rien ne casse pour l'existant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:58:00 +02:00

239 lines
12 KiB
JavaScript

// ---------- É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 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). "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);
}
// 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 <img> 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;
}
function forgeShowCollisionInteractBubble(objectEl, objectId, key) {
var parentEl = objectEl.parentElement;
if (!parentEl) return;
var bubble = forgeCollisionInteractBubbleEl(objectId, parentEl);
bubble.textContent = 'Appuie sur ' + key;
bubble.style.display = 'block';
var left = parseFloat(objectEl.style.left) || 0;
var top = parseFloat(objectEl.style.top) || 0;
var width = parseFloat(objectEl.style.width) || 0;
bubble.style.left = (left + width / 2) + 'px'; // centrée horizontalement (voir transform:translateX(-50%), static/style.css)
bubble.style.top = top + 'px'; // AU-DESSUS de l'objet (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 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, obj.id, (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;
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;
}