diff --git a/assets/images/characters/characters.svg b/assets/images/characters/characters.svg
index 9a1c160..9a9bcb1 100644
--- a/assets/images/characters/characters.svg
+++ b/assets/images/characters/characters.svg
@@ -30,6 +30,7 @@
+
@@ -37,10 +38,6 @@
-
-
-
-
@@ -59,5 +56,10 @@
+
+
+
+
+
diff --git a/assets/js/characters.js b/assets/js/characters.js
new file mode 100644
index 0000000..4f6577a
--- /dev/null
+++ b/assets/js/characters.js
@@ -0,0 +1,217 @@
+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);
+}
diff --git a/assets/js/script.js b/assets/js/script.js
index 202909c..1061f2d 100644
--- a/assets/js/script.js
+++ b/assets/js/script.js
@@ -1,6 +1,7 @@
document.addEventListener("DOMContentLoaded", () => {
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;
@@ -15,8 +16,6 @@ document.addEventListener("DOMContentLoaded", () => {
function onScroll() {
const scrollY = window.scrollY;
-
- // Characters : descend de charMax à 0 au fil du scroll
moveCharacters(scrollY);
scaleCharacters(scrollY);
moveLogo(scrollY);
@@ -29,8 +28,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);
- const svg = charactersWrapper.querySelector("svg");
+ const charWidth =
+ CHAR_WIDTH_MIN + (charOffset / charMax) * (CHAR_WIDTH_MAX - CHAR_WIDTH_MIN);
svg.setAttribute("width", charWidth + "px");
svg.setAttribute("height", charWidth + "px");
}
diff --git a/site/snippets/head.php b/site/snippets/head.php
index d6d58ba..29b453a 100644
--- a/site/snippets/head.php
+++ b/site/snippets/head.php
@@ -7,5 +7,6 @@
-
+
+
\ No newline at end of file