diff --git a/assets/parachute.glb b/assets/parachute.glb new file mode 100644 index 0000000..b2fb9ad Binary files /dev/null and b/assets/parachute.glb differ diff --git a/assets/parcel.png b/assets/parcel.png deleted file mode 100644 index dac9a04..0000000 Binary files a/assets/parcel.png and /dev/null differ diff --git a/index.html b/index.html index 6a1a985..36ee636 100644 --- a/index.html +++ b/index.html @@ -1,9 +1,9 @@ diff --git a/js/intro-scene.js b/js/intro-scene.js index fb71a53..8991b1f 100644 --- a/js/intro-scene.js +++ b/js/intro-scene.js @@ -1,11 +1,12 @@ /* ========================================================================= INTRO SCENE — MVA Global Fret - Photo aérienne d'Antananarivo en fond, avion 3D piloté par la souris : - il entre par le haut-gauche quand la souris est à gauche, traverse - l'écran à mesure qu'elle bouge, et sort par la droite. Pas de scroll. + 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 credit (CC-BY 3.0) : « Airplane » by Poly by Google - https://poly.pizza/m/a3XrQkLNna9 + 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'; @@ -65,38 +66,89 @@ loader.load( (err) => console.error('Failed to load airplane.glb:', err) ); -/* ── Colis parachutés ────────────────────────────────────────────────── +/* ── 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 - spawn un sprite « parachute + colis » à la position de l'avion. - Chaque colis tombe lentement et grossit (effet de zoom dû à la - descente vers la caméra). Il s'efface en fin de course. + 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 parcelTex = new THREE.TextureLoader().load('assets/parcel.png'); -parcelTex.colorSpace = THREE.SRGBColorSpace; - -const PARCEL_SPAWN_EVERY = 1.4; // un colis toutes les ~1.4s -const PARCEL_FALL_SPEED = 1.4; // unités monde/s -const PARCEL_DRIFT = 0.45; // dérive horizontale (sortie de soute) -const PARCEL_INITIAL_SCALE = 0.35; -const PARCEL_FINAL_SCALE = 2.6; -const PARCEL_LIFETIME = 5.5; // s avant de disparaître +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) { - const mat = new THREE.SpriteMaterial({ - map: parcelTex, transparent: true, opacity: 0 + 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; + } }); - const sprite = new THREE.Sprite(mat); - sprite.position.set(x + (Math.random() - 0.5) * 0.4, y - 0.2, 0.2); - sprite.scale.setScalar(PARCEL_INITIAL_SCALE); - scene.add(sprite); + + 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({ - sprite, age: 0, - vx: (Math.random() - 0.5) * PARCEL_DRIFT, // dérive latérale légère - sway: 0.5 + Math.random() * 0.6, // freq de balancement - swayAmp: 0.08 + Math.random() * 0.08 + 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 }); } @@ -106,26 +158,34 @@ function updateParcels(dt, t) { p.age += dt; const life = p.age / PARCEL_LIFETIME; - /* Trajectoire : chute douce + dérive + balancement parachute */ - p.sprite.position.y -= PARCEL_FALL_SPEED * dt; - p.sprite.position.x += p.vx * dt; - p.sprite.position.x += Math.sin(t * p.sway) * p.swayAmp * dt; + /* 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 en tombant — perspective */ + /* Grandit (perspective) */ const k = Math.min(1, life * 1.1); - p.sprite.scale.setScalar( + p.group.scale.setScalar( PARCEL_INITIAL_SCALE + (PARCEL_FINAL_SCALE - PARCEL_INITIAL_SCALE) * k ); - /* Fade-in rapide au spawn, fade-out lent à la fin */ + /* 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; - p.sprite.material.opacity = Math.max(0, Math.min(1, opacity)); + 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.sprite.position.y < -10) { - scene.remove(p.sprite); - p.sprite.material.dispose(); + 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); } }