289 lines
7.5 KiB
TypeScript
289 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef } from "react";
|
|
|
|
import {
|
|
createVanishingPoint,
|
|
projectFlightStar,
|
|
} from "@/features/flight-starfield/lib/projection";
|
|
import {
|
|
createFlightStar,
|
|
createFlightVanishXJitter,
|
|
getFlightStarCount,
|
|
shouldRecycleFlightStar,
|
|
} from "@/features/flight-starfield/model/starfieldModel";
|
|
import { FlightStar } from "@/features/flight-starfield/model/types";
|
|
import { clamp } from "@/shared/lib/math/number";
|
|
import { getPrefersReducedMotionMediaQuery } from "@/shared/lib/motion/prefersReducedMotion";
|
|
|
|
export function FlightStarfieldCanvas({
|
|
vanishYOffset = -68,
|
|
centerProtectRadius = 200,
|
|
isPaused = false,
|
|
}: {
|
|
vanishYOffset?: number;
|
|
centerProtectRadius?: number;
|
|
isPaused?: boolean;
|
|
}) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const starsRef = useRef<FlightStar[]>([]);
|
|
const vanishXJitterRef = useRef<number | null>(null);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const context = canvas.getContext("2d");
|
|
if (!context) return;
|
|
|
|
let width = window.innerWidth;
|
|
let height = window.innerHeight;
|
|
let animationFrameId = 0;
|
|
|
|
const motionQuery = getPrefersReducedMotionMediaQuery();
|
|
let prefersReducedMotion = motionQuery.matches;
|
|
if (vanishXJitterRef.current === null) {
|
|
vanishXJitterRef.current = createFlightVanishXJitter();
|
|
}
|
|
const vanishXJitter = vanishXJitterRef.current ?? 0;
|
|
|
|
const setCanvasSize = () => {
|
|
width = window.innerWidth;
|
|
height = window.innerHeight;
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
};
|
|
|
|
const getVanishingPoint = () =>
|
|
createVanishingPoint({
|
|
width,
|
|
height,
|
|
xJitter: vanishXJitter,
|
|
yOffset: vanishYOffset,
|
|
});
|
|
|
|
const createStars = () =>
|
|
Array.from({ length: getFlightStarCount(width, height) }, () =>
|
|
createFlightStar({ width, height, isRespawn: false }),
|
|
);
|
|
|
|
setCanvasSize();
|
|
if (starsRef.current.length === 0) {
|
|
starsRef.current = createStars();
|
|
}
|
|
let stars = starsRef.current;
|
|
|
|
const applyCenterProtection = (x: number, y: number, alpha: number) => {
|
|
const centerX = width / 2;
|
|
const centerY = height / 2;
|
|
const distance = Math.hypot(x - centerX, y - centerY);
|
|
|
|
if (distance >= centerProtectRadius) return alpha;
|
|
|
|
const ratio = clamp(distance / centerProtectRadius, 0, 1);
|
|
const attenuation = 0.35 + ratio * 0.65;
|
|
return alpha * attenuation;
|
|
};
|
|
|
|
const drawStar = ({
|
|
star,
|
|
fromX,
|
|
fromY,
|
|
toX,
|
|
toY,
|
|
}: {
|
|
star: FlightStar;
|
|
fromX: number;
|
|
fromY: number;
|
|
toX: number;
|
|
toY: number;
|
|
}) => {
|
|
const visibleAlpha = applyCenterProtection(toX, toY, star.alpha);
|
|
const deltaX = toX - fromX;
|
|
const deltaY = toY - fromY;
|
|
const movementLength = Math.hypot(deltaX, deltaY);
|
|
|
|
if (star.tailLength < 1 || movementLength < 0.001) {
|
|
context.globalAlpha = visibleAlpha;
|
|
context.fillStyle = "#f8fbff";
|
|
context.beginPath();
|
|
context.arc(toX, toY, star.radius, 0, Math.PI * 2);
|
|
context.fill();
|
|
context.globalAlpha = 1;
|
|
return;
|
|
}
|
|
|
|
const directionX = deltaX / movementLength;
|
|
const directionY = deltaY / movementLength;
|
|
const tailX = toX - directionX * star.tailLength;
|
|
const tailY = toY - directionY * star.tailLength;
|
|
|
|
const gradient = context.createLinearGradient(tailX, tailY, toX, toY);
|
|
gradient.addColorStop(0, "rgba(248, 251, 255, 0)");
|
|
gradient.addColorStop(1, `rgba(248, 251, 255, ${visibleAlpha})`);
|
|
|
|
context.strokeStyle = gradient;
|
|
context.lineWidth = clamp(star.radius * 0.9, 0.65, 1.6);
|
|
context.beginPath();
|
|
context.moveTo(tailX, tailY);
|
|
context.lineTo(toX, toY);
|
|
context.stroke();
|
|
|
|
context.globalAlpha = Math.min(1, visibleAlpha + 0.08);
|
|
context.fillStyle = "#f8fbff";
|
|
context.beginPath();
|
|
context.arc(
|
|
toX,
|
|
toY,
|
|
clamp(star.radius * 0.72, 0.6, 1.45),
|
|
0,
|
|
Math.PI * 2,
|
|
);
|
|
context.fill();
|
|
context.globalAlpha = 1;
|
|
};
|
|
|
|
const drawCenterVeil = () => {
|
|
const vp = getVanishingPoint();
|
|
const veil = context.createRadialGradient(
|
|
vp.x,
|
|
vp.y,
|
|
centerProtectRadius * 0.18,
|
|
vp.x,
|
|
vp.y,
|
|
centerProtectRadius * 1.35,
|
|
);
|
|
veil.addColorStop(0, "rgba(160, 185, 235, 0.08)");
|
|
veil.addColorStop(0.55, "rgba(90, 114, 170, 0.03)");
|
|
veil.addColorStop(1, "rgba(0, 0, 0, 0)");
|
|
|
|
context.fillStyle = veil;
|
|
context.fillRect(0, 0, width, height);
|
|
};
|
|
|
|
const drawVignette = () => {
|
|
const vignette = context.createRadialGradient(
|
|
width / 2,
|
|
height / 2,
|
|
Math.min(width, height) * 0.25,
|
|
width / 2,
|
|
height / 2,
|
|
Math.max(width, height) * 0.95,
|
|
);
|
|
vignette.addColorStop(0, "rgba(0, 0, 0, 0)");
|
|
vignette.addColorStop(1, "rgba(0, 0, 0, 0.82)");
|
|
context.fillStyle = vignette;
|
|
context.fillRect(0, 0, width, height);
|
|
};
|
|
|
|
const drawFrame = (moveStars: boolean) => {
|
|
context.fillStyle = "rgba(2, 5, 10, 0.3)";
|
|
context.fillRect(0, 0, width, height);
|
|
|
|
drawCenterVeil();
|
|
|
|
stars.forEach((star, index) => {
|
|
const vp = getVanishingPoint();
|
|
const from = projectFlightStar(star, vp, star.z);
|
|
|
|
if (moveStars) {
|
|
star.z -= star.speed;
|
|
}
|
|
|
|
const to = projectFlightStar(star, vp, star.z);
|
|
|
|
if (
|
|
shouldRecycleFlightStar({
|
|
x: to.x,
|
|
y: to.y,
|
|
z: star.z,
|
|
width,
|
|
height,
|
|
})
|
|
) {
|
|
stars[index] = createFlightStar({ width, height, isRespawn: true });
|
|
starsRef.current = stars;
|
|
return;
|
|
}
|
|
|
|
drawStar({
|
|
star,
|
|
fromX: from.x,
|
|
fromY: from.y,
|
|
toX: to.x,
|
|
toY: to.y,
|
|
});
|
|
});
|
|
|
|
drawVignette();
|
|
};
|
|
|
|
const stopAnimation = () => {
|
|
if (!animationFrameId) return;
|
|
cancelAnimationFrame(animationFrameId);
|
|
animationFrameId = 0;
|
|
};
|
|
|
|
const render = () => {
|
|
if (prefersReducedMotion || isPaused) {
|
|
animationFrameId = 0;
|
|
return;
|
|
}
|
|
|
|
drawFrame(true);
|
|
animationFrameId = requestAnimationFrame(render);
|
|
};
|
|
|
|
const renderStatic = () => {
|
|
context.clearRect(0, 0, width, height);
|
|
drawFrame(false);
|
|
};
|
|
|
|
const handleResize = () => {
|
|
setCanvasSize();
|
|
stars = createStars();
|
|
starsRef.current = stars;
|
|
|
|
if (prefersReducedMotion || isPaused) {
|
|
renderStatic();
|
|
}
|
|
};
|
|
|
|
const handleMotionChange = (event: MediaQueryListEvent) => {
|
|
prefersReducedMotion = event.matches;
|
|
|
|
if (prefersReducedMotion) {
|
|
stopAnimation();
|
|
renderStatic();
|
|
return;
|
|
}
|
|
|
|
if (!isPaused && !animationFrameId) {
|
|
render();
|
|
}
|
|
};
|
|
|
|
window.addEventListener("resize", handleResize);
|
|
motionQuery.addEventListener("change", handleMotionChange);
|
|
|
|
if (prefersReducedMotion || isPaused) {
|
|
renderStatic();
|
|
} else {
|
|
render();
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener("resize", handleResize);
|
|
motionQuery.removeEventListener("change", handleMotionChange);
|
|
stopAnimation();
|
|
};
|
|
}, [vanishYOffset, centerProtectRadius, isPaused]);
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
className="fixed inset-0 z-0 bg-black pointer-events-none"
|
|
/>
|
|
);
|
|
}
|