initial commit
This commit is contained in:
commit
30f2b4714d
43 changed files with 3654 additions and 0 deletions
374
islands/Rain.tsx
Normal file
374
islands/Rain.tsx
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
/**
|
||||
* 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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue