- 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 <noreply@anthropic.com>
217 lines
7.2 KiB
JavaScript
217 lines
7.2 KiB
JavaScript
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);
|
|
}
|