From 341dca7cb5d8769a285b39f14a2e9140f8b6ed53 Mon Sep 17 00:00:00 2001 From: MVA Global Fret Date: Tue, 5 May 2026 10:41:24 +0200 Subject: [PATCH] Convert intro into a scroll-driven 3D cinematic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the page so the first 4 viewports of scroll drive a Three.js scene composited on top of the Antananarivo parachute video. What's there: - Three.js (ESM, r158 via importmap) renders a low-poly cargo airliner built from primitives: cylinder fuselage, cone nose, sphere cockpit (dark glass + emissive), box wings/tail/fin, cylinder engines with torus intakes, gold trim band, navy fin with gold logo box. No external model file. - Hemisphere + directional + ambient lights tuned for golden-hour fill. - 14 cloud spheres scattered around the plane, slowly rotating. - GSAP + ScrollTrigger drive a single progress value scrubbed against scroll position. Inside the rAF loop, the camera arcs from rear-left (-0.6 rad) to front-right (+1.1 rad), radius dipping mid-flight, and the plane rolls slightly with scroll. - Three act labels (Paris CDG / Vol cargo / Antananarivo) cross-fade at 20%/40%-60%/72% scroll positions via a chained gsap timeline. - Gold CTA button stays opacity:0 + pointer-events:none until the last ~10% of scroll, then fades and scales in. Hover transform rebuilt without the old mouse-parallax tilt (fights the scroll animation). - Scroll hint pill (chevron + "Faites défiler") at the bottom of the first viewport, fades out on first scroll event. - prefers-reduced-motion shortcut: scroll stage hidden, CTA visible, no animation. Page reverts to a static screen with the video bg. Co-Authored-By: Claude Opus 4.6 --- css/parallax.css | 133 +++++++++++++++++++++------ index.html | 73 ++++++++------- js/intro-scene.js | 222 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 370 insertions(+), 58 deletions(-) create mode 100644 js/intro-scene.js diff --git a/css/parallax.css b/css/parallax.css index 6dc8109..20970b1 100644 --- a/css/parallax.css +++ b/css/parallax.css @@ -1,7 +1,7 @@ /* ========================================================================= PARALLAX INTRO — MVA Global Fret - Page unique, fixe (pas de scroll). Vidéo Terre rotative + ligne rouge 3D - Paris ↔ Antananarivo (suit la rotation du globe) + bouton centré. + Page scrollable (4 viewports) avec scène Three.js fixée. La timeline + pilote la caméra et l'avion 3D. À 100% du scroll, le bouton CTA apparaît. ========================================================================= */ :root { @@ -19,13 +19,15 @@ html, body { height: 100%; - overflow: hidden; + background: var(--navy-deep); } -.parallax-body { +html { scroll-behavior: smooth; } + +body.parallax-body { font-family: 'Inter', sans-serif; color: var(--white); - background: var(--navy-deep); + overflow-x: hidden; } /* ── HEADER ─────────────────────────────────────────────────────────────── */ @@ -36,7 +38,7 @@ html, body { align-items: center; justify-content: space-between; padding: 22px 36px; - z-index: 50; + z-index: 100; } .parallax-logo { @@ -54,13 +56,13 @@ html, body { .parallax-logo img { height: 44px; width: auto; - filter: drop-shadow(0 4px 14px rgba(0,0,0,0.65)); + filter: drop-shadow(0 4px 14px rgba(0, 0, 0, 0.65)); } .lang-switcher { display: inline-flex; - background: rgba(255,255,255,0.08); - border: 1px solid rgba(255,255,255,0.18); + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 50px; padding: 4px; backdrop-filter: blur(10px); @@ -69,7 +71,7 @@ html, body { .lang-switcher button { background: transparent; border: none; - color: rgba(255,255,255,0.7); + color: rgba(255, 255, 255, 0.7); padding: 6px 14px; cursor: pointer; border-radius: 50px; @@ -84,12 +86,16 @@ html, body { } .lang-switcher button:hover:not(.active) { color: var(--white); } -/* ── STAGE ──────────────────────────────────────────────────────────────── */ -.stage { - position: relative; +/* ── SCÈNE FIXE (vidéo + voile + canvas Three.js) ──────────────────────── + .stage-fixed reste plein-écran pendant que la page se déroule derrière. +*/ +.stage-fixed { + position: fixed; + inset: 0; width: 100vw; height: 100vh; overflow: hidden; + z-index: 1; } .layer { @@ -101,8 +107,6 @@ html, body { will-change: transform; } -/* Parallaxe vidéo : suit la souris avec easing. Sur-dimensionnée pour - masquer les bords quand elle bouge. */ .layer-video { object-fit: cover; object-position: center; @@ -115,17 +119,86 @@ html, body { linear-gradient(180deg, rgba(5, 5, 24, 0.25) 0%, rgba(5, 5, 24, 0.5) 100%); } +.layer-three { + display: block; + z-index: 2; +} + +/* ── SCROLL STAGE — sentinelles invisibles qui donnent la hauteur ──────── */ +.scroll-stage { + position: relative; + z-index: 5; + pointer-events: none; +} +.scroll-stage .act { + height: 100vh; +} + +/* ── ÉTIQUETTES D'ACTE ──────────────────────────────────────────────────── */ +.act-label { + position: absolute; + left: 50%; + bottom: 18%; + transform: translateX(-50%); + z-index: 30; + pointer-events: none; + text-align: center; + font-family: 'Poppins', sans-serif; + font-weight: 700; + letter-spacing: 4px; + text-transform: uppercase; + font-size: 1.05rem; + color: var(--white); + text-shadow: 0 4px 18px rgba(0, 0, 0, 0.7); + opacity: 0; +} +.act-label span { + display: inline-block; + padding: 14px 28px; + background: rgba(5, 5, 24, 0.45); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 8px; + line-height: 1.35; +} + +/* ── INDICATEUR SCROLL ─────────────────────────────────────────────────── */ +.scroll-hint { + position: absolute; + left: 50%; + bottom: 36px; + transform: translateX(-50%); + z-index: 40; + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 8px; + font-family: 'Poppins', sans-serif; + font-weight: 600; + font-size: 0.78rem; + letter-spacing: 2px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.85); + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.6); + animation: scrollBob 1.8s ease-in-out infinite; + transition: opacity 0.5s ease; +} +.scroll-hint.hidden { opacity: 0; pointer-events: none; } +.scroll-hint i { font-size: 1rem; } + +@keyframes scrollBob { + 0%, 100% { transform: translate(-50%, 0); } + 50% { transform: translate(-50%, 8px); } +} + /* ── BOUTON CTA centré ──────────────────────────────────────────────────── */ .cta-btn { position: absolute; top: 50%; left: 50%; - /* Bouge légèrement en sens INVERSE des couches → effet de tilt 3D */ - transform: translate( - calc(-50% + var(--mx) * 8px), - calc(-50% + var(--my) * 8px) - ); - z-index: 10; + transform: translate(-50%, -50%); + z-index: 50; display: inline-flex; align-items: center; gap: 16px; @@ -146,13 +219,14 @@ html, body { inset 0 1px 0 rgba(255, 255, 255, 0.45); transition: box-shadow 0.32s cubic-bezier(0.2, 0.8, 0.2, 1), transform 0.32s cubic-bezier(0.2, 0.8, 0.2, 1); + /* Caché au départ ; révélé via GSAP en fin de scroll */ + opacity: 0; + pointer-events: none; } +.cta-btn.revealed { pointer-events: auto; } .cta-btn:hover { - transform: translate( - calc(-50% + var(--mx) * 8px), - calc(-50% + var(--my) * 8px) - ) scale(1.04); + transform: translate(-50%, -50%) scale(1.04); box-shadow: 0 28px 75px rgba(197, 165, 90, 0.7), 0 0 0 12px rgba(197, 165, 90, 0.12), @@ -163,7 +237,7 @@ html, body { position: absolute; top: 0; left: -120%; width: 60%; height: 100%; - background: linear-gradient(90deg, transparent, rgba(255,255,255,0.65), transparent); + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.65), transparent); transform: skewX(-22deg); transition: left 0.7s ease; pointer-events: none; @@ -201,8 +275,13 @@ html, body { padding: 16px 36px; font-size: 0.98rem; } + .act-label { font-size: 0.85rem; bottom: 22%; } } @media (prefers-reduced-motion: reduce) { - .cta-btn::after { animation: none; } + .cta-btn::after { animation: none; } + .scroll-hint { animation: none; display: none; } + .scroll-stage { display: none; } + .cta-btn { opacity: 1; pointer-events: auto; } + html { scroll-behavior: auto; } } diff --git a/index.html b/index.html index 3e14ffd..b23bd93 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,18 @@ + + + + + + @@ -26,8 +38,8 @@ -
- + + + +
+
+
+
+
@@ -70,34 +103,12 @@ } })(); - /* Parallaxe souris : la vidéo et le bouton se décalent légèrement. - --mx, --my sont des floats dans [-1, +1] mappés sur la position - souris, avec easing. Sur mobile, on utilise l'orientation du device. */ - (function () { - const root = document.documentElement; - let targetX = 0, targetY = 0, currentX = 0, currentY = 0; - const ease = 0.08; - - function loop() { - currentX += (targetX - currentX) * ease; - currentY += (targetY - currentY) * ease; - root.style.setProperty('--mx', currentX.toFixed(4)); - root.style.setProperty('--my', currentY.toFixed(4)); - requestAnimationFrame(loop); - } - loop(); - - window.addEventListener('mousemove', (e) => { - targetX = (e.clientX / window.innerWidth - 0.5) * 2; - targetY = (e.clientY / window.innerHeight - 0.5) * 2; - }, { passive: true }); - - window.addEventListener('deviceorientation', (e) => { - if (e.gamma == null || e.beta == null) return; - targetX = Math.max(-1, Math.min(1, e.gamma / 30)); - targetY = Math.max(-1, Math.min(1, (e.beta - 45) / 30)); - }, { passive: true }); - })(); + /* Scroll-hint : disparaît dès qu'on commence à scroller */ + window.addEventListener('scroll', () => { + document.getElementById('scrollHint')?.classList.add('hidden'); + }, { once: true, passive: true }); + + diff --git a/js/intro-scene.js b/js/intro-scene.js new file mode 100644 index 0000000..cc7db5a --- /dev/null +++ b/js/intro-scene.js @@ -0,0 +1,222 @@ +/* ========================================================================= + INTRO SCENE — MVA Global Fret + Scène Three.js minimaliste : un avion-cargo low-poly construit à partir + de primitives (cylindre + boîtes + cônes), peint aux couleurs de la marque. + La timeline est pilotée par GSAP + ScrollTrigger : la position du scroll + anime caméra, avion et étiquettes. À 100% du scroll, le bouton CTA est + révélé. + ========================================================================= */ + +import * as THREE from 'three'; + +const { gsap, ScrollTrigger } = window; +gsap.registerPlugin(ScrollTrigger); + +/* ── Setup ──────────────────────────────────────────────────────────────── */ +const canvas = document.getElementById('three-canvas'); +const renderer = new THREE.WebGLRenderer({ + canvas, alpha: true, antialias: true, powerPreference: 'high-performance' +}); +renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); +renderer.outputColorSpace = THREE.SRGBColorSpace; + +const scene = new THREE.Scene(); + +const camera = new THREE.PerspectiveCamera(38, window.innerWidth / window.innerHeight, 0.1, 1000); +camera.position.set(0, 1.4, 18); +camera.lookAt(0, 0, 0); + +/* ── Lighting ───────────────────────────────────────────────────────────── */ +const sun = new THREE.DirectionalLight(0xfff1d6, 1.4); // chaude, golden hour +sun.position.set(-6, 8, 4); +scene.add(sun); + +const skyFill = new THREE.HemisphereLight(0xa8c5ff, 0x3a2a1a, 0.55); +scene.add(skyFill); + +const ambient = new THREE.AmbientLight(0xffffff, 0.18); +scene.add(ambient); + +/* ── Avion (low-poly à partir de primitives) ───────────────────────────── + Couleurs MVA : corps blanc cassé, accents navy + or sur les ailerons. */ +const NAVY = new THREE.Color(0x1a1a3e); +const GOLD = new THREE.Color(0xc5a55a); +const BODY = new THREE.Color(0xf2f2f7); + +const matBody = new THREE.MeshStandardMaterial({ color: BODY, roughness: 0.45, metalness: 0.08 }); +const matNavy = new THREE.MeshStandardMaterial({ color: NAVY, roughness: 0.55, metalness: 0.10 }); +const matGold = new THREE.MeshStandardMaterial({ color: GOLD, roughness: 0.35, metalness: 0.55 }); +const matWindow = new THREE.MeshStandardMaterial({ color: 0x0e1a30, roughness: 0.2, metalness: 0.4, emissive: 0x0a1428, emissiveIntensity: 0.4 }); + +const plane = new THREE.Group(); + +// Fuselage — cylindre couché le long de l'axe Z +const fuselageGeo = new THREE.CylinderGeometry(0.55, 0.5, 7.2, 24); +const fuselage = new THREE.Mesh(fuselageGeo, matBody); +fuselage.rotation.x = Math.PI / 2; +plane.add(fuselage); + +// Nez (cône avant) +const nose = new THREE.Mesh(new THREE.ConeGeometry(0.5, 1.4, 24), matBody); +nose.rotation.x = -Math.PI / 2; +nose.position.z = 4.3; +plane.add(nose); + +// Cockpit (sphère bleu nuit pour les vitres) +const cockpit = new THREE.Mesh(new THREE.SphereGeometry(0.42, 18, 16, 0, Math.PI * 2, 0, Math.PI / 2), matWindow); +cockpit.rotation.x = -Math.PI / 2; +cockpit.position.set(0, 0.18, 3.4); +plane.add(cockpit); + +// Bande dorée le long du fuselage (un cylindre fin) +const goldBand = new THREE.Mesh(new THREE.CylinderGeometry(0.555, 0.555, 7.2, 24, 1, true), matGold); +goldBand.rotation.x = Math.PI / 2; +goldBand.scale.set(1.001, 0.08, 1.001); // bande fine +plane.add(goldBand); + +// Ailes principales (boîte aplatie, légèrement en flèche via rotation Y) +const wingGeo = new THREE.BoxGeometry(11, 0.18, 1.6); +const wings = new THREE.Mesh(wingGeo, matBody); +wings.position.set(0, -0.05, -0.4); +plane.add(wings); + +// Bord doré sur les ailes +const wingTrim = new THREE.Mesh(new THREE.BoxGeometry(11.05, 0.06, 0.18), matGold); +wingTrim.position.set(0, -0.04, 0.32); +plane.add(wingTrim); + +// Empennage horizontal (petites ailes arrière) +const tail = new THREE.Mesh(new THREE.BoxGeometry(3.4, 0.14, 0.85), matBody); +tail.position.set(0, 0.25, -3.0); +plane.add(tail); + +// Empennage vertical (queue) +const fin = new THREE.Mesh(new THREE.BoxGeometry(0.16, 1.45, 1.6), matNavy); +fin.position.set(0, 0.85, -2.9); +fin.geometry.translate(0, 0, -0.4); // base alignée arrière +plane.add(fin); + +// Logo navy sur la queue (un petit cube doré) +const logoBadge = new THREE.Mesh(new THREE.BoxGeometry(0.165, 0.45, 0.35), matGold); +logoBadge.position.set(0, 1.1, -3.1); +plane.add(logoBadge); + +// Réacteurs sous les ailes (cylindres) +const engineGeo = new THREE.CylinderGeometry(0.35, 0.32, 1.5, 18); +const engineL = new THREE.Mesh(engineGeo, matNavy); +engineL.rotation.x = Math.PI / 2; +engineL.position.set(-2.6, -0.45, 0.2); +plane.add(engineL); + +const engineR = engineL.clone(); +engineR.position.x = 2.6; +plane.add(engineR); + +// Petites bouches d'admission dorées sur les réacteurs +const intakeGeo = new THREE.TorusGeometry(0.35, 0.05, 10, 24); +const intakeL = new THREE.Mesh(intakeGeo, matGold); +intakeL.position.set(-2.6, -0.45, 0.95); +plane.add(intakeL); +const intakeR = intakeL.clone(); +intakeR.position.x = 2.6; +plane.add(intakeR); + +scene.add(plane); + +/* ── Nuages volants (sprites simples) ───────────────────────────────────── */ +const clouds = new THREE.Group(); +const cloudMat = new THREE.MeshStandardMaterial({ + color: 0xffffff, roughness: 1.0, metalness: 0, transparent: true, opacity: 0.85 +}); +for (let i = 0; i < 14; i++) { + const cl = new THREE.Mesh(new THREE.SphereGeometry(1, 12, 10), cloudMat); + const r = 18 + Math.random() * 14; + const a = Math.random() * Math.PI * 2; + cl.position.set(Math.cos(a) * r, (Math.random() - 0.4) * 9, Math.sin(a) * r - 8); + cl.scale.set(1.6 + Math.random() * 1.4, 0.9 + Math.random() * 0.6, 1.6 + Math.random() * 1.4); + clouds.add(cl); +} +scene.add(clouds); + +/* ── État animé piloté par le scroll ───────────────────────────────────── */ +const state = { progress: 0 }; + +ScrollTrigger.create({ + trigger: '.scroll-stage', + start: 'top top', + end: 'bottom bottom', + scrub: 0.6, + onUpdate: self => { state.progress = self.progress; } +}); + +/* Étiquettes : fade-in/out aux bonnes plages de scroll */ +const tl = gsap.timeline({ + scrollTrigger: { trigger: '.scroll-stage', start: 'top top', end: 'bottom bottom', scrub: 0.4 } +}); +tl.fromTo('#label-paris', { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.05 }, 0.02) + .to( '#label-paris', { opacity: 0, y: -20 }, 0.20) + .fromTo('#label-cruise', { opacity: 0, y: 20 }, { opacity: 1, y: 0 }, 0.40) + .to( '#label-cruise', { opacity: 0, y: -20 }, 0.60) + .fromTo('#label-tana', { opacity: 0, y: 20 }, { opacity: 1, y: 0 }, 0.72) + .to( '#label-tana', { opacity: 0, y: -20 }, 0.92); + +/* CTA : caché jusqu'à ~92% du scroll, puis fade in */ +gsap.fromTo('#ctaBtn', + { opacity: 0, scale: 0.85 }, + { + opacity: 1, scale: 1, duration: 0.5, + ease: 'cubic-bezier(0.2, 0.8, 0.2, 1)', + scrollTrigger: { + trigger: '.scroll-stage', + start: 'bottom-=400 bottom', + end: 'bottom bottom', + scrub: 0.4, + onLeave: () => document.getElementById('ctaBtn').classList.add('revealed'), + onEnterBack: () => document.getElementById('ctaBtn').classList.remove('revealed'), + } + } +); + +/* ── Render loop ────────────────────────────────────────────────────────── */ +const clock = new THREE.Clock(); +function tick() { + const t = clock.getElapsedTime(); + const p = state.progress; + + /* Caméra : large arrière → côté → arrière en s'éloignant à la fin */ + // Arc autour de l'avion + const camAngle = -0.6 + p * 1.7; // -0.6 rad → +1.1 rad + const camRadius = 18 - p * 6 + Math.sin(p * Math.PI) * -3; // 18 → 12, dip au milieu + const camHeight = 1.4 + Math.sin(p * Math.PI) * 1.8 - p * 0.6; + + camera.position.set( + Math.sin(camAngle) * camRadius, + camHeight, + Math.cos(camAngle) * camRadius + ); + camera.lookAt(0, 0, 0); + + /* Avion : léger bob + roulis selon scroll */ + plane.position.y = Math.sin(t * 0.9) * 0.15; + plane.rotation.z = Math.sin(t * 0.5) * 0.04 + (p - 0.5) * 0.18; + plane.rotation.y = Math.sin(t * 0.4) * 0.025; + + /* Nuages : rotation lente autour de l'avion */ + clouds.rotation.y = t * 0.04 - p * 0.6; + clouds.position.y = Math.sin(t * 0.3) * 0.4; + + renderer.render(scene, camera); + requestAnimationFrame(tick); +} + +/* ── Resize ─────────────────────────────────────────────────────────────── */ +function resize() { + const w = window.innerWidth; + const h = window.innerHeight; + renderer.setSize(w, h, false); + camera.aspect = w / h; + camera.updateProjectionMatrix(); +} +window.addEventListener('resize', resize); +resize(); +tick();