site-mva-global-fret/js/intro-scene.js
MVA Global Fret 80957e804b Mobile: bump autonomous plane speed from 28 s → 16 s per traversal
Mobile screens are smaller and users glance at them for less time,
so the 28-second full-traversal on desktop felt sluggish there.
BASE_SPEED is now 1/16 on mobile vs 1/28 on desktop. The touch
boost (×6) still stacks on top, giving a sub-3-second sprint when
the user holds a finger down.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-05 14:46:23 +02:00

365 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* =========================================================================
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, 16 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 ? 16 : 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();