365 lines
14 KiB
JavaScript
365 lines
14 KiB
JavaScript
/* =========================================================================
|
||
INTRO SCENE — MVA Global Fret
|
||
Photo aérienne d'Antananarivo en fond, avion 3D piloté par la souris.
|
||
L'avion lâche périodiquement des colis parachutés qui tombent et
|
||
grandissent (perspective).
|
||
|
||
3D model credits (CC-BY 3.0, Poly by Google) :
|
||
- « Airplane » → https://poly.pizza/m/a3XrQkLNna9
|
||
- « Parachute » → https://poly.pizza/m/3Z7vJ96JIEB
|
||
========================================================================= */
|
||
|
||
import * as THREE from 'three';
|
||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||
|
||
/* ── Détection mobile ──────────────────────────────────────────────────
|
||
Sur mobile : on coupe l'antialiasing, on plafonne le pixel ratio à
|
||
1.5 et on espace davantage les spawns de colis pour garder ~60 fps. */
|
||
const IS_MOBILE = /iPhone|iPad|iPod|Android|Mobile/i.test(navigator.userAgent)
|
||
|| window.matchMedia('(max-width: 768px)').matches;
|
||
|
||
/* ── Renderer & camera ─────────────────────────────────────────────────── */
|
||
const canvas = document.getElementById('three-canvas');
|
||
const renderer = new THREE.WebGLRenderer({
|
||
canvas, alpha: true,
|
||
antialias: !IS_MOBILE,
|
||
powerPreference: 'high-performance'
|
||
});
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, IS_MOBILE ? 1.5 : 2));
|
||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||
renderer.toneMappingExposure = 1.05;
|
||
|
||
const scene = new THREE.Scene();
|
||
|
||
const camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 1000);
|
||
camera.position.set(0, 0, 22);
|
||
camera.lookAt(0, 0, 0);
|
||
|
||
/* ── Lighting (golden-hour fill) ───────────────────────────────────────── */
|
||
const sun = new THREE.DirectionalLight(0xfff1d6, 1.5);
|
||
sun.position.set(-6, 8, 4);
|
||
scene.add(sun);
|
||
|
||
const skyFill = new THREE.HemisphereLight(0xa8c5ff, 0x3a2a1a, 0.65);
|
||
scene.add(skyFill);
|
||
|
||
const ambient = new THREE.AmbientLight(0xffffff, 0.20);
|
||
scene.add(ambient);
|
||
|
||
/* ── Plane (GLTF) ──────────────────────────────────────────────────────── */
|
||
const planeHolder = new THREE.Group();
|
||
scene.add(planeHolder);
|
||
|
||
const loader = new GLTFLoader();
|
||
loader.load(
|
||
'assets/airplane.glb',
|
||
(gltf) => {
|
||
const model = gltf.scene;
|
||
const box = new THREE.Box3().setFromObject(model);
|
||
const size = box.getSize(new THREE.Vector3());
|
||
const center = box.getCenter(new THREE.Vector3());
|
||
model.position.sub(center);
|
||
|
||
const wrapper = new THREE.Group();
|
||
wrapper.add(model);
|
||
const targetSize = 8.5;
|
||
wrapper.scale.setScalar(targetSize / Math.max(size.x, size.y, size.z));
|
||
/* Pivote le modèle pour que le nez pointe vers la gauche (-X) :
|
||
l'avion entre par la droite et sort par la gauche. */
|
||
wrapper.rotation.y = -Math.PI / 2;
|
||
planeHolder.add(wrapper);
|
||
},
|
||
undefined,
|
||
(err) => console.error('Failed to load airplane.glb:', err)
|
||
);
|
||
|
||
/* ── Colis parachutés (modèle 3D Poly by Google + boîte cartonnée) ──────
|
||
À intervalle régulier (et tant que l'avion est dans le cadre), on
|
||
clone un template « parachute + boîte ». La boîte est solidaire du
|
||
parachute (mêmes parent → même position/rotation/échelle), et placée
|
||
pile au niveau du harnais pour que l'attachement soit lisible.
|
||
*/
|
||
const PARCEL_SPAWN_EVERY = IS_MOBILE ? 2.4 : 1.4; // moins fréquent sur mobile
|
||
const PARCEL_FALL_SPEED = 1.4;
|
||
const PARCEL_DRIFT = 0.45;
|
||
const PARCEL_INITIAL_SCALE = 0.45;
|
||
const PARCEL_FINAL_SCALE = 2.4;
|
||
const PARCEL_LIFETIME = 5.5;
|
||
|
||
const parcels = [];
|
||
let lastSpawn = 0;
|
||
|
||
let parcelTemplate = null;
|
||
|
||
const parachuteLoader = new GLTFLoader();
|
||
parachuteLoader.load(
|
||
'assets/parachute.glb',
|
||
(gltf) => {
|
||
const para = gltf.scene;
|
||
|
||
/* Calcule la bbox originale du parachute (avant tout transform) */
|
||
const box1 = new THREE.Box3().setFromObject(para);
|
||
const size = box1.getSize(new THREE.Vector3());
|
||
const baseScale = 1.6 / Math.max(size.x, size.y, size.z);
|
||
|
||
/* Wrapper qui scale, puis qui se re-centre proprement après scaling.
|
||
(Faire `sub(center)` avant `scale` laisse un offset, c'est pour ça
|
||
que le colis paraissait flotter sous le parachute.) */
|
||
const paraWrapper = new THREE.Group();
|
||
paraWrapper.add(para);
|
||
paraWrapper.scale.setScalar(baseScale);
|
||
const box2 = new THREE.Box3().setFromObject(paraWrapper);
|
||
const center2 = box2.getCenter(new THREE.Vector3());
|
||
paraWrapper.position.sub(center2);
|
||
|
||
/* Bbox monde du parachute centré à l'origine, max extent = 1.6 */
|
||
const parachuteHeight = size.y * baseScale;
|
||
const parachuteBottom = -parachuteHeight / 2;
|
||
|
||
/* Boîte cartonnée brune positionnée juste sous le harnais (= sous
|
||
le bas du parachute), avec un léger chevauchement pour que les
|
||
filins aient l'air de se terminer pile sur le dessus du colis.
|
||
Maintenant que la bbox est correctement centrée à l'origine
|
||
(grâce au wrapper), parachuteBottom correspond bien au point
|
||
d'attache visible du harnais. */
|
||
const cargoH = 0.30;
|
||
const cargo = new THREE.Mesh(
|
||
new THREE.BoxGeometry(0.36, cargoH, 0.36),
|
||
new THREE.MeshStandardMaterial({
|
||
color: 0xb98859, roughness: 0.85, metalness: 0.05
|
||
})
|
||
);
|
||
cargo.position.y = parachuteBottom - cargoH / 2 + 0.06;
|
||
|
||
parcelTemplate = new THREE.Group();
|
||
parcelTemplate.add(paraWrapper);
|
||
parcelTemplate.add(cargo);
|
||
},
|
||
undefined,
|
||
(err) => console.error('Failed to load parachute.glb:', err)
|
||
);
|
||
|
||
function spawnParcel(x, y) {
|
||
if (!parcelTemplate) return;
|
||
const g = parcelTemplate.clone(true);
|
||
/* On clone aussi les matériaux pour pouvoir gérer le fade par instance */
|
||
g.traverse((node) => {
|
||
if (node.isMesh && node.material) {
|
||
node.material = node.material.clone();
|
||
node.material.transparent = true;
|
||
}
|
||
});
|
||
|
||
/* Spawn point sous le ventre de l'avion — pas à hauteur du fuselage
|
||
(le colis sort de la soute basse, pas du milieu de l'appareil). */
|
||
g.position.set(x + (Math.random() - 0.5) * 0.4, y - 1.1, 0.2);
|
||
g.scale.setScalar(PARCEL_INITIAL_SCALE);
|
||
/* Petit angle initial différent pour chaque colis pour la variété */
|
||
g.rotation.y = Math.random() * Math.PI * 2;
|
||
scene.add(g);
|
||
parcels.push({
|
||
group: g, age: 0,
|
||
vx: (Math.random() - 0.5) * PARCEL_DRIFT,
|
||
sway: 0.5 + Math.random() * 0.6,
|
||
swayAmp: 0.08 + Math.random() * 0.08,
|
||
spinSpeed: (Math.random() - 0.5) * 0.4
|
||
});
|
||
}
|
||
|
||
function updateParcels(dt, t) {
|
||
for (let i = parcels.length - 1; i >= 0; i--) {
|
||
const p = parcels[i];
|
||
p.age += dt;
|
||
const life = p.age / PARCEL_LIFETIME;
|
||
|
||
/* Chute + dérive + balancement (boîte et parachute solidaires) */
|
||
p.group.position.y -= PARCEL_FALL_SPEED * dt;
|
||
p.group.position.x += p.vx * dt;
|
||
p.group.position.x += Math.sin(t * p.sway) * p.swayAmp * dt;
|
||
/* Léger basculement — pendulum naturel */
|
||
p.group.rotation.z = Math.sin(t * p.sway + p.age) * 0.10;
|
||
p.group.rotation.y += p.spinSpeed * dt;
|
||
|
||
/* Grandit (perspective) */
|
||
const k = Math.min(1, life * 1.1);
|
||
p.group.scale.setScalar(
|
||
PARCEL_INITIAL_SCALE + (PARCEL_FINAL_SCALE - PARCEL_INITIAL_SCALE) * k
|
||
);
|
||
|
||
/* Fade-in rapide / fade-out lent — propagé sur tous les meshes */
|
||
let opacity = 1;
|
||
if (life < 0.06) opacity = life / 0.06;
|
||
else if (life > 0.85) opacity = (1 - life) / 0.15;
|
||
opacity = Math.max(0, Math.min(1, opacity));
|
||
p.group.traverse((node) => {
|
||
if (node.isMesh && node.material) node.material.opacity = opacity;
|
||
});
|
||
|
||
if (life >= 1 || p.group.position.y < -10) {
|
||
scene.remove(p.group);
|
||
p.group.traverse((node) => {
|
||
if (node.isMesh && node.material) node.material.dispose();
|
||
});
|
||
parcels.splice(i, 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ── Souris ─────────────────────────────────────────────────────────────
|
||
L'avion avance automatiquement à vitesse de croisière (BASE_SPEED).
|
||
Bouger la souris ajoute un boost qui le pousse plus vite vers la fin.
|
||
Une fois sorti à droite (progress = 1), il reste sorti.
|
||
*/
|
||
/* Vitesse de croisière : 28 s desktop, 10 s mobile (l'écran est plus
|
||
petit, l'utilisateur a moins de temps de regarder, donc on accélère). */
|
||
const BASE_SPEED = 1 / (IS_MOBILE ? 10 : 28);
|
||
const MOUSE_BOOST = 1 / 4500; // pixels de souris → progress (0..1)
|
||
const mouse = {
|
||
targetProgress: 0,
|
||
progress: 0,
|
||
px: 0.5, py: 0.5
|
||
};
|
||
|
||
let lastX = null, lastY = null;
|
||
window.addEventListener('mousemove', (e) => {
|
||
if (lastX !== null) {
|
||
const dx = e.clientX - lastX;
|
||
const dy = e.clientY - lastY;
|
||
mouse.targetProgress = Math.min(1, mouse.targetProgress + Math.hypot(dx, dy) * MOUSE_BOOST);
|
||
}
|
||
lastX = e.clientX; lastY = e.clientY;
|
||
mouse.px = e.clientX / window.innerWidth;
|
||
mouse.py = e.clientY / window.innerHeight;
|
||
}, { passive: true });
|
||
|
||
/* Mobile : la rotation du device pousse l'avion plus vite vers la fin */
|
||
let lastGamma = null, lastBeta = null;
|
||
window.addEventListener('deviceorientation', (e) => {
|
||
if (e.gamma == null || e.beta == null) return;
|
||
if (lastGamma !== null) {
|
||
const dg = e.gamma - lastGamma;
|
||
const db = e.beta - lastBeta;
|
||
mouse.targetProgress = Math.min(1, mouse.targetProgress + Math.hypot(dg, db) / 120);
|
||
}
|
||
lastGamma = e.gamma; lastBeta = e.beta;
|
||
mouse.px = Math.max(0, Math.min(1, (e.gamma + 30) / 60));
|
||
mouse.py = Math.max(0, Math.min(1, (e.beta - 20) / 60));
|
||
}, { passive: true });
|
||
|
||
/* Tactile : tant que le doigt est posé, l'avion accélère (multiplicateur
|
||
sur BASE_SPEED). Le swipe ajoute aussi du progrès cumulé comme la
|
||
souris sur desktop. */
|
||
let touchBoost = 1;
|
||
let lastTouchX = null, lastTouchY = null;
|
||
|
||
window.addEventListener('touchstart', (e) => {
|
||
touchBoost = 6;
|
||
if (e.touches[0]) {
|
||
lastTouchX = e.touches[0].clientX;
|
||
lastTouchY = e.touches[0].clientY;
|
||
}
|
||
}, { passive: true });
|
||
|
||
window.addEventListener('touchmove', (e) => {
|
||
const t0 = e.touches[0];
|
||
if (!t0) return;
|
||
if (lastTouchX !== null) {
|
||
const dx = t0.clientX - lastTouchX;
|
||
const dy = t0.clientY - lastTouchY;
|
||
mouse.targetProgress = Math.min(1, mouse.targetProgress + Math.hypot(dx, dy) * MOUSE_BOOST);
|
||
}
|
||
lastTouchX = t0.clientX;
|
||
lastTouchY = t0.clientY;
|
||
mouse.px = t0.clientX / window.innerWidth;
|
||
mouse.py = t0.clientY / window.innerHeight;
|
||
}, { passive: true });
|
||
|
||
function endTouch() { touchBoost = 1; lastTouchX = lastTouchY = null; }
|
||
window.addEventListener('touchend', endTouch, { passive: true });
|
||
window.addEventListener('touchcancel', endTouch, { passive: true });
|
||
|
||
const root = document.documentElement;
|
||
|
||
/* ── Révélation du CTA ─────────────────────────────────────────────────
|
||
Quand l'avion arrive au centre de l'écran (p ≥ 0.5), on bascule la
|
||
classe `.revealed` sur le bouton. La CSS gère l'animation (translate
|
||
du haut vers le centre + scale 0.05 → 1, ~1.2 s). */
|
||
const ctaBtn = document.querySelector('.cta-btn');
|
||
let ctaRevealed = false;
|
||
|
||
/* ── Render loop ───────────────────────────────────────────────────────── */
|
||
const clock = new THREE.Clock();
|
||
|
||
let lastT = 0;
|
||
function tick() {
|
||
const t = clock.getElapsedTime();
|
||
const dt = Math.min(0.1, t - lastT); // clamp pour éviter les bonds après onglet en arrière-plan
|
||
lastT = t;
|
||
|
||
/* Avance autonome + boost tactile (×6 tant qu'un doigt touche l'écran)
|
||
+ cible cumulée par souris/swipe, le tout limité à 1 */
|
||
mouse.targetProgress = Math.min(1, mouse.targetProgress + BASE_SPEED * touchBoost * dt);
|
||
/* Lerp doux pour fluidifier */
|
||
mouse.progress += (mouse.targetProgress - mouse.progress) * 0.06;
|
||
const p = mouse.progress;
|
||
|
||
/* Parallaxe légère de la photo de fond */
|
||
root.style.setProperty('--mx', ((mouse.px - 0.5) * 2).toFixed(4));
|
||
root.style.setProperty('--my', ((mouse.py - 0.5) * 2).toFixed(4));
|
||
|
||
/* Trajectoire droite → gauche, en très légère montée :
|
||
- p = 0 → entre par la droite à mi-hauteur haute (y ≈ 3.5)
|
||
- p = 0.5 → traverse à y ≈ 4.25
|
||
- p = 1 → sort par la gauche un peu plus haut (y ≈ 5)
|
||
Pente ≈ 1.5/40 ≈ 2° (presque horizontal).
|
||
*/
|
||
const px = 18 - p * 40; // +18 → -22 (toujours bien hors champ aux deux bouts)
|
||
const py = 3.5 + p * 1.5; // +3.5 → +5 (douce montée)
|
||
const bob = Math.sin(t * 0.9) * 0.08;
|
||
|
||
planeHolder.position.set(px, py + bob, 0);
|
||
|
||
/* Spawn d'un colis parachuté à intervalle régulier — uniquement
|
||
pendant que l'avion est dans (ou proche de) la zone visible. */
|
||
if (p > 0.04 && p < 0.92 && t - lastSpawn > PARCEL_SPAWN_EVERY) {
|
||
spawnParcel(px, py + bob);
|
||
lastSpawn = t;
|
||
}
|
||
updateParcels(dt, t);
|
||
|
||
/* Au moment où l'avion atteint le centre, on déclenche la sortie du
|
||
CTA (l'animation CSS s'en occupe ensuite). One-shot. */
|
||
if (!ctaRevealed && p >= 0.5) {
|
||
ctaRevealed = true;
|
||
ctaBtn?.classList.add('revealed');
|
||
}
|
||
|
||
/* Avec nez à -X et up = +Y :
|
||
- rotation.z = PITCH (négatif = nez en l'air)
|
||
- rotation.x = ROLL
|
||
Pente de 2° → on relève le nez légèrement pour suivre la trajectoire.
|
||
*/
|
||
const targetPitch = -0.06 - p * 0.01; // nez ~3.5°-4° en l'air
|
||
const targetRoll = 0.04 + (p - 0.5) * 0.04; // roulis très subtil
|
||
const targetYaw = 0;
|
||
planeHolder.rotation.z += (targetPitch - planeHolder.rotation.z) * 0.08;
|
||
planeHolder.rotation.x += (targetRoll - planeHolder.rotation.x) * 0.08;
|
||
planeHolder.rotation.y += (targetYaw - planeHolder.rotation.y) * 0.08;
|
||
|
||
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();
|