web/islands/Rain.tsx
2025-10-27 04:10:26 -03:00

374 lines
9.7 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}
style={{
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
imageRendering: "pixelated",
background: BACKGROUND_COLOUR,
}}
/>
);
}