Files
personal-site/static/scripts/posteranimation.js
T
2026-05-25 16:22:05 +02:00

743 lines
24 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.
function cureClick() {
if (document.getElementById('cure-canvas')) return;
if (document.getElementById('lightning-canvas')) stopLightning();
if (document.getElementById('typewriter-canvas')) stopTypewriter();
if (document.getElementById('matrix-canvas')) stopMatrix();
if (document.getElementById('rain-canvas')) stopRain();
const el = document.getElementById('interest');
el.textContent = "Actually... I'm feeling much better now!"
startWater();
isDay = false;
applyState();
setTimeout(() => {
el.textContent = 'interest';
stopWater();
}, 10000);
}
let cureCanvas, cureCtx, cureAnimFrame;
function startWater() {
cureCanvas = document.createElement('canvas');
cureCanvas.id = 'cure-canvas';
cureCanvas.width = window.innerWidth;
cureCanvas.height = window.innerHeight;
Object.assign(cureCanvas.style, {
position: 'fixed',
top: 0,
left: 0,
pointerEvents: 'none',
zIndex: -1, // behind everything
opacity: '0',
transition: 'opacity 1.4s ease'
});
document.body.appendChild(cureCanvas);
cureCtx = cureCanvas.getContext('2d');
requestAnimationFrame(() => { cureCanvas.style.opacity = '1'; });
const W = cureCanvas.width;
const H = cureCanvas.height;
// waterline sits at 38% from the top
const waterlineY = H * 0.38;
// floating particles below the surface
const particles = Array.from({ length: 55 }, () => ({
x: Math.random() * W,
y: waterlineY + 20 + Math.random() * (H - waterlineY - 20),
r: 1 + Math.random() * 2.5,
speedX: (Math.random() - 0.5) * 0.3,
speedY: -0.08 - Math.random() * 0.18, // slow upward drift
opacity: 0.08 + Math.random() * 0.18
}));
function drawFrame(ts) {
cureCtx.clearRect(0, 0, W, H);
// ── sky above waterline (very subtle, mostly transparent) ──────────
const sky = cureCtx.createLinearGradient(0, 0, 0, waterlineY);
sky.addColorStop(0, 'rgba(190, 230, 255, 0.06)');
sky.addColorStop(1, 'rgba(190, 230, 255, 0.01)');
cureCtx.fillStyle = sky;
cureCtx.fillRect(0, 0, W, waterlineY);
// ── water body below waterline ─────────────────────────────────────
const water = cureCtx.createLinearGradient(0, waterlineY, 0, H);
water.addColorStop(0, 'rgba(80, 190, 220, 0.28)');
water.addColorStop(0.35, 'rgba(40, 140, 190, 0.22)');
water.addColorStop(0.7, 'rgba(20, 90, 150, 0.20)');
water.addColorStop(1, 'rgba(10, 50, 110, 0.22)');
cureCtx.fillStyle = water;
cureCtx.fillRect(0, waterlineY, W, H - waterlineY);
// ── caustic shimmer streaks ────────────────────────────────────────
const numStreaks = 10;
for (let i = 0; i < numStreaks; i++) {
const phase = (ts * 0.0004 + i * 0.63) % 1;
const x = (i / numStreaks) * W + Math.sin(ts * 0.0008 + i) * 60;
const yTop = waterlineY + 10 + Math.sin(ts * 0.001 + i * 1.3) * 18;
const yBot = yTop + 40 + Math.sin(ts * 0.0007 + i * 0.9) * 30;
const streak = cureCtx.createLinearGradient(x, yTop, x + 6, yBot);
streak.addColorStop(0, 'rgba(200, 245, 255, 0)');
streak.addColorStop(0.4, `rgba(200, 245, 255, ${0.05 + 0.06 * Math.sin(ts * 0.002 + i)})`);
streak.addColorStop(1, 'rgba(200, 245, 255, 0)');
cureCtx.fillStyle = streak;
cureCtx.fillRect(x, yTop, 5, yBot - yTop);
}
// ── floating particles ─────────────────────────────────────────────
particles.forEach(p => {
p.x += p.speedX;
p.y += p.speedY;
// reset when reaching the surface
if (p.y < waterlineY + 5) {
p.y = H - 10;
p.x = Math.random() * W;
}
if (p.x < 0) p.x = W;
if (p.x > W) p.x = 0;
const depth = (p.y - waterlineY) / (H - waterlineY); // 0 = surface, 1 = bottom
cureCtx.beginPath();
cureCtx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
cureCtx.fillStyle = `rgba(180, 240, 255, ${p.opacity * (0.4 + 0.6 * (1 - depth))})`;
cureCtx.fill();
});
// ── animated surface waves (2 layers, different speeds & amplitudes) ─
function drawWaveLine(amp, freq, speed, lineW, alpha) {
cureCtx.beginPath();
for (let x = 0; x <= W; x += 3) {
const y = waterlineY
+ Math.sin(x * freq + ts * speed) * amp
+ Math.sin(x * freq * 1.7 + ts * speed * 0.6) * (amp * 0.4);
x === 0 ? cureCtx.moveTo(x, y) : cureCtx.lineTo(x, y);
}
cureCtx.strokeStyle = `rgba(210, 245, 255, ${alpha})`;
cureCtx.lineWidth = lineW;
cureCtx.stroke();
}
// primary wave
drawWaveLine(7, 0.012, 0.0018, 2.0, 0.70);
// secondary wave (slightly offset phase)
drawWaveLine(4, 0.018, 0.0024, 1.2, 0.40);
// micro ripple
drawWaveLine(2, 0.030, 0.0032, 0.7, 0.25);
// ── foam/highlight on the crest ────────────────────────────────────
cureCtx.beginPath();
for (let x = 0; x <= W; x += 3) {
const y = waterlineY
+ Math.sin(x * 0.012 + ts * 0.0018) * 7
+ Math.sin(x * 0.020 + ts * 0.0024) * 3;
x === 0 ? cureCtx.moveTo(x, y - 1) : cureCtx.lineTo(x, y - 1);
}
cureCtx.strokeStyle = 'rgba(255, 255, 255, 0.18)';
cureCtx.lineWidth = 1;
cureCtx.stroke();
if (cureCanvas) cureAnimFrame = requestAnimationFrame(drawFrame);
}
cureAnimFrame = requestAnimationFrame(drawFrame);
}
function stopWater() {
if (!cureCanvas) return;
cureCanvas.style.opacity = '0';
cancelAnimationFrame(cureAnimFrame);
setTimeout(() => {
cureCanvas.remove();
cureCanvas = null;
}, 1400);
}
function draculaClick() {
if (document.getElementById('lightning-canvas')) return;
if (document.getElementById('cure-canvas')) stopWater();
if (document.getElementById('typewriter-canvas')) stopTypewriter();
if (document.getElementById('matrix-canvas')) stopMatrix();
if (document.getElementById('rain-canvas')) stopRain();
const el = document.getElementById('interest');
el.textContent = 'You have your whole life ahead of you. And I only offer death.';
startLightning();
isDay = false;
applyState();
setTimeout(() => {
el.textContent = 'interest';
stopLightning();
}, 10000);
}
let lightningCanvas, lightningCtx, lightningInterval, lightningFlashTimeout;
function startLightning() {
lightningCanvas = document.createElement('canvas');
lightningCanvas.id = 'lightning-canvas';
lightningCanvas.width = window.innerWidth;
lightningCanvas.height = window.innerHeight;
Object.assign(lightningCanvas.style, {
position: 'fixed', top: 0, left: 0,
pointerEvents: 'none', zIndex: 9999,
opacity: '0', transition: 'opacity 0.3s ease'
});
document.body.appendChild(lightningCanvas);
lightningCtx = lightningCanvas.getContext('2d');
requestAnimationFrame(() => { lightningCanvas.style.opacity = '1'; });
function drawBolt(x1, y1, x2, y2, roughness, depth) {
if (depth === 0) {
lightningCtx.beginPath();
lightningCtx.moveTo(x1, y1);
lightningCtx.lineTo(x2, y2);
lightningCtx.stroke();
return;
}
const mx = (x1 + x2) / 2 + (Math.random() - 0.5) * roughness;
const my = (y1 + y2) / 2 + (Math.random() - 0.5) * roughness;
drawBolt(x1, y1, mx, my, roughness / 2, depth - 1);
drawBolt(mx, my, x2, y2, roughness / 2, depth - 1);
// random fork branch
if (depth > 2 && Math.random() < 0.4) {
const fx = mx + (Math.random() - 0.3) * roughness * 2;
const fy = my + Math.random() * roughness * 2;
lightningCtx.save();
lightningCtx.globalAlpha *= 0.5;
drawBolt(mx, my, fx, fy, roughness / 2, depth - 2);
lightningCtx.restore();
}
}
function flashStrike() {
lightningCtx.clearRect(0, 0, lightningCanvas.width, lightningCanvas.height);
const numBolts = 1 + Math.floor(Math.random() * 3);
for (let i = 0; i < numBolts; i++) {
const startX = lightningCanvas.width * (0.1 + Math.random() * 0.8);
const endX = startX + (Math.random() - 0.5) * 300;
const color = Math.random() < 0.6
? `rgba(220, 180, 255, ${0.6 + Math.random() * 0.4})` // purple
: `rgba(255, 255, 255, ${0.7 + Math.random() * 0.3})`; // white
lightningCtx.strokeStyle = color;
lightningCtx.lineWidth = 0.5 + Math.random() * 2;
lightningCtx.shadowColor = color;
lightningCtx.shadowBlur = 18 + Math.random() * 30;
lightningCtx.globalAlpha = 1;
drawBolt(startX, 0, endX, lightningCanvas.height * (0.4 + Math.random() * 0.5), 120, 7);
}
// ambient flash on the body
document.body.style.transition = 'background 0.05s';
document.body.style.background = `rgba(80, 0, 120, 0.08)`;
setTimeout(() => { document.body.style.background = ''; }, 80);
// fade the bolt out
lightningFlashTimeout = setTimeout(() => {
lightningCtx.clearRect(0, 0, lightningCanvas.width, lightningCanvas.height);
}, 80 + Math.random() * 120);
}
// irregular strike cadence — like real lightning
function scheduleStrike() {
flashStrike();
const delay = 600 + Math.random() * 2200;
lightningInterval = setTimeout(scheduleStrike, delay);
}
scheduleStrike();
}
function stopLightning() {
if (!lightningCanvas) return;
lightningCanvas.style.opacity = '0';
clearTimeout(lightningInterval);
clearTimeout(lightningFlashTimeout);
setTimeout(() => {
lightningCanvas.remove();
lightningCanvas = null;
}, 800);
}
function dragonTypewriterClick() {
if (document.getElementById('typewriter-canvas')) return;
if (document.getElementById('lightning-canvas')) stopLightning();
if (document.getElementById('cure-canvas')) stopWater();
if (document.getElementById('matrix-canvas')) stopMatrix();
if (document.getElementById('rain-canvas')) stopRain();
const el = document.getElementById('interest');
el.textContent = 'case file 36609...';
startTypewriter();
isDay = false;
applyState();
setTimeout(() => {
el.textContent = 'interest';
stopTypewriter();
}, 10000);
}
let twCanvas, twCtx, twAnimFrame;
function startTypewriter() {
twCanvas = document.createElement('canvas');
twCanvas.id = 'typewriter-canvas';
twCanvas.width = window.innerWidth;
twCanvas.height = window.innerHeight;
Object.assign(twCanvas.style, {
position: 'fixed',
top: 0,
left: 0,
pointerEvents: 'none',
zIndex: -1,
opacity: '0',
transition: 'opacity 1.2s ease'
});
document.body.appendChild(twCanvas);
twCtx = twCanvas.getContext('2d');
requestAnimationFrame(() => { twCanvas.style.opacity = '1'; });
const W = twCanvas.width;
const H = twCanvas.height;
// ── case file fragments ──────────────────────────────────────────────
const fragments = [
'SUBJECT: HARRIET VANGER',
'CASE NO. 36609-74',
'CLASSIFICATION: MISSING / PRESUMED',
'████████████ DECEASED ████████████',
'LAST SEEN: HEDEBY ISLAND',
'NOVEMBER 1, 1966',
'INVESTIGATING OFFICER: ██████████',
'FINANCIAL IRREGULARITIES NOTED',
'SEE ATTACHMENT ██-C',
'WENNERSTROM GROUP — CLASSIFIED',
'DO NOT DISTRIBUTE',
'SOURCE: CONFIDENTIAL',
'SALANDER, L. — AUTHORIZED',
'BLOMKVIST, M. — AUTHORIZED',
'ALL OTHERS: ACCESS DENIED',
'████████████████████████████',
'MOTIVE: UNKNOWN',
'SUSPECT LIST: REDACTED',
'EVIDENCE SEALED BY ORDER OF',
'████████ COURT ████████',
'TRAUMA INDICATED / SEE REPORT 7',
'LISBETH — CLEARANCE LEVEL 3',
'THE DRAGON TATTOO',
'——————————————————————',
'WHERE IS SHE.',
'WHO KNOWS.',
'FILE CLOSED: ██/██/████',
];
// glitch chars pool
const glitchChars = '!@#$%&*?|\\/<>[]{}~^░▒▓█▄▀■□▪▫';
function randGlitch() {
return glitchChars[Math.floor(Math.random() * glitchChars.length)];
}
// ── active lines on screen ───────────────────────────────────────────
const lines = [];
let fragmentPool = [...fragments];
function pickFragment() {
if (!fragmentPool.length) fragmentPool = [...fragments];
const i = Math.floor(Math.random() * fragmentPool.length);
return fragmentPool.splice(i, 1)[0];
}
function spawnLine() {
const text = pickFragment();
const fontSize = 11 + Math.floor(Math.random() * 9);
const x = 40 + Math.random() * (W * 0.55);
const y = 60 + Math.random() * (H - 120);
const isRedact = text.includes('█') || Math.random() < 0.12;
const speed = 28 + Math.random() * 22; // chars per second
lines.push({
text,
x, y,
fontSize,
typed: 0, // chars revealed so far (float)
speed,
glitching: false,
glitchTimer: 0,
glitchStr: '',
opacity: 0.72 + Math.random() * 0.26,
isRedact,
done: false,
holdTimer: 0,
fadeOut: false,
fadeAlpha: 1,
stamp: Math.random() < 0.08, // rare CLASSIFIED/DENIED stamp
});
}
// spawn cadence
spawnLine();
const spawnTimer = setInterval(() => {
if (lines.length < 14) spawnLine();
}, 600);
twCanvas._spawnTimer = spawnTimer;
let lastTs = null;
function drawFrame(ts) {
if (!lastTs) lastTs = ts;
const dt = (ts - lastTs) / 1000;
lastTs = ts;
twCtx.clearRect(0, 0, W, H);
// faint scanline overlay — gives it that CRT/photocopy feel
for (let sy = 0; sy < H; sy += 3) {
twCtx.fillStyle = 'rgba(0,0,0,0.04)';
twCtx.fillRect(0, sy, W, 1);
}
lines.forEach((line, idx) => {
twCtx.font = `${line.fontSize}px "Courier New", Courier, monospace`;
if (line.fadeOut) {
line.fadeAlpha -= dt * 0.6;
if (line.fadeAlpha <= 0) {
lines.splice(idx, 1);
return;
}
}
const alpha = line.opacity * (line.fadeOut ? line.fadeAlpha : 1);
if (!line.done) {
line.typed += line.speed * dt;
if (line.typed >= line.text.length) {
line.typed = line.text.length;
line.done = true;
}
// random mid-type glitch burst
if (!line.glitching && Math.random() < 0.004) {
line.glitching = true;
line.glitchTimer = 0.18 + Math.random() * 0.22;
line.glitchStr = Array.from(
{ length: 3 + Math.floor(Math.random() * 5) },
randGlitch
).join('');
}
} else {
// after typing finishes, hold then fade
line.holdTimer += dt;
if (line.holdTimer > 3.5 + Math.random() * 3) {
line.fadeOut = true;
// spawn a replacement
setTimeout(spawnLine, 200 + Math.random() * 600);
}
// occasional re-glitch on finished lines
if (!line.glitching && Math.random() < 0.003) {
line.glitching = true;
line.glitchTimer = 0.1 + Math.random() * 0.15;
line.glitchStr = Array.from(
{ length: 2 + Math.floor(Math.random() * 4) },
randGlitch
).join('');
}
}
// resolve glitch burst
if (line.glitching) {
line.glitchTimer -= dt;
if (line.glitchTimer <= 0) {
line.glitching = false;
line.glitchStr = '';
}
}
// ── build visible string ───────────────────────────────────────
const visible = line.text.slice(0, Math.floor(line.typed));
let display = visible;
// blinking cursor at the type head
if (!line.done) {
const blink = Math.floor(ts / 530) % 2 === 0;
display += blink ? '▌' : ' ';
}
// append glitch burst after cursor
if (line.glitching) display += line.glitchStr;
// ── color choice ───────────────────────────────────────────────
// redacted blocks stay white-ish; normal text is the faded amber/grey
const isRedactLine = line.text.includes('█');
if (isRedactLine) {
twCtx.fillStyle = `rgba(220, 215, 200, ${alpha})`;
} else if (line.stamp) {
twCtx.fillStyle = `rgba(180, 30, 30, ${alpha * 0.85})`;
} else {
// slight warm/cool variation per line
const warm = Math.random() < 0.5;
twCtx.fillStyle = warm
? `rgba(210, 200, 180, ${alpha})`
: `rgba(185, 200, 195, ${alpha})`;
}
twCtx.fillText(display, line.x, line.y);
// ── underline for "stamp" lines ────────────────────────────────
if (line.stamp && line.done) {
const mw = twCtx.measureText(line.text).width;
twCtx.strokeStyle = `rgba(180, 30, 30, ${alpha * 0.6})`;
twCtx.lineWidth = 1;
twCtx.beginPath();
twCtx.moveTo(line.x, line.y + 3);
twCtx.lineTo(line.x + mw, line.y + 3);
twCtx.stroke();
}
});
if (twCanvas) twAnimFrame = requestAnimationFrame(drawFrame);
}
twAnimFrame = requestAnimationFrame(drawFrame);
}
function stopTypewriter() {
if (!twCanvas) return;
twCanvas.style.opacity = '0';
clearInterval(twCanvas._spawnTimer);
cancelAnimationFrame(twAnimFrame);
setTimeout(() => {
twCanvas.remove();
twCanvas = null;
}, 1200);
}
function matrixClick() {
if (document.getElementById('matrix-canvas')) return;
if (document.getElementById('cure-canvas')) stopWater();
if (document.getElementById('lightning-canvas')) stopLightning();
if (document.getElementById('typewriter-canvas')) stopTypewriter();
if (document.getElementById('rain-canvas')) stopRain();
const el = document.getElementById('interest');
el.textContent = 'wake up, neo...';
startMatrix();
isDay = false;
applyState();
setTimeout(() => {
el.textContent = 'interest';
stopMatrix();
}, 10000);
}
let matrixCanvas, matrixCtx, matrixAnimFrame, matrixStopping = false;
function startMatrix() {
matrixCanvas = document.createElement('canvas');
matrixCanvas.id = 'matrix-canvas';
matrixCanvas.width = window.innerWidth;
matrixCanvas.height = window.innerHeight;
Object.assign(matrixCanvas.style, {
position: 'fixed',
top: 0,
left: 0,
pointerEvents: 'none',
zIndex: -1,
opacity: '0',
transition: 'opacity 1.2s ease'
});
document.body.appendChild(matrixCanvas);
matrixCtx = matrixCanvas.getContext('2d');
requestAnimationFrame(() => { matrixCanvas.style.opacity = '1'; });
const W = matrixCanvas.width;
const H = matrixCanvas.height;
const FONT_SIZE = 16;
const COLS = Math.floor(W / FONT_SIZE);
// katakana unicode block ァ–ン (U+30A1U+30F6)
const KATA_START = 0x30A1;
const KATA_END = 0x30F6;
function randKata() {
return String.fromCharCode(
KATA_START + Math.floor(Math.random() * (KATA_END - KATA_START + 1))
);
}
// each column: how far down it has dripped (in rows)
// start negative so columns begin off-screen at staggered times
const drops = Array.from({ length: COLS }, () => ({
y: -(Math.random() * 50), // staggered start above top
speed: 0.1 + Math.random() * 0.2,
glyphs: [] // trail of { char, age }
}));
const TRAIL_LEN = 14; // how many glyphs in the tail
function drawFrame() {
// dim the previous frame — this creates the fade trail
matrixCtx.fillStyle = 'rgba(0, 0, 0, 0.10)';
matrixCtx.fillRect(0, 0, W, H);
matrixCtx.font = `${FONT_SIZE}px monospace`;
drops.forEach((drop, col) => {
const x = col * FONT_SIZE;
// advance drop
drop.y += drop.speed;
// push a new glyph at the head
if (drop.y >= 0) {
drop.glyphs.unshift({ char: randKata(), age: 0 });
if (drop.glyphs.length > TRAIL_LEN) drop.glyphs.pop();
}
// age each glyph and draw
drop.glyphs.forEach((g, i) => {
g.age++;
// randomly mutate the character occasionally
if (Math.random() < 0.04) g.char = randKata();
const headY = Math.floor(drop.y) - i;
if (headY < 0 || headY * FONT_SIZE > H) return;
const t = i / TRAIL_LEN; // 0 = head, 1 = tail
if (i === 0) {
// head glyph — bright white flash
matrixCtx.fillStyle = `rgba(200, 255, 220, 0.98)`;
} else if (i < 3) {
// near-head — bright green
matrixCtx.fillStyle = `rgba(100, 255, 140, ${1 - t * 0.3})`;
} else {
// tail — fade to dark green
const alpha = Math.max(0, 1 - t * 1.1);
const green = Math.floor(180 - t * 120);
matrixCtx.fillStyle = `rgba(0, ${green}, 60, ${alpha})`;
}
matrixCtx.fillText(g.char, x, headY * FONT_SIZE);
});
// reset column when it scrolls fully off the bottom
// reset column when it scrolls fully off the bottom
if (drop.y * FONT_SIZE > H + TRAIL_LEN * FONT_SIZE) {
if (matrixStopping) {
// don't recycle — park it off-screen so the trail dies
drop.y = -(TRAIL_LEN * 10);
drop.glyphs = [];
} else {
drop.y = -(Math.random() * 30);
drop.speed = 0.3 + Math.random() * 0.7;
drop.glyphs = [];
}
}
});
if (matrixCanvas) matrixAnimFrame = requestAnimationFrame(drawFrame);
}
matrixAnimFrame = requestAnimationFrame(drawFrame);
}
function stopMatrix() {
if (!matrixCanvas) return;
// signal: no new heads, let existing trails die out
matrixStopping = true;
// after trails have had time to fade, pull the canvas
setTimeout(() => {
cancelAnimationFrame(matrixAnimFrame);
matrixCanvas.style.opacity = '0';
matrixCanvas.style.transition = 'opacity 1.2s ease';
setTimeout(() => {
matrixCanvas.remove();
matrixCanvas = null;
matrixStopping = false;
}, 1200);
}, 1800);
}
function bladerunnerClick() {
if (document.getElementById('rain-canvas')) return; // already running, do nothing
if (document.getElementById('cure-canvas')) stopWater();
if (document.getElementById('matrix-canvas')) stopMatrix();
if (document.getElementById('typewriter-canvas')) stopTypewriter();
if (document.getElementById('lightning-canvas')) stopLightning();
const el = document.getElementById('interest');
el.textContent = 'tears in rain';
startRain();
isDay = false
applyState();
setTimeout(() => {
el.textContent = "interest";
stopRain();
}, 10000);
}
let rainCanvas, rainCtx, rainInterval;
function startRain() {
rainCanvas = document.createElement('canvas');
rainCanvas.id = 'rain-canvas';
rainCanvas.width = window.innerWidth;
rainCanvas.height = window.innerHeight;
document.body.appendChild(rainCanvas);
rainCtx = rainCanvas.getContext('2d');
requestAnimationFrame(() => { rainCanvas.style.opacity = '1'; });
const drops = Array.from({ length: 120 }, () => ({
x: Math.random() * rainCanvas.width,
y: Math.random() * rainCanvas.height,
speed: 8 + Math.random() * 7,
length: 12 + Math.random() * 20,
opacity: 0.2 + Math.random() * 0.4
}));
rainInterval = setInterval(() => {
rainCtx.clearRect(0, 0, rainCanvas.width, rainCanvas.height);
drops.forEach(drop => {
rainCtx.beginPath();
rainCtx.moveTo(drop.x, drop.y);
rainCtx.lineTo(drop.x - 2, drop.y + drop.length);
rainCtx.strokeStyle = `rgba(174, 214, 241, ${drop.opacity})`;
rainCtx.lineWidth = 3;
rainCtx.stroke();
drop.y += drop.speed;
if (drop.y > rainCanvas.height) {
drop.y = -drop.length;
drop.x = Math.random() * rainCanvas.width;
}
});
}, 30);
}
function stopRain() {
if (!rainCanvas) return;
rainCanvas.style.opacity = '0';
setTimeout(() => {
clearInterval(rainInterval);
rainCanvas.remove();
rainCanvas = null;
}, 800);
}