Files
Forge-Engine/static/js/play/personnage-controller.js
T
williamandClaude Sonnet 5 ccf836f2c5
Build and deploy / test-python (push) Successful in 11m18s
Build and deploy / test-js (push) Successful in 1m16s
Build and deploy / lint-python (push) Successful in 4m24s
Build and deploy / lint-js (push) Successful in 2m51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Successful in 5m30s
Lot 4 modernisation JS (SonarLint) + restauration CI sonarqube non-bloquante
Corrige les findings SonarQube (via SonarLint IDE, fichier par fichier)
sur ~24 fichiers static/js/ : parseFloat/parseInt -> Number.*,
.replace(/x/g,y) -> .replaceAll, .indexOf() -> .includes()/.startsWith(),
getAttribute/setAttribute -> .dataset, tableaux -> Set, x && x.y -> x?.y
(verifie site par site), extraction de template litteraux imbriques,
ternaires imbriquees, refactors de complexite cognitive (S3776) via
tables de dispatch, Object.hasOwn, .at(), et deduplication de fonctions
identiques (S4144). Deux exceptions S2486 documentees/corrigees
(filter-repeater-rows.js) et un cas S2703 de partage inter-scripts
complete (_collisionWizard, trigger-editor.js <-> collision-rules-editor.js).
Details complets dans CODE_QUALITY.md section 5.

Restaure aussi le job CI "sonarqube" (non-bloquant) dans
.gitea/workflows/deploy.yml maintenant que l'instance prod est
operationnelle.

Suites vertes : 276/276 JS (node --test), 591/591 Python (pytest).

SKIP=djlint sur ce commit : hook djlint bloquant sur le backlog H021
(styles inline, 49 occurrences/6 templates) deja documente comme dette
assumee non traitee dans CODE_QUALITY.md section 6, aucun rapport avec
ce commit (aucun template touche ici) - valide explicitement avec
l'utilisateur avant de contourner ce hook.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 16:38:29 +02:00

333 lines
16 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.
let activePersonnageControllerInterval = null;
let 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).
const FORGE_PERSONNAGE_FPS = 8;
function forgePersonnageAnimExists(elementId, animName) {
const byAnim = gameData.personnage_animations?.[elementId];
return !!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) {
let width = screenData.scene_width; let
height = screenData.scene_height;
(screenData.elements || []).forEach((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, 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) {
const worldEl = document.querySelector(`.playScreen[data-screen-id="${screenData.id}"] .sceneWorld`);
if (!worldEl) return;
const 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;
}
const player = (screenData.elements || []).find((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
const playerEl = player && document.querySelector(`[data-object-id="${player.id}"]`);
const px = playerEl
? (Number.parseFloat(playerEl.style.left) || 0) + (Number.parseFloat(playerEl.style.width) || 0) / 2
: world.width / 2;
const py = playerEl
? (Number.parseFloat(playerEl.style.top) || 0) + (Number.parseFloat(playerEl.style.height) || 0) / 2
: world.height / 2;
const offsetX = Math.min(
Math.max(px - screenData.scene_width / 2, 0),
Math.max(0, world.width - screenData.scene_width),
);
const 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, top, width, 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((o) => o.id !== movingId && o.kind !== 'fond' && o.kind !== 'dialogue_box' && o.kind !== 'quiz_box' && o.kind !== 'score_widget' && 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 (const other of obstacles) {
const otherEl = document.querySelector(`[data-object-id="${other.id}"]`);
if (!otherEl) continue;
const otherLeft = Number.parseFloat(otherEl.style.left) || 0;
const otherTop = Number.parseFloat(otherEl.style.top) || 0;
const otherWidth = Number.parseFloat(otherEl.style.width) || 0;
const otherHeight = Number.parseFloat(otherEl.style.height) || 0;
const otherRect = forgeObjectCollisionRectAt(otherLeft, otherTop, otherWidth, otherHeight, other.collision);
if (forgeShapesOverlap(movingRect, otherRect)) return true;
}
return false;
}
// Déplacement BRUT tenté (avant collision), selon l'axe autorisé par cmd.axe
// — extrait de forgeRunPersonnageControllerTick (lot 4 "modernisation JS", S3776).
function forgeComputeMovementDelta(cmd) {
let rawDx = 0; let
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;
}
return { rawDx, rawDy };
}
// 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. Extrait de
// forgeRunPersonnageControllerTick.
// `box` = {left, top, width, height} (position/taille ACTUELLES, avant
// déplacement) ; `rawDelta` = {rawDx, rawDy} (voir forgeComputeMovementDelta)
// — regroupés en objets pour rester sous la limite de 7 paramètres (S107),
// plutôt que 9 paramètres positionnels distincts.
function forgeResolveBlockedMovement(screenData, obj, targetEl, box, rawDelta) {
const {
left: curLeft, top: curTop, width: boxWidth, height: boxHeight,
} = box;
let { rawDx: dx, rawDy: dy } = rawDelta;
if (dx !== 0) {
const candidateLeft = clampSceneObjectPosition(targetEl, 'left', curLeft + dx);
if (forgeWouldCollide(screenData, obj, candidateLeft, curTop, boxWidth, boxHeight)) dx = 0;
}
if (dy !== 0) {
const candidateTop = clampSceneObjectPosition(targetEl, 'top', curTop + dy);
if (forgeWouldCollide(screenData, obj, curLeft, candidateTop, boxWidth, boxHeight)) dy = 0;
}
return { dx, dy };
}
// Anime idle (immobile) ou walk (déplacement effectif) — même fps pour les
// deux (bug corrigé par l'utilisateur : idle tournait à 4 i/s contre 8
// pour walk, aucune raison réelle de les désynchroniser). Extrait de
// forgeRunPersonnageControllerTick.
function forgeApplyPersonnageAnimForTick(targetEl, obj, dx, dy) {
if (dx === 0 && dy === 0) {
const idleAnim = forgePersonnageIdleAnim(obj.id);
if (idleAnim) {
runSpriteAnimation(
targetEl,
resolveSpriteFrames(obj.id, { animation: idleAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }),
);
}
return;
}
const moveAnim = forgePersonnageMovementAnim(obj.id);
if (moveAnim) {
runSpriteAnimation(
targetEl,
resolveSpriteFrames(obj.id, { animation: moveAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }),
);
}
}
// Déplace/anime UN personnage joueur pour ce tick — extrait du forEach de
// forgeRunPersonnageControllerTick.
function forgeRunPersonnagePlayerTick(screenData, 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;
const cmd = obj.personnage_commandes;
const targetEl = document.querySelector(`[data-object-id="${obj.id}"]`);
if (!targetEl) return;
const { rawDx, rawDy } = forgeComputeMovementDelta(cmd);
const curLeft = Number.parseFloat(targetEl.style.left) || 0;
const curTop = Number.parseFloat(targetEl.style.top) || 0;
const boxWidth = Number.parseFloat(targetEl.style.width) || 0;
const boxHeight = Number.parseFloat(targetEl.style.height) || 0;
const box = {
left: curLeft, top: curTop, width: boxWidth, height: boxHeight,
};
const { dx, dy } = forgeResolveBlockedMovement(screenData, obj, targetEl, box, { rawDx, rawDy });
// 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');
if (dx !== 0) applyObjectProperty(targetEl, 'pos_x_relatif', dx);
if (dy !== 0) applyObjectProperty(targetEl, 'pos_y_relatif', dy);
forgeApplyPersonnageAnimForTick(targetEl, obj, dx, dy);
}
// 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((obj) => forgeRunPersonnagePlayerTick(screenData, obj));
}
// 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';
const extraMatch = (cmd.extra || []).find((e) => 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((obj) => {
if (obj.kind !== 'personnage' || obj.personnage_role !== 'joueur' || !obj.personnage_commandes) return;
const animName = forgePersonnageActionAnim(obj.personnage_commandes, ev.key);
if (!animName || !forgePersonnageAnimExists(obj.id, animName)) return;
const 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?.kind !== 'jeu_2d') return;
const elements = screenData.elements || [];
const hasPersonnage = elements.some((o) => o.kind === 'personnage' && o.personnage_role === 'joueur');
const hasFond = elements.some((o) => 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(() => { 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;
}