380 lines
9.4 KiB
TypeScript
380 lines
9.4 KiB
TypeScript
/**
|
|
* Copyright (c) 2025 xwra
|
|
* 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<HTMLCanvasElement>(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 (
|
|
<canvas
|
|
ref={canvasRef}
|
|
aria-hidden="true"
|
|
role="presentation"
|
|
style={{
|
|
position: "fixed",
|
|
top: 0,
|
|
left: 0,
|
|
width: "100vw",
|
|
height: "100vh",
|
|
imageRendering: "pixelated",
|
|
background: BACKGROUND_COLOUR,
|
|
}}
|
|
/>
|
|
);
|
|
}
|