Files
pragativeer-v2/public/streak-animation.html

879 lines
27 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PRAGATIVEER · Streak</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--badge: 240px;
--grow: 1.18;
--halo-w: 170%;
--font: "Space Grotesk", ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: transparent !important;
font-family: var(--font);
display: flex;
align-items: center;
justify-content: center;
-webkit-font-smoothing: antialiased;
user-select: none;
}
/* Canvases fill entire view and pass clicks through */
#fw, #ec {
position: fixed;
inset: 0;
pointer-events: none;
}
#fw { z-index: 4; }
#ec { z-index: 6; }
/* Subtle flash overlay on big burst */
.flash {
position: fixed;
inset: 0;
z-index: 5;
pointer-events: none;
opacity: 0;
background: radial-gradient(circle at 50% 46%, rgba(255, 240, 190, 0.45), rgba(212, 175, 55, 0.10) 45%, transparent 70%);
}
body.light-theme .flash {
background: radial-gradient(circle at 50% 46%, rgba(12, 96, 96, 0.18), rgba(12, 96, 96, 0.05) 45%, transparent 70%);
}
.stage.play.tier3 .flash { animation: flash 760ms ease-out 2600ms; }
.stage {
position: relative;
z-index: 10;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
}
.scene {
position: relative;
width: var(--badge);
height: var(--badge);
perspective: 1300px;
perspective-origin: 50% 46%;
display: flex;
align-items: center;
justify-content: center;
z-index: 20; /* High z-index keeps logo completely clear and prominent */
}
.stage.play.tier3 .scene { animation: shake 520ms ease-out 2620ms; }
/* Halo glow behind spinning badge */
.halo {
position: absolute;
top: 50%;
left: 50%;
width: var(--halo-w);
aspect-ratio: 1;
transform: translate(-50%, -50%) scale(0.3);
opacity: 0;
border-radius: 50%;
filter: blur(8px);
z-index: 0;
background: radial-gradient(circle,
rgba(246, 221, 147, 0.55) 0%,
rgba(212, 175, 55, 0.35) 34%,
rgba(212, 175, 55, 0.08) 58%,
transparent 72%);
}
body.light-theme .halo {
background: radial-gradient(circle,
rgba(12, 96, 96, 0.32) 0%,
rgba(12, 96, 96, 0.16) 34%,
rgba(12, 96, 96, 0.04) 58%,
transparent 72%);
}
.stage.play.happy .halo {
animation: bloom 850ms cubic-bezier(0.2, 0.8, 0.3, 1) 1850ms forwards, pulse 2.8s ease-in-out 2700ms infinite;
}
.stage.sad .halo {
background: radial-gradient(circle,
rgba(59, 130, 246, 0.22) 0%,
rgba(100, 116, 139, 0.18) 36%,
transparent 70%);
}
body.light-theme .stage.sad .halo {
background: radial-gradient(circle,
rgba(37, 99, 235, 0.20) 0%,
rgba(148, 163, 184, 0.14) 36%,
transparent 70%);
}
.stage.play.sad .halo {
animation: bloomcold 1000ms ease-out 1900ms forwards;
}
.scaler {
position: relative;
width: 100%;
height: 100%;
z-index: 1;
transform: scale(1);
}
.stage.play.happy .scaler {
animation: grow 680ms cubic-bezier(0.34, 1.56, 0.5, 1) 2050ms forwards, litglow 700ms ease-out 1900ms forwards;
}
body.light-theme .stage.play.happy .scaler {
animation: grow 680ms cubic-bezier(0.34, 1.56, 0.5, 1) 2050ms forwards, litglow-teal 700ms ease-out 1900ms forwards;
}
.stage.play.sad .scaler {
animation: droop 950ms cubic-bezier(0.3, 0.7, 0.4, 1) 2050ms forwards, dimglow 800ms ease-out 1900ms forwards;
}
.spinner {
position: absolute;
inset: 0;
will-change: transform;
transform: rotateY(0deg) scale(0);
}
.stage.play .spinner {
animation: spin 1800ms cubic-bezier(0.14, 0.62, 0.2, 1) 150ms forwards;
}
/* Badge styling */
.badge {
position: absolute;
inset: 0;
border-radius: 50%;
background: url('/PragatiVeer_Logo/SVG_Logos/Square_dark_background.svg') center/cover no-repeat !important;
transition: background 0.3s ease;
}
body.light-theme .badge {
background: url('/PragatiVeer_Logo/SVG_Logos/Square_light_background.svg') center/cover no-repeat !important;
}
.stage.sad .badge {
filter: saturate(0.85) brightness(0.92);
}
/* Replay tooltip / test hint */
.replay-hint {
position: absolute;
bottom: 8px;
font-size: 11px;
font-weight: 600;
opacity: 0;
transition: opacity 0.3s ease;
color: rgba(148, 163, 184, 0.75);
pointer-events: none;
letter-spacing: 0.04em;
}
body.light-theme .replay-hint { color: rgba(100, 116, 139, 0.85); }
.stage:hover .replay-hint { opacity: 1; }
@keyframes spin {
0% { transform: rotateY(0deg) scale(0); }
100% { transform: rotateY(1440deg) scale(1); }
}
@keyframes bloom {
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.3); }
60% { opacity: 1; transform: translate(-50%, -50%) scale(1.06); }
100% { opacity: 0.9; transform: translate(-50%, -50%) scale(1); }
}
@keyframes bloomcold {
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.3); }
100% { opacity: 0.6; transform: translate(-50%, -50%) scale(0.85); }
}
@keyframes pulse {
0%, 100% { opacity: 0.75; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.05); }
}
@keyframes grow {
0% { transform: scale(1); }
70% { transform: scale(calc(var(--grow) + 0.06)); }
100% { transform: scale(var(--grow)); }
}
@keyframes droop {
0% { transform: scale(1) translateY(0); }
100% { transform: scale(0.94) translateY(6px); }
}
@keyframes litglow {
0% { filter: drop-shadow(0 0 0 rgba(212, 175, 55, 0)); }
100% { filter: drop-shadow(0 0 24px rgba(233, 196, 99, 0.65)); }
}
@keyframes litglow-teal {
0% { filter: drop-shadow(0 0 0 rgba(12, 96, 96, 0)); }
100% { filter: drop-shadow(0 0 24px rgba(12, 96, 96, 0.50)); }
}
@keyframes dimglow {
0% { filter: drop-shadow(0 0 0 rgba(100, 116, 139, 0)); }
100% { filter: drop-shadow(0 4px 14px rgba(30, 41, 59, 0.35)); }
}
@keyframes flash {
0% { opacity: 0; }
18% { opacity: 0.8; }
100% { opacity: 0; }
}
@keyframes shake {
0%, 100% { transform: translate(0, 0); }
20% { transform: translate(-4px, 2px); }
40% { transform: translate(4px, -2px); }
60% { transform: translate(-3px, -1px); }
80% { transform: translate(3px, 1px); }
}
@media (prefers-reduced-motion: reduce) {
.stage.play .spinner { animation: none; transform: rotateY(0) scale(1); }
.stage.play.happy .scaler { animation: none; transform: scale(var(--grow)); }
.stage.play.sad .scaler { animation: none; transform: scale(0.94); }
.stage.play.tier3 .scene, .stage.play.tier3 .flash { animation: none; }
}
</style>
</head>
<body>
<canvas id="fw"></canvas>
<canvas id="ec"></canvas>
<div class="flash"></div>
<div class="stage" id="stage" title="Click to replay celebration">
<div class="scene">
<div class="halo"></div>
<div class="scaler">
<div class="spinner">
<div class="badge"></div>
</div>
</div>
</div>
<div class="replay-hint">✨ Click badge to replay</div>
</div>
<script>
(function(){
// ── 1. Theme Detection & Synchronization ─────────────────────────────────
function detectLight() {
const p = new URLSearchParams(window.location.search);
const param = p.get('theme');
if (param) return param === 'light';
try {
if (window.parent && window.parent.document && window.parent.document.documentElement) {
return !window.parent.document.documentElement.classList.contains('dark');
}
} catch (e) {}
return false;
}
let isLight = detectLight();
window.isLightTheme = isLight;
function applyThemeClass(light) {
if (light) {
document.body.classList.add('light-theme');
} else {
document.body.classList.remove('light-theme');
}
}
applyThemeClass(isLight);
// Poll for parent theme switch (instant visual update)
setInterval(() => {
const nowLight = detectLight();
if (nowLight !== isLight) {
isLight = nowLight;
window.isLightTheme = isLight;
applyThemeClass(isLight);
rebuildColors();
play();
}
}, 180);
// ── 2. Canvases & Sizing ────────────────────────────────────────────────
const cv = document.getElementById('fw');
const ctx = cv.getContext('2d');
const ec = document.getElementById('ec');
const ectx = ec.getContext('2d');
let W, H, DPR;
let keepoutRadiusSq = 0;
function resize() {
DPR = Math.min(2, window.devicePixelRatio || 1);
W = cv.width = ec.width = Math.floor(innerWidth * DPR);
H = cv.height = ec.height = Math.floor(innerHeight * DPR);
cv.style.width = ec.style.width = innerWidth + 'px';
cv.style.height = ec.style.height = innerHeight + 'px';
// Logo radius is 120px CSS, keep-out radius is 155px CSS
// STRICT KEEP-OUT ZONE around center (W/2, H/2)
const keepoutR = 155 * DPR;
keepoutRadiusSq = keepoutR * keepoutR;
}
resize();
window.addEventListener('resize', resize);
// ── 3. High-Contrast Jewel / Neon Palettes ───────────────────────────────
let SPARK_COLORS, CONFETTI_COLORS;
function rebuildColors() {
if (isLight) {
// Saturated jewel tones that POP cleanly on white cards without looking pale
SPARK_COLORS = [
'#0c6060', // Cyan AI / Deep Teal
'#e11d48', // Vivid Crimson
'#2563eb', // Royal Blue
'#d97706', // Amber Gold
'#7c3aed', // Purple
'#059669', // Emerald
'#dc2626', // Coral Fire
];
CONFETTI_COLORS = [
'#0c6060', '#e11d48', '#2563eb', '#d97706',
'#7c3aed', '#059669', '#0284c7'
];
} else {
// Radiant gold & neon that glow brilliantly against dark background
SPARK_COLORS = [
'#fde047', // Brilliant Gold
'#f59e0b', // Warm Gold
'#00F0FF', // Cyan AI Neon
'#38bdf8', // Sky Blue
'#f43f5e', // Neon Rose
'#a855f7', // Purple Neon
'#34d399', // Mint
'#ffffff', // Pure White Flash
];
CONFETTI_COLORS = [
'#fde047', '#00F0FF', '#f43f5e', '#a855f7',
'#34d399', '#ffffff'
];
}
}
rebuildColors();
// ── 4. Particle Systems & STRICT LOGO KEEP-OUT ─────────────────────────
let particles = [];
let emojiParticles = [];
let shockwaves = [];
let running = false;
let emojiRunning = false;
let rafFW = null;
let rafEmoji = null;
let fwTimers = [];
let mode = 'fireworks'; // 'fireworks' | 'faces'
// Grand Pyrotechnic Shell Burst — FIRED STRICTLY ON FLANKS (Left & Right)
// NEVER ON TOP OF THE LOGO!
function flankBurst(isRightSide, count) {
// Left burst: x = 10% to 18%, y = 30% to 52%
// Right burst: x = 82% to 90%, y = 30% to 52%
const xr = isRightSide ? (0.81 + Math.random() * 0.09) : (0.10 + Math.random() * 0.09);
const yr = 0.32 + Math.random() * 0.22;
const x = xr * W;
const y = yr * H;
const n = count | 0;
// Outer flank shockwave
shockwaves.push({
x, y,
r: 6 * DPR,
maxR: (34 + Math.random() * 20) * DPR,
vr: 2.2 * DPR,
life: 1,
col: isLight ? 'rgba(12, 96, 96, 0.40)' : 'rgba(253, 224, 71, 0.55)'
});
for (let i = 0; i < n; i++) {
// Angle: bias outward away from the center logo!
let a;
if (isRightSide) {
// Explode mostly up, right, and down-right (away from center)
a = (Math.random() - 0.25) * Math.PI * 1.5;
} else {
// Explode mostly up, left, and down-left (away from center)
a = Math.PI + (Math.random() - 0.5) * Math.PI * 1.5;
}
const sp = (2.2 + Math.random() * 4.6) * DPR;
const color = SPARK_COLORS[(Math.random() * SPARK_COLORS.length) | 0];
particles.push({
t: 'spark',
x, y,
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp - 1.1 * DPR,
life: 1,
decay: 0.013 + Math.random() * 0.014,
col: color,
size: (1.8 + Math.random() * 2.0) * DPR,
isCrackle: Math.random() < 0.2
});
}
}
// Spinning Confetti Ribbons — FALL ONLY ON LEFT AND RIGHT FLANKS
function spawnFlankConfetti(count) {
for (let i = 0; i < count; i++) {
const isRight = Math.random() < 0.5;
// Left corridor: 4% to 24%; Right corridor: 76% to 96%
const xr = isRight ? (0.76 + Math.random() * 0.20) : (0.04 + Math.random() * 0.20);
particles.push({
t: 'conf',
x: xr * W,
y: -25 * DPR,
vx: (isRight ? 1 : -1) * (0.2 + Math.random() * 1.6) * DPR,
vy: (1.6 + Math.random() * 2.4) * DPR,
rot: Math.random() * Math.PI * 2,
vr: (-0.14 + Math.random() * 0.28),
life: 1,
w: (4.5 + Math.random() * 4) * DPR,
h: (8 + Math.random() * 6) * DPR,
col: CONFETTI_COLORS[(Math.random() * CONFETTI_COLORS.length) | 0]
});
}
}
// ── 5. Emojis Raining System — STRICTLY ON FLANKS ────────────────────────
const CELEBRATE_EMOJIS = ['✨', '🎉', '🎊', '🔥', '⚡', '🌟', '💥', '🚀', '🏆', '💫'];
const CRYING_EMOJIS = ['😭', '😢', '💧', '🥺', '💔', '🌧️', '😿'];
function spawnFlankEmojiRain(count, isSad) {
const list = isSad ? CRYING_EMOJIS : CELEBRATE_EMOJIS;
for (let i = 0; i < count; i++) {
const isRight = Math.random() < 0.5;
// Spawn strictly in side corridors: 4% to 24% or 76% to 96%
const xr = isRight ? (0.76 + Math.random() * 0.20) : (0.04 + Math.random() * 0.20);
emojiParticles.push({
x: xr * W,
y: -35 * DPR,
vx: (isRight ? 0.3 : -0.3) + (Math.random() - 0.5) * 0.8 * DPR,
vy: (3.0 + Math.random() * 4.4) * DPR,
rot: (-20 + Math.random() * 40) * Math.PI / 180,
vr: (-0.02 + Math.random() * 0.04),
life: 1,
decay: 0.0035 + Math.random() * 0.0035,
size: (18 + Math.random() * 12) * DPR,
emoji: list[(Math.random() * list.length) | 0],
swayPhase: Math.random() * Math.PI * 2,
swaySpeed: 0.028 + Math.random() * 0.025
});
}
}
// ── 6. Sad Cartoon Faces (Flank Positioned, Outside Logo) ────────────────
function drawSadFace(p) {
const r = p.r;
const s = p.s;
const alpha = Math.min(1, p.fi);
const yb = Math.sin(p.phase) * 3 * DPR + s * 3 * DPR;
ctx.save();
ctx.translate(p.x, p.y + yb);
ctx.globalAlpha = alpha;
const line = Math.max(2, r * 0.08);
const stroke = isLight ? '#0f172a' : 'rgba(186, 210, 245, 0.95)';
const fill = isLight ? '#f1f5f9' : 'rgba(148, 163, 184, 0.18)';
// Face Circle
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.fillStyle = fill;
ctx.fill();
ctx.lineWidth = line;
ctx.strokeStyle = stroke;
ctx.stroke();
// Eyebrows (drooping down)
const ex = r * 0.42, ey = -r * 0.12, er = r * 0.11;
const bl = r * 0.32, tilt = (0.2 + s * 0.5);
ctx.lineWidth = Math.max(2, r * 0.085);
ctx.strokeStyle = stroke;
ctx.lineCap = 'round';
// Left brow
ctx.beginPath();
ctx.moveTo(-ex - bl * 0.5, ey - r * 0.32 + bl * tilt);
ctx.lineTo(-ex + bl * 0.5, ey - r * 0.32 - bl * tilt * 0.6);
ctx.stroke();
// Right brow
ctx.beginPath();
ctx.moveTo(ex - bl * 0.5, ey - r * 0.32 - bl * tilt * 0.6);
ctx.lineTo(ex + bl * 0.5, ey - r * 0.32 + bl * tilt);
ctx.stroke();
// Eyes
ctx.fillStyle = stroke;
ctx.beginPath();
ctx.arc(-ex, ey, er, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(ex, ey, er, 0, Math.PI * 2);
ctx.fill();
// Downward sad mouth
const mw = r * 0.44;
const my = r * 0.52;
const dip = r * (0.12 + s * 0.55);
ctx.beginPath();
ctx.moveTo(-mw, my);
ctx.quadraticCurveTo(0, my - dip, mw, my);
ctx.lineWidth = line;
ctx.strokeStyle = stroke;
ctx.stroke();
// Animated Tear Drops
const drop1 = (p.tp % 1);
const drop2 = ((p.tp + 0.5) % 1);
const tearColor = isLight ? '#0284c7' : '#38bdf8';
const cheeks = [-ex, ex];
for (const cx of cheeks) {
[drop1, drop2].forEach((drop) => {
const ty = ey + er + drop * r * 1.05;
const tr = r * 0.09 * (1 - drop * 0.3);
ctx.save();
ctx.globalAlpha = alpha * (1 - drop * 0.5);
ctx.fillStyle = tearColor;
ctx.beginPath();
ctx.arc(cx, ty, tr, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
});
}
ctx.restore();
}
// ── 7. Animation Loop (Fireworks with STRICT LOGO KEEP-OUT) ──────────────
function tick() {
ctx.clearRect(0, 0, W, H);
const cx = W / 2;
const cy = H / 2;
// Shockwaves
for (let i = shockwaves.length - 1; i >= 0; i--) {
const sw = shockwaves[i];
sw.r += sw.vr;
sw.life -= 0.035;
if (sw.life <= 0) {
shockwaves.splice(i, 1);
continue;
}
ctx.save();
ctx.globalAlpha = Math.max(0, sw.life * 0.55);
ctx.lineWidth = 2 * DPR;
ctx.strokeStyle = sw.col;
ctx.beginPath();
ctx.arc(sw.x, sw.y, sw.r, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
// Particles (sparks, confetti, sad faces)
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
if (p.t === 'spark') {
p.vx *= 0.982;
p.vy = p.vy * 0.982 + 0.06 * DPR;
p.x += p.vx;
p.y += p.vy;
p.life -= p.decay;
if (p.life <= 0) {
particles.splice(i, 1);
continue;
}
// STRICT LOGO EXCLUSION ZONE: Never draw over the central logo!
const dx = p.x - cx;
const dy = p.y - cy;
if (dx * dx + dy * dy < keepoutRadiusSq) {
continue;
}
ctx.save();
ctx.globalAlpha = Math.max(0, p.life);
// Photorealistic Spark Velocity Trail
ctx.strokeStyle = p.col;
ctx.lineWidth = Math.max(1, p.size * p.life * 0.75);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x - p.vx * 2.8, p.y - p.vy * 2.8);
ctx.stroke();
// Glowing Spark Head
ctx.fillStyle = p.col;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * p.life * 0.9, 0, Math.PI * 2);
ctx.fill();
// Subtle crackle flash
if (p.isCrackle && Math.random() < 0.25) {
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * 1.3, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
} else if (p.t === 'sadface') {
p.fi += 0.035;
p.phase += 0.035;
p.tp += 0.015;
drawSadFace(p);
} else if (p.t === 'conf') {
p.vy += 0.045 * DPR;
p.x += p.vx;
p.y += p.vy;
p.rot += p.vr;
p.life -= 0.0055;
if (p.y > H + 25 || p.life <= 0) {
particles.splice(i, 1);
continue;
}
// STRICT LOGO EXCLUSION ZONE: Never draw confetti over central logo!
const dx = p.x - cx;
const dy = p.y - cy;
if (dx * dx + dy * dy < keepoutRadiusSq) {
continue;
}
ctx.save();
ctx.globalAlpha = Math.max(0, Math.min(1, p.life));
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.fillStyle = p.col;
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
ctx.restore();
}
}
if (running || particles.length || shockwaves.length) {
rafFW = requestAnimationFrame(tick);
} else {
ctx.clearRect(0, 0, W, H);
rafFW = null;
}
}
// ── 8. Animation Loop (Emojis Canvas with STRICT LOGO KEEP-OUT) ───────────
function emojiTick() {
ectx.clearRect(0, 0, W, H);
const cx = W / 2;
const cy = H / 2;
for (let i = emojiParticles.length - 1; i >= 0; i--) {
const p = emojiParticles[i];
p.swayPhase += p.swaySpeed;
p.x += p.vx + Math.sin(p.swayPhase) * 0.6 * DPR;
p.y += p.vy;
p.rot += p.vr;
p.life -= p.decay;
if (p.y > H + 40 || p.life <= 0) {
emojiParticles.splice(i, 1);
continue;
}
// STRICT LOGO EXCLUSION ZONE: Emojis NEVER overlap the central badge!
const dx = p.x - cx;
const dy = p.y - cy;
if (dx * dx + dy * dy < keepoutRadiusSq) {
continue;
}
ectx.save();
ectx.globalAlpha = Math.min(1, Math.max(0, p.life));
ectx.translate(p.x, p.y);
ectx.rotate(p.rot);
ectx.font = `${Math.round(p.size)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
ectx.textAlign = 'center';
ectx.textBaseline = 'middle';
ectx.fillText(p.emoji, 0, 0);
ectx.restore();
}
if (emojiRunning || emojiParticles.length) {
rafEmoji = requestAnimationFrame(emojiTick);
} else {
ectx.clearRect(0, 0, W, H);
rafEmoji = null;
}
}
// ── 9. Stop & Cleanup ───────────────────────────────────────────────────
function stopAll() {
fwTimers.forEach(clearTimeout);
fwTimers = [];
running = false;
emojiRunning = false;
particles = [];
emojiParticles = [];
shockwaves = [];
if (W && H) {
ctx.clearRect(0, 0, W, H);
ectx.clearRect(0, 0, W, H);
}
}
// ── 10. Celebrate (Side Flank Pyrotechnics & Flank Emojis) ───────────────
function celebrate(intensity) {
stopAll();
mode = 'fireworks';
running = true;
if (!rafFW) rafFW = requestAnimationFrame(tick);
emojiRunning = true;
if (!rafEmoji) rafEmoji = requestAnimationFrame(emojiTick);
const rounds = Math.round(5 + intensity * 8);
const gap = Math.round(340 - intensity * 130);
const base = Math.round(30 + intensity * 36);
for (let k = 0; k < rounds; k++) {
const d = k * gap;
const isRight = (k % 2 === 1);
// Alternating left and right flank grand fireworks
fwTimers.push(setTimeout(() => flankBurst(false, base), d));
fwTimers.push(setTimeout(() => flankBurst(true, base), d + 90));
// Flank confetti and celebratory emojis (strictly on left/right wings)
fwTimers.push(setTimeout(() => spawnFlankConfetti(3 + Math.round(intensity * 4)), d + 40));
fwTimers.push(setTimeout(() => spawnFlankEmojiRain(2 + Math.round(intensity * 3), false), d + 80));
}
fwTimers.push(setTimeout(() => {
running = false;
emojiRunning = false;
}, rounds * gap + 1200));
}
// ── 11. Crying Sad Mood (Streak <= 2: 0, 1, or 2 Days) ───────────────────
function sadFaces(sadness) {
stopAll();
mode = 'faces';
running = true;
if (!rafFW) rafFW = requestAnimationFrame(tick);
emojiRunning = true;
if (!rafEmoji) rafEmoji = requestAnimationFrame(emojiTick);
const r = Math.min(W, H) * 0.09 + sadness * Math.min(W, H) * 0.02;
const y = H * 0.42;
// Two weeping cartoon faces on the side wings (completely clear of badge)
particles.push({ t: 'sadface', x: W * 0.14, y, r, s: sadness, phase: 0, tp: 0.1, fi: 0, life: 1 });
particles.push({ t: 'sadface', x: W * 0.86, y, r, s: sadness, phase: 1.6, tp: 0.6, fi: 0, life: 1 });
// Ongoing waves of crying emojis down the side corridors
for (let k = 0; k < 8; k++) {
fwTimers.push(setTimeout(() => spawnFlankEmojiRain(3 + Math.round(sadness * 3), true), k * 650 + 400));
}
}
// ── 12. Orchestrate Play (1-Day & 2-Day Fragile Streak Logic) ─────────────
const stage = document.getElementById('stage');
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
let moodTimer = null;
function parseStreak() {
const params = new URLSearchParams(window.location.search);
const s = params.get('streak');
const v = parseFloat(s);
return isFinite(v) ? v : 0;
}
function parseIsSad(streakVal) {
const params = new URLSearchParams(window.location.search);
if (params.get('mood') === 'sad') return true;
if (params.get('mood') === 'happy') return false;
// Streak <= 2 (0 days, 1 day, 2 days) is the fragile streak -> crying/weeping mood!
// Streak >= 3 (3+ days) -> triumphant celebration!
return streakVal <= 2;
}
function play() {
clearTimeout(moodTimer);
stopAll();
const streak = parseStreak();
const isSad = parseIsSad(streak);
const intensity = isSad ? 0 : Math.min(1, Math.log10(Math.max(streak, 3) / 2) / 2.5);
stage.classList.remove('play', 'happy', 'sad', 'tier1', 'tier2', 'tier3');
document.body.classList.remove('sad');
if (isSad) {
stage.classList.add('sad');
document.body.classList.add('sad');
} else {
stage.classList.add('happy');
stage.classList.add(intensity < 0.34 ? 'tier1' : intensity < 0.7 ? 'tier2' : 'tier3');
stage.style.setProperty('--grow', (1.12 + intensity * 0.16).toFixed(3));
stage.style.setProperty('--halo-w', (160 + intensity * 70).toFixed(0) + '%');
}
void stage.offsetWidth;
stage.classList.add('play');
const sadness = isSad ? Math.max(0.4, (3 - streak) / 3) : 0;
if (reduce.matches) {
if (isSad) setTimeout(() => sadFaces(sadness), 0);
return;
}
// Settle after 3D coin spin (~2400ms)
moodTimer = setTimeout(() => {
if (isSad) {
sadFaces(sadness);
} else {
celebrate(intensity);
}
}, 2450);
}
// Click badge/stage to replay celebration anytime
stage.addEventListener('click', () => {
play();
});
// Keyboard shortcut: Space / R to replay, S to toggle sad/happy for testing
window.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'r' || e.key === 'R') {
play();
} else if (e.key === 's' || e.key === 'S') {
const p = new URLSearchParams(window.location.search);
const currentMood = p.get('mood');
p.set('mood', currentMood === 'sad' ? 'happy' : 'sad');
window.history.replaceState(null, '', '?' + p.toString());
play();
}
});
// Auto-play on load
window.addEventListener('load', () => setTimeout(play, 150));
})();
</script>
</body>
</html>