257 lines
8.9 KiB
JavaScript
257 lines
8.9 KiB
JavaScript
// Reveal animations, drawn by the renderer on top of / behind the rings.
|
|
// Each reveal gets live ring objects so it follows fingers that still move.
|
|
|
|
export const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
|
|
export const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
|
|
export const easeInOutCubic = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
|
|
|
|
export function hexToRgba(hex, a) {
|
|
const n = parseInt(hex.slice(1), 16);
|
|
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`;
|
|
}
|
|
|
|
function farthestCorner(x, y, w, h) {
|
|
return Math.hypot(Math.max(x, w - x), Math.max(y, h - y));
|
|
}
|
|
|
|
/**
|
|
* @param {string} style flood | confetti | spotlight | shockwave | calm | teams
|
|
* @param {{targets:{ring:{x:number,y:number}, color:string}[], w:number, h:number, R:number, start:number, palette:string[]}} o
|
|
*/
|
|
export function createReveal(style, o) {
|
|
const make = { flood, confetti, spotlight, shockwave, calm, teams }[style] || calm;
|
|
return { winnerWhite: false, drawBack() {}, drawFront() {}, shake: null, ...make(o) };
|
|
}
|
|
|
|
function flood({ targets, w, h, start }) {
|
|
const single = targets.length === 1;
|
|
return {
|
|
winnerWhite: true,
|
|
drawBack(g, now) {
|
|
const k = easeInOutCubic(clamp01((now - start) / 850));
|
|
g.globalAlpha = single ? 1 : 0.8;
|
|
for (const { ring, color } of targets) {
|
|
const maxR = farthestCorner(ring.x, ring.y, w, h) * (single ? 1 : 0.5);
|
|
g.fillStyle = color;
|
|
g.beginPath();
|
|
g.arc(ring.x, ring.y, Math.max(0, maxR * k), 0, Math.PI * 2);
|
|
g.fill();
|
|
}
|
|
g.globalAlpha = 1;
|
|
if (single && k >= 1) {
|
|
// Soft vignette so the flooded screen keeps some depth.
|
|
const { ring } = targets[0];
|
|
const grad = g.createRadialGradient(ring.x, ring.y, 0, ring.x, ring.y, farthestCorner(ring.x, ring.y, w, h));
|
|
grad.addColorStop(0, 'rgba(0,0,0,0)');
|
|
grad.addColorStop(1, 'rgba(0,0,0,0.4)');
|
|
g.fillStyle = grad;
|
|
g.fillRect(0, 0, w, h);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function confetti({ targets, w, h, R, start, palette }) {
|
|
const parts = [];
|
|
const per = Math.round(170 / targets.length);
|
|
for (const { ring, color } of targets) {
|
|
for (let i = 0; i < per; i++) {
|
|
const a = Math.random() * Math.PI * 2;
|
|
const speed = 250 + Math.random() * 850;
|
|
parts.push({
|
|
ring,
|
|
delay: i < per * 0.6 ? 0 : 0.22,
|
|
vx: Math.cos(a) * speed,
|
|
vy: Math.sin(a) * speed - 300,
|
|
size: 5 + Math.random() * 7,
|
|
color: Math.random() < 0.45 ? color : palette[(Math.random() * palette.length) | 0],
|
|
rot: Math.random() * Math.PI,
|
|
vr: (Math.random() - 0.5) * 14,
|
|
flip: 4 + Math.random() * 8,
|
|
life: 2.4 + Math.random() * 1.2,
|
|
round: Math.random() < 0.3,
|
|
});
|
|
}
|
|
}
|
|
// Freeze burst origin at reveal time.
|
|
for (const p of parts) {
|
|
p.x0 = p.ring.x;
|
|
p.y0 = p.ring.y;
|
|
}
|
|
const tau = 0.75;
|
|
const grav = 1100;
|
|
return {
|
|
drawBack(g, now) {
|
|
const k = clamp01((now - start) / 700);
|
|
for (const { ring, color } of targets) {
|
|
const grad = g.createRadialGradient(ring.x, ring.y, R, ring.x, ring.y, Math.max(w, h));
|
|
grad.addColorStop(0, hexToRgba(color, 0.45 * k));
|
|
grad.addColorStop(1, hexToRgba(color, 0.08 * k));
|
|
g.fillStyle = grad;
|
|
g.fillRect(0, 0, w, h);
|
|
}
|
|
},
|
|
drawFront(g, now) {
|
|
const T = (now - start) / 1000;
|
|
for (const p of parts) {
|
|
const t = T - p.delay;
|
|
if (t <= 0 || t > p.life) continue;
|
|
const e = tau * (1 - Math.exp(-t / tau));
|
|
const x = p.x0 + p.vx * e + Math.sin(t * 3 + p.rot) * 12 * t;
|
|
const y = p.y0 + (p.vy + grav * tau) * e - grav * tau * t * 0.55;
|
|
if (y > h + 20) continue;
|
|
g.globalAlpha = clamp01((p.life - t) / 0.5);
|
|
g.fillStyle = p.color;
|
|
g.save();
|
|
g.translate(x, y);
|
|
g.rotate(p.rot + p.vr * t);
|
|
g.scale(1, Math.cos(t * p.flip));
|
|
if (p.round) {
|
|
g.beginPath();
|
|
g.arc(0, 0, p.size * 0.5, 0, Math.PI * 2);
|
|
g.fill();
|
|
} else g.fillRect(-p.size / 2, -p.size * 0.3, p.size, p.size * 0.6);
|
|
g.restore();
|
|
}
|
|
g.globalAlpha = 1;
|
|
},
|
|
};
|
|
}
|
|
|
|
function spotlight({ targets, w, h, R, start }) {
|
|
// Darkness is drawn at half resolution: it's all soft gradients anyway.
|
|
const scale = 0.5;
|
|
const off = document.createElement('canvas');
|
|
off.width = Math.ceil(w * scale);
|
|
off.height = Math.ceil(h * scale);
|
|
const o = off.getContext('2d');
|
|
const spots = targets.map(() => ({ sx: Math.random() * w, sy: Math.random() * h * 0.6, ph: Math.random() * 6 }));
|
|
return {
|
|
drawBack(g, now) {
|
|
const k = clamp01((now - start - 900) / 500);
|
|
if (!k) return;
|
|
for (const { ring, color } of targets) {
|
|
const grad = g.createRadialGradient(ring.x, ring.y, 0, ring.x, ring.y, R * 3);
|
|
grad.addColorStop(0, hexToRgba(color, 0.55 * k));
|
|
grad.addColorStop(1, hexToRgba(color, 0));
|
|
g.fillStyle = grad;
|
|
g.fillRect(ring.x - R * 3, ring.y - R * 3, R * 6, R * 6);
|
|
}
|
|
},
|
|
drawFront(g, now) {
|
|
const t = (now - start) / 1000;
|
|
o.setTransform(scale, 0, 0, scale, 0, 0);
|
|
o.globalCompositeOperation = 'source-over';
|
|
o.clearRect(0, 0, w, h);
|
|
o.fillStyle = `rgba(0,0,0,${0.9 * clamp01(t / 0.25)})`;
|
|
o.fillRect(0, 0, w, h);
|
|
o.globalCompositeOperation = 'destination-out';
|
|
targets.forEach(({ ring }, i) => {
|
|
const s = spots[i];
|
|
const m = easeOutCubic(clamp01(t / 1.1));
|
|
const wob = (1 - m) * 60;
|
|
const x = s.sx + (ring.x - s.sx) * m + Math.sin(t * 9 + s.ph) * wob;
|
|
const y = s.sy + (ring.y - s.sy) * m + Math.cos(t * 7 + s.ph) * wob;
|
|
const r = Math.max(w, h) * 0.22 + (R * 2.3 - Math.max(w, h) * 0.22) * easeInOutCubic(clamp01((t - 0.3) / 0.9));
|
|
const grad = o.createRadialGradient(x, y, r * 0.7, x, y, r * 1.2);
|
|
grad.addColorStop(0, 'rgba(0,0,0,1)');
|
|
grad.addColorStop(1, 'rgba(0,0,0,0)');
|
|
o.fillStyle = grad;
|
|
o.fillRect(x - r * 1.2, y - r * 1.2, r * 2.4, r * 2.4);
|
|
});
|
|
g.drawImage(off, 0, 0, w, h);
|
|
},
|
|
};
|
|
}
|
|
|
|
function shockwave({ targets, w, h, R, start }) {
|
|
const delays = [0, 0.14, 0.3];
|
|
return {
|
|
shake(now) {
|
|
const t = (now - start) / 1000;
|
|
const amp = 16 * Math.exp(-t * 7);
|
|
if (amp < 0.3) return null;
|
|
return [Math.sin(now * 0.09) * amp, Math.cos(now * 0.113) * amp];
|
|
},
|
|
drawBack(g, now) {
|
|
const t = (now - start) / 1000;
|
|
for (const { ring, color } of targets) {
|
|
g.fillStyle = hexToRgba(color, 0.25 * clamp01((t - 0.2) / 0.6));
|
|
g.fillRect(-20, -20, w + 40, h + 40);
|
|
const maxR = farthestCorner(ring.x, ring.y, w, h);
|
|
for (const d of delays) {
|
|
const k = (t - d) / 1.0;
|
|
if (k <= 0 || k >= 1) continue;
|
|
const r = easeOutCubic(k) * maxR;
|
|
g.globalAlpha = 1 - k;
|
|
g.strokeStyle = color;
|
|
g.lineWidth = R * 0.6 * (1 - k) + 2;
|
|
g.beginPath();
|
|
g.arc(ring.x, ring.y, r, 0, Math.PI * 2);
|
|
g.stroke();
|
|
g.strokeStyle = '#fff';
|
|
g.lineWidth = Math.max(1, R * 0.12 * (1 - k));
|
|
g.stroke();
|
|
}
|
|
g.globalAlpha = 1;
|
|
}
|
|
},
|
|
drawFront(g, now) {
|
|
const t = (now - start) / 1000;
|
|
if (t < 0.15) {
|
|
g.fillStyle = `rgba(255,255,255,${0.55 * (1 - t / 0.15)})`;
|
|
g.fillRect(-20, -20, w + 40, h + 40);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/** prefers-reduced-motion: a slow, gentle colour fade. */
|
|
function calm({ targets, w, h, R, start }) {
|
|
return {
|
|
drawBack(g, now) {
|
|
const k = easeInOutCubic(clamp01((now - start) / 1200));
|
|
for (const { ring, color } of targets) {
|
|
const grad = g.createRadialGradient(ring.x, ring.y, R, ring.x, ring.y, Math.max(w, h) * 0.9);
|
|
grad.addColorStop(0, hexToRgba(color, 0.5 * k));
|
|
grad.addColorStop(1, hexToRgba(color, 0.15 * k));
|
|
g.fillStyle = grad;
|
|
g.fillRect(0, 0, w, h);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function teams({ targets, R, start }) {
|
|
return {
|
|
drawBack(g, now) {
|
|
const k = easeOutCubic(clamp01((now - start) / 700));
|
|
for (const { ring, color } of targets) {
|
|
const r = R * (1.5 + 2.5 * k);
|
|
const grad = g.createRadialGradient(ring.x, ring.y, 0, ring.x, ring.y, r);
|
|
grad.addColorStop(0, hexToRgba(color, 0.5));
|
|
grad.addColorStop(1, hexToRgba(color, 0));
|
|
g.fillStyle = grad;
|
|
g.fillRect(ring.x - r, ring.y - r, r * 2, r * 2);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Small expanding ring (joins, countdown beats, order steps). */
|
|
export function createRipple(x, y, color, start, maxR, dur = 600, width = 4) {
|
|
return {
|
|
done: (now) => now - start > dur,
|
|
draw(g, now) {
|
|
const k = (now - start) / dur;
|
|
if (k < 0 || k > 1) return;
|
|
g.globalAlpha = (1 - k) * 0.8;
|
|
g.strokeStyle = color;
|
|
g.lineWidth = width * (1 - k) + 0.5;
|
|
g.beginPath();
|
|
g.arc(x, y, maxR * easeOutCubic(k), 0, Math.PI * 2);
|
|
g.stroke();
|
|
g.globalAlpha = 1;
|
|
},
|
|
};
|
|
}
|