/* position:fixed + top:0/left:0 explicites, z-index très haut : le
calque reste calé sur le vrai écran visible et toujours au-dessus de
tout le reste de la page (carte de fin d'exercice comprise), quel que
soit l'endroit où ce widget HTML est placé dans la mise en page. */
#confetti-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
overflow: hidden;
pointer-events: none;
z-index: 999999;
background: transparent;
}
#confettiCanvas {
display: block;
width: 100%;
height: 100%;
background: transparent;
}
(function () {
// =========================================================================
// CONFETTI — reprend fidèlement le moteur de particules fourni comme
// référence ("c'est cet effet que je veux") : explosion en forme de
// comètes (tête étoilée + traînée dessinée directement, dont la longueur
// dépend de la vitesse COURANTE de la particule à chaque image -> elle
// s'amenuise naturellement quand la particule ralentit, sans aucun
// bricolage de canvas), suivies d'étoiles simples scintillantes.
//
// Différences par rapport au fichier de référence (qui était une démo
// "cliquez pour rejouer") :
// - Le canvas est calé en plein écran (position fixed) et surveille la
// section .taekwon_page_complete comme le reste du site : l'effet se
// déclenche automatiquement à la fin d'un exercice, pas au clic.
// - La pluie d'étoiles est maintenant ILLIMITÉE : au lieu d'un seul lot
// fini de particules, de nouveaux petits lots sont générés en continu
// (confettiRain rappelée à intervalle régulier) tant que l'écran de
// fin d'exercice reste affiché, et s'arrêtent dès qu'il se referme.
// - Plus de dépendance externe (canvas-confetti) : ce moteur est
// autonome, ce qui règle aussi au passage tous les problèmes qu'on
// avait avec les formes figées/la rotation impossible par particule
// de cette librairie -- ici chaque comète est simplement tournée
// (ctx.rotate) selon sa propre direction de vol à chaque image.
var wrapper = document.getElementById('confetti-wrapper');
var canvas = document.getElementById('confettiCanvas');
var ctx = canvas.getContext('2d');
// Palette reprise telle quelle du fichier de référence (turquoise /
// jaune / corail / orange).
var COLORS = ['#3FC7C1', '#FFCE45', '#F2726B', '#FFA94D'];
var W, H, DPR;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2);
W = wrapper.clientWidth || window.innerWidth;
H = wrapper.clientHeight || window.innerHeight;
canvas.width = W * DPR;
canvas.height = H * DPR;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
window.addEventListener('resize', resize);
resize();
var MAX_PARTICLES = 500; // garde-fou : jamais un nombre illimité de
// particules à l'écran, même si la pluie
// tourne très longtemps.
var particles = [];
var rand = function (a, b) { return a + Math.random() * (b - a); };
var pick = function (arr) { return arr[(Math.random() * arr.length) | 0]; };
// Une particule = une "comète" : une petite étoile à 4 branches en tête,
// suivie d'une traînée qui s'amenuise. La traînée est dessinée en
// fonction de la vitesse courante -> elle rétrécit naturellement quand
// la particule ralentit, exactement comme demandé ("les étoiles ont
// parfois des traînées au début quand elles accélèrent, ensuite elles
// n'en ont plus"). `delay` retarde le "vrai" départ de la particule
// (utilisé par la pluie pour faire apparaître les étoiles de façon
// échelonnée plutôt que toutes d'un coup).
function spawnParticle(x, y, opts) {
if (particles.length >= MAX_PARTICLES) particles.shift(); // évite tout emballement
opts = opts || {};
var angle = opts.angle !== undefined ? opts.angle : rand(0, Math.PI * 2);
var speed = opts.speed !== undefined ? opts.speed : rand(340, 780);
var isComet = Math.random() < 0.72; // certaines restent de simples étoiles
particles.push({
x: x, y: y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
size: opts.size !== undefined ? opts.size : rand(9, 18),
color: pick(COLORS),
isComet: isComet,
age: 0,
delay: opts.delay || 0,
// durée avant de commencer à s'estomper, puis durée du fondu
holdTime: opts.holdTime !== undefined ? opts.holdTime : rand(0.45, 0.9),
fadeTime: opts.fadeTime !== undefined ? opts.fadeTime : rand(0.7, 1.3),
// rotation propre de l'étoile (indépendante de la trajectoire)
spin: rand(-4, 4),
rot: rand(0, Math.PI * 2),
// les étoiles "résiduelles" scintillent au lieu de juste disparaître
twinkle: !isComet ? rand(3, 7) : (opts.twinkle || 0),
gravity: opts.gravity !== undefined ? opts.gravity : 260,
alpha: 1,
dead: false
});
}
function confettiBurst(x, y, count) {
count = count || 90;
for (var i = 0; i < count; i++) spawnParticle(x, y);
}
// Pluie d'étoiles qui tombent du haut de l'écran et s'évanouissent en
// cours de chute. `originX` centre la zone de chute (facultatif, sinon
// toute la largeur).
function confettiRain(originX, count, spread) {
count = count || 26;
var cx = originX === undefined ? W / 2 : originX;
var half = (spread === undefined ? W * 0.85 : spread) / 2;
for (var i = 0; i < count; i++) {
var x = cx + rand(-half, half);
var y = rand(-70, -10); // part juste au-dessus du cadre visible
spawnParticle(x, y, {
angle: Math.PI / 2 + rand(-0.15, 0.15), // vers le bas, léger dérapage
speed: rand(50, 130), // vitesse initiale faible...
gravity: 220, // ...qui s'accélère en tombant
size: rand(7, 14),
delay: rand(0, 1.3), // apparition échelonnée
holdTime: rand(0.8, 1.5),
fadeTime: rand(0.5, 0.9),
twinkle: rand(4, 8)
});
}
}
// Vide instantanément toutes les particules à l'écran.
function confettiStop() {
particles.length = 0;
}
function drawStar(cx, cy, outerR, innerR, rotation, color, alpha) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(rotation);
ctx.beginPath();
var spikes = 4;
for (var i = 0; i 2) {
var grad = ctx.createLinearGradient(0, 0, -tailLen, 0);
grad.addColorStop(0, p.color);
grad.addColorStop(1, 'transparent');
ctx.beginPath();
ctx.moveTo(0, -p.size * 0.35);
ctx.lineTo(-tailLen, 0);
ctx.lineTo(0, p.size * 0.35);
ctx.closePath();
ctx.fillStyle = grad;
ctx.fill();
}
ctx.restore();
// petite étoile à la tête de la comète
drawStar(p.x, p.y, p.size, p.size * 0.42, p.rot, p.color, p.alpha);
}
var boucleActive = false;
var last = 0;
function frame(now) {
if (!boucleActive) return;
if (!last) last = now;
var dt = Math.min((now - last) / 1000, 0.05);
last = now;
ctx.clearRect(0, 0, W, H); // reste transparent, pas de fillRect
for (var i = particles.length - 1; i >= 0; i--) {
var p = particles[i];
p.age += dt;
// particule en attente (delay pas encore écoulé) : invisible, immobile
if (p.age
p.holdTime) {
var t = (localAge - p.holdTime) / p.fadeTime;
baseAlpha = Math.max(0, 1 - t);
}
if (localAge > p.holdTime + p.fadeTime || baseAlpha <= 0.01) {
particles.splice(i, 1);
continue;
}
// le scintillement module juste l'alpha affiché à l'instant t, sans
// jamais s'accumuler d'une image à l'autre
p.alpha = p.twinkle
? baseAlpha * (0.55 + 0.45 * Math.sin(localAge * p.twinkle))
: baseAlpha;
if (p.isComet && localAge <p.holdTime * 0.9) { drawComet(p); } else { drawStar(p.x, p.y, p.size, p.size * 0.42, p.rot, p.color, p.alpha); } } requestAnimationFrame(frame); } function demarrerBoucle() { if (boucleActive) return; boucleActive = true; last = 0; requestAnimationFrame(frame); } function arreterBoucle() { boucleActive = false; } // ✅ Pluie continue : au lieu d'un seul lot fini de particules, on // relance confettiRain() à intervalle régulier tant que l'écran de fin // d'exercice reste affiché -- elle ne s'arrête donc jamais d'elle-même. var pluieInterval = null; function demarrerPluie() { arreterPluie(); // sécurité : jamais deux pluies en même temps pluieInterval = setInterval(function () { confettiRain(undefined, 3 + Math.floor(Math.random() * 3), W * 0.9); }, 320); } function arreterPluie() { if (pluieInterval) { clearInterval(pluieInterval); pluieInterval = null; } } var sectionVisible = false; function tirerConfettis() { resize(); demarrerBoucle(); // Explosion près du haut de l'écran ("centre haut"). confettiBurst(W / 2, H * 0.2); // La pluie enchaîne tout de suite : chaque étoile de pluie a son // propre délai d'apparition échelonné (voir confettiRain), donc la // transition avec l'explosion reste douce même sans attendre ici. demarrerPluie(); } function arreterTout() { arreterPluie(); confettiStop(); arreterBoucle(); ctx.clearRect(0, 0, W, H); } // ✅ On surveille la section .taekwon_page_complete (voir // showCompletionSection() dans taekwon-progress.js, qui passe sa // visibilité à "visible" quand l'exercice est terminé) et on tire // l'explosion exactement à ce moment-là, à chaque fois qu'elle // réapparaît -- et on arrête tout (boucle + pluie continue) dès qu'elle // se referme. function surveillerSection() { var section = document.querySelector('[class*="taekwon_page_complete"]'); if (!section) { setTimeout(surveillerSection, 200); return; } var arretDifféré = null; var observer = new MutationObserver(function () { var visible = section.style.visibility === 'visible'; if (visible) { // La section redevient (ou reste) visible : on annule un arrêt // qui aurait pu être programmé juste avant (voir ci-dessous). if (arretDifféré) { clearTimeout(arretDifféré); arretDifféré = null; } if (!sectionVisible) { sectionVisible = true; tirerConfettis(); } } else if (sectionVisible && !arretDifféré) { // On n'arrête pas tout de suite : le chargement du résumé de fin // d'exercice (badges, score, bouton "Rejouer"...) peut modifier // brièvement le style de la section pendant son rendu, ce qui // déclenche cet observer sans que la section ait vraiment été // refermée. Un arrêt immédiat sur ce simple "scintillement" // coupait la pluie de confettis en plein milieu. On attend donc // un court instant : si la section n'est toujours pas visible // passé ce délai, on arrête pour de bon. arretDifféré = setTimeout(function () { arretDifféré = null; if (section.style.visibility !== 'visible') { sectionVisible = false; arreterTout(); } }, 500); } }); observer.observe(section, { attributes: true, attributeFilter: ['style'] }); // La pluie pouvant durer longtemps, on garde le canvas à la bonne // taille si l'écran change (rotation du téléphone, etc.) pendant // qu'elle est affichée. window.addEventListener('resize', function () { if (sectionVisible) resize(); }); } surveillerSection(); })();