Build and deploy / test-python (push) Successful in 11m12s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 3m56s
Build and deploy / lint-js (push) Successful in 3m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m58s
Prepare la scission a venir entre l'editeur Jeu 2D et le futur editeur Support de formation (voir docs/plan/PLAN.md), sans toucher a l'architecture en couches existante : - screens/ renomme en game_engine/ (nom clair pour le moteur du jeu 2D, avant l'arrivee d'un second "moteur" cote document) : ~85 imports corriges, contrat import-linter mis a jour, meme forme de couches. - routes/, scripts/, static/, templates/, tests/ : tout ce qui est propre au jeu 2D deplace dans un sous-dossier game/ de chacun (routes/game/, static/game/, templates/game/, tests/game/, scripts/game/) ; ce qui est partage par le site (auth, onboarding, dashboard, uploads, db/) reste a la racine de chaque dossier. Un sous-dossier document/ (vide) cree dans chacun pour le futur chantier. - styles/ volontairement inchange : les 3 fichiers sources sont concatenes en un seul static/style.css charge par tout le site, scinder leur CONTENU (editeur vs partage) serait un refactor CSS distinct, pas un deplacement mecanique. - Chaine d'export SCORM (publish/build_scorm_package.py) mise a jour en profondeur : copie des assets, URLs d'icones relatives a static/style.css (qui ne bouge pas), manifeste, wrapper SCORM. - Deux regressions d'un sweep de renommage anterieur corrigees au passage (screens.js/screens/scene-objects incorrectement convertis en game_engine.js/game_engine/scene-objects dans des commentaires). - Effet de bord Windows decouvert et corrige : git mv + Path.write_text convertissent des fichiers en CRLF (core.autocrlf=true) - ~189 fichiers normalises en LF. - .eslintrc.json/package.json : uniquement les chemins de glob mis a jour (static/game/js/...) ; la preparation eslint-plugin-unicorn du lot 7 reste volontairement non committee (package-lock.json restaure a la version precedente). Verifications : ruff, mypy --strict (391 fichiers), vulture, bandit, lint-imports tous verts ; 591/591 tests Python, 276/276 tests JS ; demarrage serveur + requetes HTTP manuelles confirmant que les assets deplaces repondent en 200 au nouvel emplacement et 404 a l'ancien. SKIP=djlint : backlog H021 (styles inline) deja documente comme dette assumee dans CODE_QUALITY.md section 6, aucun template touche par ce commit au-dela d'un deplacement de fichier. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
333 lines
16 KiB
JavaScript
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/game/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 game_engine/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/game/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/game/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 game_engine/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/game/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/game/play.html).
|
|
const conditionsMod = require('./conditions.js');
|
|
global.forgeCollisionRectFromBox = global.forgeCollisionRectFromBox || conditionsMod.forgeCollisionRectFromBox;
|
|
global.forgeShapesOverlap = global.forgeShapesOverlap || conditionsMod.forgeShapesOverlap;
|
|
}
|