/** * Copyright (c) 2025 misties * SPDX-License-Identifier: AGPL-3.0-or-later */ import { useEffect, useRef } from "preact/hooks"; interface Particle { offsetX: number; offsetY: number; velocityX: number; velocityY: number; } interface Raindrop { x: number; y: number; length: number; speed: number; opacity: number; floorHeight: number; } interface Splash { x: number; y: number; age: number; maxAge: number; particles: Particle[]; } interface Star { x: number; y: number; size: number; brightness: number; twinkleSpeed: number; twinkleOffset: number; age: number; maxAge: number; canVanish: boolean; color: { r: number; g: number; b: number }; colorIndex: number; } const PIXEL_SIZE = 4; const DROP_COUNT = 300; const RAIN_COLOR = { r: 87, g: 97, b: 100 }; const BACKGROUND_COLOUR = "#16191C"; const STAR_COUNT = 150; const RAIN_SPEED = 0.75; const SPLASH_CHANCE = 0.3; const FLOOR_HEIGHT_MIN = 0.55; const FLOOR_HEIGHT_RANGE = 0.95; const PARTICLE_OFFSET_RANGE = 4; const PARTICLE_Y_RANGE = 2; const PARTICLE_Y_MIN = 1; const PARTICLE_VX_RANGE = 0.8; const PARTICLE_VY_RANGE = 0.5; const PARTICLE_VY_MIN = 0.3; const PARTICLE_GRAVITY = 0.15; const STAR_BRIGHTNESS_MIN = 0.6; const STAR_BRIGHTNESS_RANGE = 0.4; const STAR_LARGE_THRESHOLD = 0.7; const STAR_SIZE_SMALL = 4; const STAR_SIZE_LARGE = 8; const STAR_TWINKLE_MIN = 0.002; const STAR_TWINKLE_RANGE = 0.005; const STAR_AGE_MIN = 60; const STAR_AGE_RANGE = 120; const STAR_TWINKLE_AMPLITUDE = 0.3; const STAR_TWINKLE_BASE = 0.7; const STAR_GLOW_THRESHOLD = 0.7; const STAR_GLOW_ALPHA = 0.5; const RAIN_COLORS: string[] = []; const STAR_COLORS: string[] = []; const ALPHA_STAR_COLORS: string[] = []; const NUM_COLOR_VARIATIONS = 20; for (let i = 0; i < NUM_COLOR_VARIATIONS; i++) { const brightness = 1 - (i / NUM_COLOR_VARIATIONS) * 0.5; RAIN_COLORS.push( `rgb(${Math.floor(RAIN_COLOR.r * brightness)},${ Math.floor( RAIN_COLOR.g * brightness, ) },${Math.floor(RAIN_COLOR.b * brightness)})`, ); } for (let i = 0; i < NUM_COLOR_VARIATIONS; i++) { const brightness = STAR_BRIGHTNESS_MIN + (i / NUM_COLOR_VARIATIONS) * STAR_BRIGHTNESS_RANGE; STAR_COLORS.push( `rgb(${Math.floor(RAIN_COLOR.r * brightness)},${ Math.floor( RAIN_COLOR.g * brightness, ) },${Math.floor(RAIN_COLOR.b * brightness)})`, ); ALPHA_STAR_COLORS.push( `rgba(${Math.floor(RAIN_COLOR.r * brightness)},${ Math.floor( RAIN_COLOR.g * brightness, ) },${Math.floor(RAIN_COLOR.b * brightness)},`, ); } const SPLASH_PARTICLE_COLOR = `rgb(${RAIN_COLOR.r * 0.9},${ RAIN_COLOR.g * 0.95 },${RAIN_COLOR.b * 1.1})`; const SPLASH_RIPPLE_COLOR = `rgb(${RAIN_COLOR.r * 0.7},${RAIN_COLOR.g * 0.8},${ RAIN_COLOR.b * 0.95 })`; function createParticle(): Particle { return { offsetX: (Math.random() - 0.5) * PARTICLE_OFFSET_RANGE, offsetY: -(Math.random() * PARTICLE_Y_RANGE + PARTICLE_Y_MIN), velocityX: (Math.random() - 0.5) * PARTICLE_VX_RANGE, velocityY: -(Math.random() * PARTICLE_VY_RANGE + PARTICLE_VY_MIN), }; } function createSplash(x: number, y: number): Splash { return { x, y, age: 0, maxAge: 12, particles: Array.from( { length: Math.floor(Math.random() * 3) + 3 }, createParticle, ), }; } function createRaindrop(gridWidth: number, gridHeight: number): Raindrop { const bias = Math.random() * Math.random(); return { x: Math.floor(Math.random() * gridWidth), y: Math.floor(Math.random() * gridHeight) - gridHeight, length: Math.floor(Math.random() * 4) + 2, speed: RAIN_SPEED * (Math.random() * 2 + 1), opacity: Math.random() * 0.3 + 0.6, floorHeight: window.innerHeight * (FLOOR_HEIGHT_MIN + bias * FLOOR_HEIGHT_RANGE), }; } function createStar(): Star { const brightness = Math.random() * STAR_BRIGHTNESS_RANGE + STAR_BRIGHTNESS_MIN; const colorIndex = Math.floor( ((brightness - STAR_BRIGHTNESS_MIN) / STAR_BRIGHTNESS_RANGE) * (NUM_COLOR_VARIATIONS - 1), ); return { x: Math.random() * window.innerWidth, y: Math.random() * window.innerHeight, size: Math.random() > STAR_LARGE_THRESHOLD ? STAR_SIZE_LARGE : STAR_SIZE_SMALL, twinkleSpeed: Math.random() * STAR_TWINKLE_RANGE + STAR_TWINKLE_MIN, twinkleOffset: Math.random() * Math.PI * 2, brightness, age: 0, maxAge: STAR_AGE_MIN + Math.random() * STAR_AGE_RANGE, canVanish: false, colorIndex, }; } function updateParticle(p: Particle) { p.offsetX += p.velocityX; p.offsetY += p.velocityY; p.velocityY += PARTICLE_GRAVITY; } function updateSplash(splash: Splash) { splash.age++; splash.particles.forEach(updateParticle); } function updateRaindrop( drop: Raindrop, splashes: Splash[], gridWidth: number, gridHeight: number, ) { drop.y += drop.speed; const dropY = drop.y * PIXEL_SIZE; if (dropY >= drop.floorHeight && Math.random() < SPLASH_CHANCE) { splashes.push(createSplash(drop.x, Math.floor(dropY / PIXEL_SIZE))); Object.assign(drop, createRaindrop(gridWidth, gridHeight)); } else if (dropY > window.innerHeight) { Object.assign(drop, createRaindrop(gridWidth, gridHeight)); } } function updateStar(star: Star): void { star.age++; const phase = (star.age * star.twinkleSpeed + star.twinkleOffset) % (2 * Math.PI); if (!star.canVanish && phase < star.twinkleSpeed) { star.canVanish = true; } if (star.canVanish && star.age > star.maxAge) { Object.assign(star, createStar()); } else if (star.x > window.innerWidth || star.y > window.innerHeight) { Object.assign(star, createStar()); } } const isSplashDead = (splash: Splash): boolean => splash.age >= splash.maxAge; const drawRaindrop = (ctx: CanvasRenderingContext2D, drop: Raindrop): void => { ctx.globalAlpha = drop.opacity; const xPos = drop.x * PIXEL_SIZE; for (let i = 0; i < drop.length; i++) { const colorIndex = Math.floor( (i / drop.length) * NUM_COLOR_VARIATIONS * 0.5, ); ctx.fillStyle = RAIN_COLORS[colorIndex]; ctx.fillRect(xPos, (drop.y - i) * PIXEL_SIZE, PIXEL_SIZE, PIXEL_SIZE); } ctx.globalAlpha = 1; }; function drawSplash(ctx: CanvasRenderingContext2D, splash: Splash) { if (splash.age >= splash.maxAge) return; const progress = splash.age / splash.maxAge; ctx.globalAlpha = 1 - progress; ctx.fillStyle = SPLASH_PARTICLE_COLOR; splash.particles.forEach((p) => { const px = Math.floor(splash.x + p.offsetX) * PIXEL_SIZE; const py = Math.floor(splash.y + p.offsetY) * PIXEL_SIZE; ctx.fillRect(px, py, PIXEL_SIZE, PIXEL_SIZE); }); if (splash.age < 6) { const rippleSize = Math.floor(splash.age / 2) + 1; const splashY = splash.y * PIXEL_SIZE; ctx.fillStyle = SPLASH_RIPPLE_COLOR; ctx.fillRect( (splash.x - rippleSize) * PIXEL_SIZE, splashY, PIXEL_SIZE, PIXEL_SIZE, ); ctx.fillRect( (splash.x + rippleSize) * PIXEL_SIZE, splashY, PIXEL_SIZE, PIXEL_SIZE, ); } ctx.globalAlpha = 1; } function drawStar(ctx: CanvasRenderingContext2D, star: Star, time: number) { const twinkle = Math.sin(time * star.twinkleSpeed + star.twinkleOffset) * STAR_TWINKLE_AMPLITUDE + STAR_TWINKLE_BASE; const alpha = star.brightness * twinkle; const x = Math.floor(star.x); const y = Math.floor(star.y); const color = ALPHA_STAR_COLORS[star.colorIndex]; ctx.fillStyle = color + alpha; ctx.fillRect(x, y, star.size, star.size); if (star.size === STAR_SIZE_LARGE && alpha > STAR_GLOW_THRESHOLD) { ctx.fillStyle = color + alpha * STAR_GLOW_ALPHA; ctx.fillRect(x - 4, y + 2, 4, 4); ctx.fillRect(x + 8, y + 2, 4, 4); ctx.fillRect(x + 2, y - 4, 4, 4); ctx.fillRect(x + 2, y + 8, 4, 4); } } export default function Rain() { const canvasRef = useRef(null); useEffect(() => { const prefersReducedMotion = window.matchMedia( "(prefers-reduced-motion: reduce)", ).matches; if (prefersReducedMotion) return; const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; let gridWidth = 0; let gridHeight = 0; const resizeCanvas = () => { canvas.width = window.innerWidth; canvas.height = window.innerHeight; gridWidth = Math.floor(window.innerWidth / PIXEL_SIZE); gridHeight = Math.floor(window.innerHeight / PIXEL_SIZE); }; resizeCanvas(); window.addEventListener("resize", resizeCanvas); const stars = Array.from({ length: STAR_COUNT }, createStar); const raindrops = Array.from( { length: DROP_COUNT }, () => createRaindrop(gridWidth, gridHeight), ); const splashes: Splash[] = []; const startTime = performance.now(); const animate = (time: number) => { ctx.fillStyle = BACKGROUND_COLOUR; ctx.fillRect(0, 0, canvas.width, canvas.height); for (let i = 0; i < stars.length; i++) { updateStar(stars[i]); drawStar(ctx, stars[i], time - startTime); } raindrops.forEach((drop) => updateRaindrop(drop, splashes, gridWidth, gridHeight) ); for (let i = splashes.length - 1; i >= 0; i--) { updateSplash(splashes[i]); if (isSplashDead(splashes[i])) { splashes.splice(i, 1); } } for (let i = 0; i < stars.length; i++) { updateStar(stars[i]); drawStar(ctx, stars[i], performance.now() - startTime); } raindrops.forEach((drop) => drawRaindrop(ctx, drop)); splashes.forEach((splash) => drawSplash(ctx, splash)); requestAnimationFrame(animate); }; animate(0); }, []); return (