Files
Forge-Engine/static/js/play/collision-rules-controller.js
T
williamandClaude Sonnet 5 ee270d0286
Build and deploy / test-python (push) Successful in 6m31s
Build and deploy / test-js (push) Successful in 50s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Éditeur de collision (jeu 2D) : règles trigger -> action par objet
Nouvel onglet "🧩 Collision" dans l'éditeur de scène 2D : chaque objet
(hors joueur et fond) peut porter des règles "à la collision" ou "dans
un périmètre (px)" déclenchant une action (quête, attaque, événement,
ou interagir - qui affiche "Appuie sur [touche]" puis exécute une
sous-action à l'appui, un seul niveau d'imbrication). Backend
(sanitisation, route de persistance, exposition dans
full_game_payload) + assistant modal en cartes empilées côté client.

Ajoute le moteur d'exécution runtime (collision-rules-controller.js) :
détecte l'entrée en collision/périmètre avec le joueur à chaque tick,
déclenche l'action une seule fois par entrée, pur JS client sans appel
serveur (fonctionne à l'identique en ligne et en export SCORM).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 17:11:42 +02:00

189 lines
8.8 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);
}
// "collision" : même chevauchement de boîtes que forgeWouldCollide
// (personnage-controller.js). "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(objRect, 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') {
// Éditeur de quête pas encore construit (voir plan) — mémorise
// l'intention pour un futur système de quêtes plutôt que de la perdre.
window.forgeQuestLog = window.forgeQuestLog || [];
window.forgeQuestLog.push(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,
};
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;
}