Files
Forge-Engine/static/js/play/personnage-controller.js
T
williamandClaude Sonnet 5 b5546df044
Build and deploy / test-python (push) Failing after 1m34s
Build and deploy / test-js (push) Successful in 1m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Quiz jouable : budget de récompense, boîte à quiz, widget score
- Récompense d'une quête = budget MAXIMUM pour ses questions : la somme
  des points des "❓ Question" ne peut plus dépasser recompense_score
  (routes/quests/quest_dialogues.py à l'enregistrement des dialogues,
  quest_update.py si on abaisse la récompense sous ce qui est déjà
  réparti) — message d'erreur explicite (400) dans les deux sens,
  affiché via alert() côté éditeur (quest-editor.js), jamais enregistré
  silencieusement dans un état incohérent.
- Nombre de choix par question plafonné à 4 (au lieu de 8).

- Deux nouveaux widgets "🖥️ Interface" (mêmes fondations que "💬 Boîte
  de dialogue" — screens/rendering/dialogue_box_style.py, même panneau
  "🎨 Style") :
  - "❓ Boîte à quiz" : affiche la question et ses choix, sans pied (une
    question se résout au clic sur un choix, pas de "Suivant").
  - "🏆 Score" : affiche en continu les points gagnés, TOUJOURS visible
    une fois posé (contrairement aux boîtes de dialogue/quiz, masquées
    par défaut).

- Moteur d'exécution (static/js/play/dialogue-box-controller.js,
  refonte) : une conversation de quête alterne maintenant répliques
  (boîte de dialogue) et questions (boîte à quiz) selon le type de
  chaque ligne. Bonne réponse -> crédite le score (compteur runtime
  dédié, jamais lié au score du moteur document) et avance ; mauvaise
  réponse -> rien, la question reste affichée pour réessayer ; aucune
  boîte à quiz posée -> la question est ignorée plutôt que de bloquer
  la conversation. Le dialogue "en_cours" épuisé (donc, s'il contenait
  des questions, toutes répondues) fait passer la quête "terminee" (en
  mémoire seulement, comme le reste de ce système — voir accepter/
  refuser une offre de quête) : le joueur peut alors quitter la scène
  normalement, ces widgets n'étant jamais des obstacles.

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

270 lines
15 KiB
JavaScript

// ---------- Personnage jouable — déplacement/animation automatiques ----------
// N'importe quel objet de scène kind="personnage" se déplace et s'anime
// TOUT SEUL avec ses touches (ZQSD + E par défaut — voir "Commandes" du
// panneau de propriétés, templates/scene_edit.html) dès qu'il est posé sur
// la scène, sans le moindre nœud de flow à poser — contrairement au reste
// du moteur, entièrement piloté par le graphe de logique. Réutilise
// heldKeys (déjà tenu à jour par bindHeldKeyTriggers, triggers.js) pour
// savoir quelles touches sont enfoncées, et
// applyObjectProperty/clampSceneObjectPosition/runSpriteAnimation/
// resolveSpriteFrames (actions.js) pour le déplacement/l'animation — même
// mécanique que "Modifier un objet de scène"/"Jouer une animation",
// jamais dupliquée.
var activePersonnageControllerInterval = null;
var activePersonnageInteractListener = null;
// Un seul rythme pour TOUTES les poses (marche, idle, interagir, touches
// supplémentaires) — les désynchroniser (ex. idle plus lent que walk)
// n'a aucune justification et se voit immédiatement au jeu (bug signalé
// par l'utilisateur).
var FORGE_PERSONNAGE_FPS = 8;
function forgePersonnageAnimExists(elementId, animName) {
var byAnim = (gameData.personnage_animations || {})[elementId];
return !!(byAnim && byAnim[animName] && byAnim[animName].length);
}
// "walk" est la SEULE animation de déplacement du catalogue Forge/CraftPix
// (voir screens/labels/sprite_library.py — aucune variante directionnelle
// walk_haut/walk_bas...) : le sens se voit via l'inversion miroir
// (orientation, déjà existante — Phase 7), pas une frame différente.
// Absente (personnage sans cette pose) : le déplacement continue sans
// animation plutôt que de planter, comme le reste du moteur.
function forgePersonnageMovementAnim(elementId) {
return forgePersonnageAnimExists(elementId, 'walk') ? 'walk' : null;
}
function forgePersonnageIdleAnim(elementId) {
return forgePersonnageAnimExists(elementId, 'idle') ? 'idle' : null;
}
// Taille du "monde" (voir static/style.css .sceneWorld) : la scène elle-
// même par défaut, agrandie si un objet kind="fond" (voir
// add_scene_object.py) dépasse ses bords — un jeu sans fond XXL garde
// donc EXACTEMENT son comportement d'avant (monde == scène). Mémorisée
// directement sur screenData (déjà l'objet partagé avec screensData,
// voir static/js/play/actions.js) pour que clampSceneObjectPosition
// borne un déplacement au monde entier, pas seulement au petit cadre
// visible de la scène.
function forgeUpdateSceneWorldSize(screenData) {
var width = screenData.scene_width, height = screenData.scene_height;
(screenData.elements || []).forEach(function (o) {
if (o.kind !== 'fond') return;
width = Math.max(width, (o.x || 0) + (o.width || 0));
height = Math.max(height, (o.y || 0) + (o.height || 0));
});
screenData.world_width = width;
screenData.world_height = height;
return { width: width, height: height };
}
// Caméra qui suit le personnage (le premier objet kind="personnage"
// trouvé — aucune notion de "joueur" distincte n'existe ailleurs dans le
// moteur 2D, voir personnage-controller.js dans son ensemble) : centre le
// monde sur lui, borné pour ne jamais montrer au-delà de ses bords (même
// principe que clampSceneObjectPosition, actions.js, mais pour la caméra
// plutôt qu'un objet). Sans effet (transform vidé) tant que le monde ne
// dépasse pas la scène — c'est ce qui garde le comportement identique à
// avant pour tout jeu sans fond plus grand que sa scène.
function forgeUpdateSceneCamera(screenData) {
var worldEl = document.querySelector('.playScreen[data-screen-id="' + screenData.id + '"] .sceneWorld');
if (!worldEl) return;
var world = forgeUpdateSceneWorldSize(screenData);
worldEl.style.width = world.width + 'px';
worldEl.style.height = world.height + 'px';
if (world.width <= screenData.scene_width && world.height <= screenData.scene_height) {
worldEl.style.transform = '';
return;
}
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 + '"]');
var px = playerEl ? (parseFloat(playerEl.style.left) || 0) + (parseFloat(playerEl.style.width) || 0) / 2 : world.width / 2;
var py = playerEl ? (parseFloat(playerEl.style.top) || 0) + (parseFloat(playerEl.style.height) || 0) / 2 : world.height / 2;
var offsetX = Math.min(Math.max(px - screenData.scene_width / 2, 0), Math.max(0, world.width - screenData.scene_width));
var offsetY = Math.min(Math.max(py - screenData.scene_height / 2, 0), Math.max(0, world.height - screenData.scene_height));
worldEl.style.transform = 'translate(' + (-offsetX) + 'px, ' + (-offsetY) + 'px)';
}
function forgeStopPersonnageControllers() {
if (activePersonnageControllerInterval) {
clearInterval(activePersonnageControllerInterval);
activePersonnageControllerInterval = null;
}
if (activePersonnageInteractListener) {
window.removeEventListener('keydown', activePersonnageInteractListener);
activePersonnageInteractListener = null;
}
}
// Boîte de collision d'un objet à une position DONNÉE (pas forcément sa
// position actuelle — voir forgeWouldCollide ci-dessous, qui teste une
// position candidate AVANT de l'appliquer) — réutilise forgeCollisionRectFromBox
// (conditions.js, déjà chargé avant ce fichier dans templates/play.html),
// la MÊME formule que la condition de collision de flow (elementsOverlap).
function forgeObjectCollisionRectAt(left, top, width, height, collision) {
return forgeCollisionRectFromBox({ left: left, top: top, width: width, height: height }, collision);
}
// Tout objet de scène potentiellement "solide" pour le déplacement au
// clavier — jamais un "fond" (image de décor, voir add_scene_object.py :
// pointer-events déjà désactivés au jeu, jamais un obstacle), ni un
// objet dont la collision est explicitement désactivée, ni l'objet qui
// se déplace lui-même.
function forgeSolidObstacles(screenData, movingId) {
return (screenData.elements || []).filter(function (o) {
return o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && o.kind !== 'quiz_box' && o.kind !== 'score_widget' && !(o.collision && o.collision.enabled === false);
});
}
// Est-ce que le personnage, À la position candidate (newLeft/newTop),
// chevaucherait la boîte de collision d'un AUTRE objet solide ? Bloque
// alors le déplacement (voir forgeRunPersonnageControllerTick) — demandé
// par l'utilisateur : "elle doit empêcher d'avancer en cas de collision".
// Lit la position ACTUELLE des autres objets sur le DOM (pas
// screenData.elements, jamais mis à jour après un premier déplacement —
// voir actions.js, qui ne touche que le style, jamais les données) pour
// rester correct même si un autre objet a déjà bougé.
function forgeWouldCollide(screenData, movingObj, newLeft, newTop, width, height) {
const movingRect = forgeObjectCollisionRectAt(newLeft, newTop, width, height, movingObj.collision);
const obstacles = forgeSolidObstacles(screenData, movingObj.id);
for (let i = 0; i < obstacles.length; i++) {
const other = obstacles[i];
const otherEl = document.querySelector('[data-object-id="' + other.id + '"]');
if (!otherEl) continue;
const otherLeft = parseFloat(otherEl.style.left) || 0;
const otherTop = parseFloat(otherEl.style.top) || 0;
const otherWidth = parseFloat(otherEl.style.width) || 0;
const otherHeight = parseFloat(otherEl.style.height) || 0;
const otherRect = forgeObjectCollisionRectAt(otherLeft, otherTop, otherWidth, otherHeight, other.collision);
if (forgeShapesOverlap(movingRect, otherRect)) return true;
}
return false;
}
// 50ms (20 fois/seconde) — même cadence que runScreenHeldKeyTriggers
// (triggers.js), pour un déplacement perçu comme continu.
function forgeRunPersonnageControllerTick(screenData) {
forgeUpdateSceneCamera(screenData);
(screenData.elements || []).forEach(function (obj) {
// Seul le personnage "joueur" (voir screens/rendering/personnage_role.py)
// est déplacé/animé au clavier — "ennemie"/"pnj" restent immobiles tant
// qu'aucune logique de flow ne les pilote (voir add_scene_object.py).
if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return;
var cmd = obj.personnage_commandes;
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
if (!targetEl) return;
var rawDx = 0, rawDy = 0;
if (cmd.axe !== 'vertical') {
if (heldKeys.has(cmd.gauche)) rawDx -= cmd.vitesse;
if (heldKeys.has(cmd.droite)) rawDx += cmd.vitesse;
}
if (cmd.axe !== 'horizontal') {
if (heldKeys.has(cmd.haut)) rawDy -= cmd.vitesse;
if (heldKeys.has(cmd.bas)) rawDy += cmd.vitesse;
}
// Bloque le déplacement axe par axe (pas les deux à la fois dès qu'un
// seul est gêné) — glisser le long d'un mur en diagonale reste
// possible, plus naturel qu'un blocage total au moindre contact.
var curLeft = parseFloat(targetEl.style.left) || 0;
var curTop = parseFloat(targetEl.style.top) || 0;
var boxWidth = parseFloat(targetEl.style.width) || 0;
var boxHeight = parseFloat(targetEl.style.height) || 0;
var dx = rawDx, dy = rawDy;
if (dx !== 0) {
var candidateLeft = clampSceneObjectPosition(targetEl, 'left', curLeft + dx);
if (forgeWouldCollide(screenData, obj, candidateLeft, curTop, boxWidth, boxHeight)) dx = 0;
}
if (dy !== 0) {
var candidateTop = clampSceneObjectPosition(targetEl, 'top', curTop + dy);
if (forgeWouldCollide(screenData, obj, curLeft, candidateTop, boxWidth, boxHeight)) dy = 0;
}
// Se tourne vers la direction TENTÉE même si le déplacement est
// finalement bloqué (fait face au mur/obstacle, plus naturel qu'un
// personnage qui reste dos tourné contre ce qui le bloque).
if (rawDx !== 0) applyObjectProperty(targetEl, 'orientation', rawDx < 0 ? 'gauche' : 'droite');
// Même fps pour idle et walk (bug signalé par l'utilisateur : idle
// tournait à 4 i/s contre 8 pour walk, perçue comme "les autres
// animations sont lentes à côté de la marche" — aucune raison réelle
// de les désynchroniser).
if (dx === 0 && dy === 0) {
var idleAnim = forgePersonnageIdleAnim(obj.id);
if (idleAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: idleAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }));
return;
}
if (dx !== 0) applyObjectProperty(targetEl, 'pos_x_relatif', dx);
if (dy !== 0) applyObjectProperty(targetEl, 'pos_y_relatif', dy);
var moveAnim = forgePersonnageMovementAnim(obj.id);
if (moveAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: moveAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }));
});
}
// Quelle pose jouer pour une touche d'ACTION (pas de déplacement) donnée —
// "interagir" (usage fixe, pose "interact") d'abord, sinon la première
// touche supplémentaire (cmd.extra, voir "Animations supplémentaires" du
// panneau Commandes) qui correspond, pour un nombre de poses illimité
// (sauter, attaquer, courir...) sans ajouter un champ fixe par pose.
function forgePersonnageActionAnim(cmd, key) {
if (cmd.interagir === key) return 'interact';
var extraMatch = (cmd.extra || []).find(function (e) { return e.touche === key; });
return extraMatch ? extraMatch.animation : null;
}
// Ponctuel (keydown, pas heldKeys) — contrairement au déplacement, une
// touche d'action est une action instantanée (interagir, sauter,
// attaquer...), jamais un mouvement continu.
function forgeStartPersonnageInteractListener(screenData) {
activePersonnageInteractListener = function (ev) {
(screenData.elements || []).forEach(function (obj) {
if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return;
var animName = forgePersonnageActionAnim(obj.personnage_commandes, ev.key);
if (!animName || !forgePersonnageAnimExists(obj.id, animName)) return;
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
if (!targetEl) return;
runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: animName, fps: FORGE_PERSONNAGE_FPS, loop: false }));
});
};
window.addEventListener('keydown', activePersonnageInteractListener);
}
// Même patron PAR ÉCRAN que runScreenHeldKeyTriggers/runScreenTimerTriggers
// (triggers.js/screens.js) : n'existe que tant que l'écran est affiché,
// réinitialisé à chaque changement d'écran (voir showScreen()).
function forgeStartPersonnageControllers(screenData) {
forgeStopPersonnageControllers();
if (!screenData || screenData.kind !== 'jeu_2d') return;
var elements = screenData.elements || [];
var hasPersonnage = elements.some(function (o) { return o.kind === 'personnage' && o.personnage_role === 'joueur'; });
var hasFond = elements.some(function (o) { return o.kind === 'fond'; });
// Appliqué tout de suite (avant le premier tick de l'intervalle, 50ms
// plus tard) pour éviter un flash d'une frame à la taille/caméra par
// défaut. Un fond seul (sans personnage) n'a besoin que de CET appel
// ponctuel — rien à suivre en continu ; un personnage, lui, a besoin du
// tick régulier pour le déplacement continu au clavier.
if (hasFond) forgeUpdateSceneCamera(screenData);
if (!hasPersonnage) return;
activePersonnageControllerInterval = setInterval(function () { forgeRunPersonnageControllerTick(screenData); }, 50);
forgeStartPersonnageInteractListener(screenData);
}
// static/js/play/__tests__/ (node:test) — pas de risque en navigateur :
// `module` n'existe pas là-bas, cette branche ne s'exécute jamais côté
// client.
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim, forgePersonnageActionAnim,
forgeRunPersonnageControllerTick, forgeStartPersonnageControllers, forgeStopPersonnageControllers,
forgeStartPersonnageInteractListener, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera,
forgeObjectCollisionRectAt, forgeSolidObstacles, forgeWouldCollide,
};
// node:test charge ce fichier isolément (voir __tests__/) — jamais un
// <script> séparé en navigateur, où conditions.js est déjà chargé avant
// celui-ci (voir templates/play.html).
const conditionsMod = require('./conditions.js');
global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox;
global.forgeShapesOverlap = global.forgeShapesOverlap || conditionsMod.forgeShapesOverlap;
}