Files
hushroom/src/features/flight-starfield/ui/FlightStarfieldCanvas.tsx
2026-02-14 21:55:54 +09:00

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"
/>
);
}