From da3fd9db2fe10b19167eee10cf27c0e3fa4ce2f4 Mon Sep 17 00:00:00 2001 From: isUnknown Date: Wed, 27 May 2026 18:18:39 +0200 Subject: [PATCH] =?UTF-8?q?characters=20:=20physique=20gravit=C3=A9,=20d?= =?UTF-8?q?=C3=A9clenchement=20au=20survol,=20refactor=20JS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remplace le ressort principal par une cinématique gravité constante (900 SVG/s²) pour un décollage vif et une chute naturelle - L'animation de saut se déclenche en boucle au mouseenter et s'arrête au mouseleave (atterrissage propre en cours de vol) - Extrait la logique d'animation dans characters.js, script.js garde uniquement le scroll - SVG : groupe #right-character-body pour dissocier corps/tête des jambes lors de la compression - CSS : overflow visible sur le SVG pour le débordement pendant le saut Co-Authored-By: Claude Sonnet 4.6 --- assets/images/characters/characters.svg | 10 +- assets/js/characters.js | 217 ++++++++++++++++++++++++ assets/js/script.js | 7 +- site/snippets/head.php | 3 +- 4 files changed, 228 insertions(+), 9 deletions(-) create mode 100644 assets/js/characters.js 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