/* ========================================================================= 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'; /* ── Renderer & camera ─────────────────────────────────────────────────── */ 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; 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 = 1.4; 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; /* Centrer + normaliser le parachute à ~1.6 unités max */ const box = new THREE.Box3().setFromObject(para); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); para.position.sub(center); const baseScale = 1.6 / Math.max(size.x, size.y, size.z); para.scale.setScalar(baseScale); /* Boîte cartonnée brune, dimensionnée pour ressembler à un colis réaliste, attachée juste au-dessous du harnais (bottom of bbox ≈ y -0.8 après centrage + scaling à 1.6). */ const cargo = new THREE.Mesh( new THREE.BoxGeometry(0.46, 0.36, 0.46), new THREE.MeshStandardMaterial({ color: 0xb98859, roughness: 0.85, metalness: 0.05 }) ); cargo.position.y = -0.96; // pendu juste sous le harnais /* Petites « sangles » noires sur la boîte (deux fines bandes perpendiculaires) → renforce visuellement l'idée d'attachement. */ const strapMat = new THREE.MeshStandardMaterial({ color: 0x2a2118, roughness: 0.6 }); const strapH = new THREE.Mesh(new THREE.BoxGeometry(0.48, 0.04, 0.48), strapMat); strapH.position.copy(cargo.position); const strapV = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.38, 0.48), strapMat); strapV.position.copy(cargo.position); parcelTemplate = new THREE.Group(); parcelTemplate.add(para); parcelTemplate.add(cargo); parcelTemplate.add(strapH); parcelTemplate.add(strapV); }, 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; } }); g.position.set(x + (Math.random() - 0.5) * 0.4, y - 0.4, 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. */ const BASE_SPEED = 1 / 28; // 28 s pour traverser sans toucher la souris 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 }); const root = document.documentElement; /* ── 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 + cible cumulée par la souris, le tout limité à 1 */ mouse.targetProgress = Math.min(1, mouse.targetProgress + BASE_SPEED * 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); /* 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();