Adds a sprite-based parcel system. The plane spawns a new parcel-on-parachute (assets/parcel.png, 236 KB transparent) every ~1.4 s while it's visible (progress 0.04-0.92). Each parcel: - Spawns at the plane's current position with a tiny random offset. - Falls at 1.4 world units/s, with a small horizontal drift and a parachute sway sinusoid for character. - Scales 0.35 → 2.6 over its lifetime, simulating the perspective of falling toward the camera. - Fades in over 6% of life, fades out over the last 15%. - Cleaned up (removed from scene + material disposed) when its lifetime expires or it drops below y = -10. Implemented as THREE.Sprite so it always faces the camera, no need to track per-parcel orientation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
241 lines
9.6 KiB
JavaScript
241 lines
9.6 KiB
JavaScript
/* =========================================================================
|
|
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.
|
|
|
|
3D model credit (CC-BY 3.0) : « Airplane » by Poly by Google
|
|
https://poly.pizza/m/a3XrQkLNna9
|
|
========================================================================= */
|
|
|
|
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 ──────────────────────────────────────────────────
|
|
À 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.
|
|
*/
|
|
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 parcels = [];
|
|
let lastSpawn = 0;
|
|
|
|
function spawnParcel(x, y) {
|
|
const mat = new THREE.SpriteMaterial({
|
|
map: parcelTex, transparent: true, opacity: 0
|
|
});
|
|
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);
|
|
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
|
|
});
|
|
}
|
|
|
|
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;
|
|
|
|
/* 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;
|
|
|
|
/* Grandit en tombant — perspective */
|
|
const k = Math.min(1, life * 1.1);
|
|
p.sprite.scale.setScalar(
|
|
PARCEL_INITIAL_SCALE + (PARCEL_FINAL_SCALE - PARCEL_INITIAL_SCALE) * k
|
|
);
|
|
|
|
/* Fade-in rapide au spawn, fade-out lent à la fin */
|
|
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));
|
|
|
|
if (life >= 1 || p.sprite.position.y < -10) {
|
|
scene.remove(p.sprite);
|
|
p.sprite.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();
|