Supprime le concept de "quête" au profit d'un onglet unique "Déclencheurs"
portant toute la logique (dialogue, condition, marquage terminé) directement
sur l'objet de scène. Ajoute une nouvelle action "Modifier une variable"
(réutilisant le vocabulaire du graphe de flow) utilisable après une
collision, une interaction ou une branche de condition, ainsi qu'un
chaînage d'actions ("then") permettant d'enchaîner plusieurs actions à la
suite et d'étendre un déclencheur déjà posé sans le recréer.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
263 lines
14 KiB
JavaScript
263 lines
14 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 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 <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;
|
||
}
|
||
|
||
// 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 <img> : 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';
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
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, 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);
|
||
}
|
||
|
||
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;
|
||
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;
|
||
}
|