characters : refacto clean code, découpage en modules ES

Noms de variables et fonctions explicites (sans abréviation), fonctions
unitaires, paths SVG construits via template literals avec constantes nommées.
Découpage en assets/js/characters/jump.js et pupilTracking.js, importés dans
script.js. Clamp de la rotation des pieds pour éviter le bounce à l'atterrissage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
isUnknown 2026-05-28 08:11:47 +02:00
parent e7b8bf7aa7
commit 967afa28c3
5 changed files with 277 additions and 243 deletions

View file

@ -1,217 +0,0 @@
document.addEventListener("DOMContentLoaded", () => {
const svg = document.querySelector(".characters svg");
initCharacterJump();
initPupilTracking(svg);
});
function initCharacterJump() {
// Gravité constante — contrairement à un ressort, la force est uniforme :
// la chute accélère jusqu'au sol au lieu de ralentir en apesanteur
const GRAVITY = 900; // accélération vers le bas (unités SVG/s²)
const LAUNCH_V = 225; // impulsion vers le haut (unités SVG/s)
const RESTITUTION = 0.15; // restitution du mini-rebond à l'atterrissage
const CYCLE = 1200; // ms entre deux lancers
const SQUAT_MS = 220; // durée du squat avant lancer
const EXTEND_MS = 100; // extension des jambes juste après le lancer
const FOOT_ROT = 35; // rotation max des pieds (degrés)
// Distance dont le top des jambes descend à compression max (173.32 → 197)
const BODY_SQUAT = 197 - 173.32;
const rightChar = document.getElementById("right-character");
const rightBody = document.getElementById("right-character-body");
const rightLeg = document.getElementById("right-leg1");
const leftLeg = document.getElementById("left-leg1");
const rightFoot = document.getElementById("right-foot1");
const leftFoot = document.getElementById("left-foot1");
const LEG_R_REST = "M209.01,216.53C221.33,194.34 209.01,173.32 209.01,173.32";
const LEG_L_REST = "M189.01,216.53C176.69,194.34 189.01,173.32 189.01,173.32";
const LEG_R_COMP = "M209.01,216.53C231.01,207 209.01,197 209.01,197";
const LEG_L_COMP = "M189.01,216.53C167.01,207 189.01,197 189.01,197";
function lerpPath(p1, p2, t) {
const re = /-?\d+\.?\d*/g;
const n1 = p1.match(re).map(Number);
const n2 = p2.match(re).map(Number);
let i = 0;
return p1.replace(re, () => {
const v = n1[i] + (n2[i] - n1[i]) * t;
i++;
return v.toFixed(2);
});
}
const easeInOut = t => t < 0.5 ? 2*t*t : 1 - (-2*t+2)**2/2;
const easeIn = t => t*t;
// Crée un ressort secondaire indépendant (fermeture sur pos/vel)
function makeSpring(k, c) {
let pos = 0, vel = 0;
return (target, dt) => {
const acc = -k * (pos - target) - c * vel;
vel += acc * dt;
pos += vel * dt;
return pos;
};
}
// Cinématique verticale — pos > 0 = en l'air
let yPos = 0, yVel = 0, landingCompress = 0;
// Ressorts secondaires : chaque partie suit sa cible avec son propre délai
// Corps/tête : k élevé → réactif, léger overshoot sur le relâché
// Jambes : mêmes paramètres que le corps pour rester visuellement connectés
// Pieds : k bas → floppy, ils traînent naturellement
const bodySpring = makeSpring(300, 18);
const legSpring = makeSpring(300, 18);
const rFootSpring = makeSpring(150, 10);
const lFootSpring = makeSpring(150, 10);
let hovered = false;
let inCycle = false;
let cycleStart = null;
let launched = false;
let lastTime = null;
const wrapper = document.querySelector(".characters");
wrapper.addEventListener("mouseenter", () => { hovered = true; });
wrapper.addEventListener("mouseleave", () => { hovered = false; });
function tick(time) {
if (!lastTime) lastTime = time;
const dt = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
if (hovered && !inCycle) {
cycleStart = time;
inCycle = true;
launched = false;
}
let elapsed = inCycle ? time - cycleStart : 0;
if (inCycle && elapsed >= CYCLE) {
if (hovered) {
cycleStart = time;
elapsed = 0;
launched = false;
} else {
inCycle = false;
elapsed = 0;
}
}
// Squat + lancer seulement pendant un cycle actif
let squatCompress = 0;
if (inCycle) {
if (elapsed < SQUAT_MS) {
squatCompress = easeInOut(elapsed / SQUAT_MS);
} else if (elapsed < SQUAT_MS + EXTEND_MS) {
squatCompress = 1 - easeIn((elapsed - SQUAT_MS) / EXTEND_MS);
}
if (!launched && elapsed >= SQUAT_MS) {
yVel += LAUNCH_V;
launched = true;
}
}
// Cinématique verticale : gravité constante, contrainte sol
if (yPos > 0 || yVel > 0) {
yVel -= GRAVITY * dt;
}
yPos += yVel * dt;
if (yPos < 0) {
if (yVel < 0) {
const impactV = -yVel;
if (impactV > 50) {
landingCompress = Math.min(0.6, impactV / LAUNCH_V * 0.8);
}
yVel = impactV > 15 ? impactV * RESTITUTION : 0;
}
yPos = 0;
}
landingCompress = Math.max(0, landingCompress - 4 * dt);
const ty = -yPos;
const compressTarget = Math.max(squatCompress, landingCompress);
// Rotation cible des pieds proportionnelle à la hauteur
const footRotTarget = yPos > 3
? Math.min(1, (yPos - 3) / 12)
: 0;
// Ressorts secondaires : calcul des valeurs avec inertie propre
const bodyDisp = bodySpring(compressTarget * BODY_SQUAT, dt);
const legMorph = Math.max(0, Math.min(1, legSpring(compressTarget, dt)));
const rFootAngle = rFootSpring(footRotTarget * FOOT_ROT, dt);
const lFootAngle = lFootSpring(-footRotTarget * FOOT_ROT, dt);
rightChar.setAttribute("transform", `translate(0,${ty.toFixed(2)})`);
rightBody.setAttribute("transform", `translate(0,${bodyDisp.toFixed(2)})`);
rightLeg.setAttribute("d", lerpPath(LEG_R_REST, LEG_R_COMP, legMorph));
leftLeg.setAttribute("d", lerpPath(LEG_L_REST, LEG_L_COMP, legMorph));
rightFoot.setAttribute("transform", `rotate(${rFootAngle.toFixed(1)},209.01,217.6)`);
leftFoot.setAttribute("transform", `rotate(${lFootAngle.toFixed(1)},189.01,217.6)`);
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
function initPupilTracking(svg) {
const VB_W = 304;
const VB_H = 259;
const S = 1.267471;
const BASE_TX = S * 52.5;
const BASE_TY = S * 87.5;
const MAX_R = 3.5;
const LERP = 0.12;
// Centre de chaque œil — personnage gauche uniquement
const eyes = [
{ id: "left-pupil", cx: 54.03, cy: 86.79 },
{ id: "right-pupil", cx: 103.55, cy: 86.79 },
];
const cur = eyes.map(e => ({ x: e.cx, y: e.cy }));
const tgt = eyes.map(e => ({ x: e.cx, y: e.cy }));
document.addEventListener("mousemove", (e) => {
const rect = svg.getBoundingClientRect();
const mx = (e.clientX - rect.left) * (VB_W / rect.width);
const my = (e.clientY - rect.top) * (VB_H / rect.height);
eyes.forEach((eye, i) => {
const dx = mx - eye.cx;
const dy = my - eye.cy;
const dist = Math.hypot(dx, dy);
const ratio = dist > 0 ? Math.min(MAX_R, dist) / dist : 0;
tgt[i].x = eye.cx + dx * ratio;
tgt[i].y = eye.cy + dy * ratio;
});
});
document.addEventListener("mouseleave", () => {
eyes.forEach((eye, i) => {
tgt[i].x = eye.cx;
tgt[i].y = eye.cy;
});
});
function tick() {
eyes.forEach((eye, i) => {
cur[i].x += (tgt[i].x - cur[i].x) * LERP;
cur[i].y += (tgt[i].y - cur[i].y) * LERP;
document.getElementById(eye.id)?.setAttribute(
"transform",
`matrix(${S},0,0,${S},${cur[i].x - BASE_TX},${cur[i].y - BASE_TY})`
);
});
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}

View file

@ -0,0 +1,192 @@
const RIGHT_LEG_AXIS_X = 209.01;
const LEFT_LEG_AXIS_X = 189.01;
const ANKLE_Y = 216.53;
const HIP_Y_STANDING = 173.32;
const HIP_Y_SQUATTING = 197;
// Point de contrôle de Bézier qui détermine la courbure latérale de la jambe
const rightLegBowControlStanding = { x: 221.33, y: 194.34 };
const rightLegBowControlSquatting = { x: 231.01, y: 207 };
const leftLegBowControlStanding = { x: 176.69, y: 194.34 };
const leftLegBowControlSquatting = { x: 167.01, y: 207 };
function buildLegSvgPath(legAxisX, bowControl, hipY) {
return `M${legAxisX},${ANKLE_Y}C${bowControl.x},${bowControl.y} ${legAxisX},${hipY} ${legAxisX},${hipY}`;
}
const RIGHT_LEG_REST_PATH = buildLegSvgPath(RIGHT_LEG_AXIS_X, rightLegBowControlStanding, HIP_Y_STANDING);
const LEFT_LEG_REST_PATH = buildLegSvgPath(LEFT_LEG_AXIS_X, leftLegBowControlStanding, HIP_Y_STANDING);
const RIGHT_LEG_COMPRESSED_PATH = buildLegSvgPath(RIGHT_LEG_AXIS_X, rightLegBowControlSquatting, HIP_Y_SQUATTING);
const LEFT_LEG_COMPRESSED_PATH = buildLegSvgPath(LEFT_LEG_AXIS_X, leftLegBowControlSquatting, HIP_Y_SQUATTING);
export function initCharacterJump() {
// Gravité constante — contrairement à un ressort, la force est uniforme :
// la chute accélère jusqu'au sol au lieu de ralentir en apesanteur
const GRAVITY = 900; // accélération vers le bas (unités SVG/s²)
const LAUNCH_VELOCITY = 225; // impulsion vers le haut (unités SVG/s)
const RESTITUTION = 0.15; // restitution du mini-rebond à l'atterrissage
const JUMP_CYCLE_DURATION_MS = 1200; // ms entre deux lancers
const SQUAT_DURATION_MS = 220; // durée du squat avant lancer
const LEG_EXTEND_DURATION_MS = 100; // extension des jambes juste après le lancer
const FOOT_MAX_ROTATION_DEG = 45; // rotation max des pieds (degrés)
const BODY_SQUAT_AMPLITUDE = HIP_Y_SQUATTING - HIP_Y_STANDING;
const rightCharacter = document.getElementById("right-character");
const rightCharacterBody = document.getElementById("right-character-body");
const rightLeg = document.getElementById("right-leg1");
const leftLeg = document.getElementById("left-leg1");
const rightFoot = document.getElementById("right-foot1");
const leftFoot = document.getElementById("left-foot1");
function interpolateSvgPath(startPath, endPath, interpolationFactor) {
const numberPattern = /-?\d+\.?\d*/g;
const startNumbers = startPath.match(numberPattern).map(Number);
const endNumbers = endPath.match(numberPattern).map(Number);
let numberIndex = 0;
return startPath.replace(numberPattern, () => {
const interpolatedValue =
startNumbers[numberIndex] +
(endNumbers[numberIndex] - startNumbers[numberIndex]) * interpolationFactor;
numberIndex++;
return interpolatedValue.toFixed(2);
});
}
const easeInOutQuadratic = (t) => (t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2);
const easeInQuadratic = (t) => t * t;
// Crée un ressort secondaire indépendant (fermeture sur position/vitesse)
function createSpringSimulator(stiffness, damping) {
let position = 0;
let velocity = 0;
return (target, deltaTime) => {
const acceleration = -stiffness * (position - target) - damping * velocity;
velocity += acceleration * deltaTime;
position += velocity * deltaTime;
return position;
};
}
// Cinématique verticale — position > 0 = en l'air
let verticalPosition = 0;
let verticalVelocity = 0;
let landingCompressionRatio = 0;
// Ressorts secondaires : chaque partie suit sa cible avec son propre délai
// Corps/tête : k élevé → réactif, léger overshoot sur le relâché
// Jambes : mêmes paramètres que le corps pour rester visuellement connectés
// Pieds : k bas → floppy, ils traînent naturellement
const bodySpring = createSpringSimulator(300, 18);
const legSpring = createSpringSimulator(300, 18);
const rightFootSpring = createSpringSimulator(150, 10);
const leftFootSpring = createSpringSimulator(150, 10);
let isHovered = false;
let isInJumpCycle = false;
let jumpCycleStartTime = null;
let hasLaunched = false;
let previousFrameTime = null;
const wrapper = document.querySelector(".characters");
wrapper.addEventListener("mouseenter", () => { isHovered = true; });
wrapper.addEventListener("mouseleave", () => { isHovered = false; });
function computeSquatCompressionRatio(elapsedCycleTime) {
if (elapsedCycleTime < SQUAT_DURATION_MS) {
return easeInOutQuadratic(elapsedCycleTime / SQUAT_DURATION_MS);
}
if (elapsedCycleTime < SQUAT_DURATION_MS + LEG_EXTEND_DURATION_MS) {
return 1 - easeInQuadratic((elapsedCycleTime - SQUAT_DURATION_MS) / LEG_EXTEND_DURATION_MS);
}
return 0;
}
function applyLandingBounce(impactVelocity) {
if (impactVelocity > 50) {
landingCompressionRatio = Math.min(0.6, (impactVelocity / LAUNCH_VELOCITY) * 0.8);
}
verticalVelocity = impactVelocity > 15 ? impactVelocity * RESTITUTION : 0;
}
function updateVerticalPhysics(deltaTime) {
if (verticalPosition > 0 || verticalVelocity > 0) {
verticalVelocity -= GRAVITY * deltaTime;
}
verticalPosition += verticalVelocity * deltaTime;
if (verticalPosition < 0) {
if (verticalVelocity < 0) {
applyLandingBounce(-verticalVelocity);
}
verticalPosition = 0;
}
landingCompressionRatio = Math.max(0, landingCompressionRatio - 4 * deltaTime);
}
function updateJumpCycle(currentTime) {
if (isHovered && !isInJumpCycle) {
jumpCycleStartTime = currentTime;
isInJumpCycle = true;
hasLaunched = false;
}
let elapsedCycleTime = isInJumpCycle ? currentTime - jumpCycleStartTime : 0;
if (isInJumpCycle && elapsedCycleTime >= JUMP_CYCLE_DURATION_MS) {
if (isHovered) {
jumpCycleStartTime = currentTime;
elapsedCycleTime = 0;
hasLaunched = false;
} else {
isInJumpCycle = false;
elapsedCycleTime = 0;
}
}
let squatCompressionRatio = 0;
if (isInJumpCycle) {
squatCompressionRatio = computeSquatCompressionRatio(elapsedCycleTime);
if (!hasLaunched && elapsedCycleTime >= SQUAT_DURATION_MS) {
verticalVelocity += LAUNCH_VELOCITY;
hasLaunched = true;
}
}
return squatCompressionRatio;
}
function applyTransforms(compressionTarget, deltaTime) {
const verticalTranslation = -verticalPosition;
const footRotationTarget = verticalPosition > 3 ? Math.min(1, (verticalPosition - 3) / 12) : 0;
const bodyVerticalDisplacement = bodySpring(compressionTarget * BODY_SQUAT_AMPLITUDE, deltaTime);
const legCompressionRatio = Math.max(0, Math.min(1, legSpring(compressionTarget, deltaTime)));
const rightFootRotationAngle = Math.max(0, rightFootSpring(footRotationTarget * FOOT_MAX_ROTATION_DEG, deltaTime));
const leftFootRotationAngle = Math.min(0, leftFootSpring(-footRotationTarget * FOOT_MAX_ROTATION_DEG, deltaTime));
rightCharacter.setAttribute("transform", `translate(0,${verticalTranslation.toFixed(2)})`);
rightCharacterBody.setAttribute("transform", `translate(0,${bodyVerticalDisplacement.toFixed(2)})`);
rightLeg.setAttribute("d", interpolateSvgPath(RIGHT_LEG_REST_PATH, RIGHT_LEG_COMPRESSED_PATH, legCompressionRatio));
leftLeg.setAttribute("d", interpolateSvgPath(LEFT_LEG_REST_PATH, LEFT_LEG_COMPRESSED_PATH, legCompressionRatio));
rightFoot.setAttribute("transform", `rotate(${rightFootRotationAngle.toFixed(1)},${RIGHT_LEG_AXIS_X},217.6)`);
leftFoot.setAttribute("transform", `rotate(${leftFootRotationAngle.toFixed(1)},${LEFT_LEG_AXIS_X},217.6)`);
}
function animateJump(currentTime) {
if (!previousFrameTime) previousFrameTime = currentTime;
const deltaTime = Math.min((currentTime - previousFrameTime) / 1000, 0.05);
previousFrameTime = currentTime;
const squatCompressionRatio = updateJumpCycle(currentTime);
updateVerticalPhysics(deltaTime);
const compressionTarget = Math.max(squatCompressionRatio, landingCompressionRatio);
applyTransforms(compressionTarget, deltaTime);
requestAnimationFrame(animateJump);
}
requestAnimationFrame(animateJump);
}

View file

@ -0,0 +1,58 @@
export function initPupilTracking(svg) {
const VIEWBOX_WIDTH = 304;
const VIEWBOX_HEIGHT = 259;
const PUPIL_SCALE = 1.267471;
const PUPIL_BASE_TRANSLATE_X = PUPIL_SCALE * 52.5;
const PUPIL_BASE_TRANSLATE_Y = PUPIL_SCALE * 87.5;
const PUPIL_MAX_OFFSET_RADIUS = 3.5;
const PUPIL_LERP_FACTOR = 0.12;
// Centre de chaque œil — personnage gauche uniquement
const eyes = [
{ id: "left-pupil", centerX: 54.03, centerY: 86.79 },
{ id: "right-pupil", centerX: 103.55, centerY: 86.79 },
];
const currentPupilPositions = eyes.map((eye) => ({ x: eye.centerX, y: eye.centerY }));
const targetPupilPositions = eyes.map((eye) => ({ x: eye.centerX, y: eye.centerY }));
document.addEventListener("mousemove", (event) => {
const svgBoundingRect = svg.getBoundingClientRect();
const mouseViewportX = (event.clientX - svgBoundingRect.left) * (VIEWBOX_WIDTH / svgBoundingRect.width);
const mouseViewportY = (event.clientY - svgBoundingRect.top) * (VIEWBOX_HEIGHT / svgBoundingRect.height);
eyes.forEach((eye, eyeIndex) => {
const deltaX = mouseViewportX - eye.centerX;
const deltaY = mouseViewportY - eye.centerY;
const distanceToMouse = Math.hypot(deltaX, deltaY);
const clampRatio = distanceToMouse > 0 ? Math.min(PUPIL_MAX_OFFSET_RADIUS, distanceToMouse) / distanceToMouse : 0;
targetPupilPositions[eyeIndex].x = eye.centerX + deltaX * clampRatio;
targetPupilPositions[eyeIndex].y = eye.centerY + deltaY * clampRatio;
});
});
document.addEventListener("mouseleave", () => {
eyes.forEach((eye, eyeIndex) => {
targetPupilPositions[eyeIndex].x = eye.centerX;
targetPupilPositions[eyeIndex].y = eye.centerY;
});
});
function animatePupils() {
eyes.forEach((eye, eyeIndex) => {
currentPupilPositions[eyeIndex].x +=
(targetPupilPositions[eyeIndex].x - currentPupilPositions[eyeIndex].x) * PUPIL_LERP_FACTOR;
currentPupilPositions[eyeIndex].y +=
(targetPupilPositions[eyeIndex].y - currentPupilPositions[eyeIndex].y) * PUPIL_LERP_FACTOR;
document
.getElementById(eye.id)
?.setAttribute(
"transform",
`matrix(${PUPIL_SCALE},0,0,${PUPIL_SCALE},${currentPupilPositions[eyeIndex].x - PUPIL_BASE_TRANSLATE_X},${currentPupilPositions[eyeIndex].y - PUPIL_BASE_TRANSLATE_Y})`
);
});
requestAnimationFrame(animatePupils);
}
requestAnimationFrame(animatePupils);
}

View file

@ -1,21 +1,22 @@
document.addEventListener("DOMContentLoaded", () => {
const header = document.querySelector(".main-header");
const charactersWrapper = document.querySelector(".characters");
const logo = document.querySelector(".logo");
const svg = charactersWrapper.querySelector("svg");
import { initCharacterJump } from "./characters/jump.js";
import { initPupilTracking } from "./characters/pupilTracking.js";
const CHAR_SPEED = 0.6;
const LOGO_SPEED = 1.2;
document.addEventListener("DOMContentLoaded", () => {
const header = document.querySelector(".main-header");
const charactersWrapper = document.querySelector(".characters");
const logo = document.querySelector(".logo");
const svg = charactersWrapper.querySelector("svg");
const CHAR_SPEED = 0.6;
const LOGO_SPEED = 1.2;
const CHAR_WIDTH_MAX = 304;
const CHAR_WIDTH_MIN = 150;
// Valeurs initiales lues depuis l'attribut style HTML
const charMax = parseFloat(
charactersWrapper.style.getPropertyValue("--offset")
);
const charMax = parseFloat(charactersWrapper.style.getPropertyValue("--offset"));
const logoMax = logo.offsetHeight + 100;
let menuOpen = false;
let menuOpen = false;
let menuTransitioning = false;
function onScroll() {
@ -33,10 +34,8 @@ document.addEventListener("DOMContentLoaded", () => {
function scaleCharacters(scrollY) {
const charOffset = Math.max(0, charMax - (scrollY / 16) * CHAR_SPEED);
const charWidth =
CHAR_WIDTH_MIN +
(charOffset / charMax) * (CHAR_WIDTH_MAX - CHAR_WIDTH_MIN);
svg.setAttribute("width", charWidth + "px");
const charWidth = CHAR_WIDTH_MIN + (charOffset / charMax) * (CHAR_WIDTH_MAX - CHAR_WIDTH_MIN);
svg.setAttribute("width", charWidth + "px");
svg.setAttribute("height", charWidth + "px");
}
@ -50,23 +49,23 @@ document.addEventListener("DOMContentLoaded", () => {
{
const nav = {
toggleBtn: document.querySelector(".toggle-nav"),
strip: document.querySelector(".strip"),
strip: document.querySelector(".strip"),
};
function openMenu() {
menuOpen = true;
header.classList.add("menu-transitioning", "big");
nav.strip.classList.add("open");
nav.toggleBtn.textContent = "fermer";
nav.toggleBtn.textContent = "fermer";
document.body.style.overflow = "hidden";
}
function closeMenu() {
menuOpen = false;
menuOpen = false;
menuTransitioning = true;
header.classList.remove("big");
nav.strip.classList.remove("open");
nav.toggleBtn.textContent = "menu";
nav.toggleBtn.textContent = "menu";
document.body.style.overflow = "";
setTimeout(() => {
header.classList.remove("menu-transitioning");
@ -78,14 +77,17 @@ document.addEventListener("DOMContentLoaded", () => {
nav.strip.classList.contains("open") ? closeMenu() : openMenu();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && menuOpen) closeMenu();
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && menuOpen) closeMenu();
});
document.addEventListener("click", (e) => {
document.addEventListener("click", (event) => {
if (!menuOpen) return;
if (e.target.closest(".toggle-nav")) return;
if (!e.target.closest(".strip")) closeMenu();
if (event.target.closest(".toggle-nav")) return;
if (!event.target.closest(".strip")) closeMenu();
});
}
initCharacterJump();
initPupilTracking(svg);
});

View file

@ -7,6 +7,5 @@
<meta name="robots" content="none">
<link rel="stylesheet" href="<?= url('assets/css/style.css') ?>">
<script src="<?= url('assets/js/script.js') ?>" defer></script>
<script src="<?= url('assets/js/characters.js') ?>" defer></script>
<script src="<?= url('assets/js/script.js') ?>" type="module"></script>
</head>