879 lines
27 KiB
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: (6.0 + Math.random() * 8.8) * DPR, // 2x faster
|
|
rot: (-20 + Math.random() * 40) * Math.PI / 180,
|
|
vr: (-0.02 + Math.random() * 0.04),
|
|
life: 1,
|
|
decay: 0.007 + Math.random() * 0.007, // 2x faster decay
|
|
size: (18 + Math.random() * 12) * DPR,
|
|
emoji: list[(Math.random() * list.length) | 0],
|
|
swayPhase: Math.random() * Math.PI * 2,
|
|
swaySpeed: 0.056 + Math.random() * 0.050 // 2x faster sway
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── 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(3 + intensity * 4); // Half the rounds
|
|
const gap = Math.round(200 - intensity * 80); // Faster gaps
|
|
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 + 60));
|
|
|
|
// Flank confetti and celebratory emojis (strictly on left/right wings)
|
|
fwTimers.push(setTimeout(() => spawnFlankConfetti(3 + Math.round(intensity * 4)), d + 20));
|
|
fwTimers.push(setTimeout(() => spawnFlankEmojiRain(2 + Math.round(intensity * 3), false), d + 40));
|
|
}
|
|
|
|
fwTimers.push(setTimeout(() => {
|
|
running = false;
|
|
emojiRunning = false;
|
|
}, rounds * gap + 500)); // Finish quickly after last burst
|
|
}
|
|
|
|
// ── 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> |