182 lines
5.2 KiB
TypeScript
Executable File
182 lines
5.2 KiB
TypeScript
Executable File
"use client";
|
|
// 回响星核 / Echo Nexus — 深空星空背景 Canvas
|
|
import { useEffect, useRef } from "react";
|
|
|
|
interface Star {
|
|
x: number;
|
|
y: number;
|
|
z: number; // 深度 0-1
|
|
r: number;
|
|
tw: number; // 闪烁相位
|
|
hue: number;
|
|
}
|
|
|
|
interface Dust {
|
|
x: number;
|
|
y: number;
|
|
vx: number;
|
|
vy: number;
|
|
life: number;
|
|
maxLife: number;
|
|
color: string;
|
|
}
|
|
|
|
export function StarfieldCanvas({ className }: { className?: string }) {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
let w = 0;
|
|
let h = 0;
|
|
let dpr = 1;
|
|
let stars: Star[] = [];
|
|
const dust: Dust[] = [];
|
|
let raf = 0;
|
|
let lastT = performance.now();
|
|
|
|
const palette = ["#34d399", "#fb7185", "#fbbf24", "#e879f9", "#a5f3fc"];
|
|
|
|
function resize() {
|
|
dpr = Math.min(2, window.devicePixelRatio || 1);
|
|
w = canvas!.clientWidth;
|
|
h = canvas!.clientHeight;
|
|
canvas!.width = Math.floor(w * dpr);
|
|
canvas!.height = Math.floor(h * dpr);
|
|
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
// 生成星星:密度随面积
|
|
const count = Math.min(220, Math.floor((w * h) / 7000));
|
|
stars = Array.from({ length: count }, () => ({
|
|
x: Math.random() * w,
|
|
y: Math.random() * h,
|
|
z: Math.random(),
|
|
r: Math.random() * 1.4 + 0.2,
|
|
tw: Math.random() * Math.PI * 2,
|
|
hue: Math.random() < 0.15 ? Math.floor(Math.random() * palette.length) : -1,
|
|
}));
|
|
}
|
|
resize();
|
|
const ro = new ResizeObserver(resize);
|
|
ro.observe(canvas);
|
|
|
|
function spawnDust() {
|
|
if (dust.length > 60) return;
|
|
const color = palette[Math.floor(Math.random() * palette.length)];
|
|
dust.push({
|
|
x: Math.random() * w,
|
|
y: h + 10,
|
|
vx: (Math.random() - 0.5) * 0.3,
|
|
vy: -(Math.random() * 0.5 + 0.2),
|
|
life: 0,
|
|
maxLife: 600 + Math.random() * 800,
|
|
color,
|
|
});
|
|
}
|
|
|
|
function draw(t: number) {
|
|
const dt = t - lastT;
|
|
lastT = t;
|
|
// 背景渐变
|
|
const g = ctx!.createRadialGradient(w * 0.5, h * 0.4, 0, w * 0.5, h * 0.5, Math.max(w, h) * 0.8);
|
|
g.addColorStop(0, "#1a0f2e");
|
|
g.addColorStop(0.45, "#0d0a1f");
|
|
g.addColorStop(1, "#050410");
|
|
ctx!.fillStyle = g;
|
|
ctx!.fillRect(0, 0, w, h);
|
|
|
|
// 星云团块
|
|
ctx!.globalCompositeOperation = "screen";
|
|
for (let i = 0; i < 3; i++) {
|
|
const cx = w * (0.2 + i * 0.3) + Math.sin(t / 9000 + i) * 40;
|
|
const cy = h * (0.3 + (i % 2) * 0.4) + Math.cos(t / 11000 + i) * 30;
|
|
const rad = Math.max(80, Math.min(w, h) * (0.25 + i * 0.05));
|
|
const ng = ctx!.createRadialGradient(cx, cy, 0, cx, cy, rad);
|
|
const col = i === 0 ? "rgba(232,121,249,0.10)" : i === 1 ? "rgba(52,211,153,0.08)" : "rgba(251,113,133,0.08)";
|
|
ng.addColorStop(0, col);
|
|
ng.addColorStop(1, "rgba(0,0,0,0)");
|
|
ctx!.fillStyle = ng;
|
|
ctx!.beginPath();
|
|
ctx!.arc(cx, cy, rad, 0, Math.PI * 2);
|
|
ctx!.fill();
|
|
}
|
|
ctx!.globalCompositeOperation = "source-over";
|
|
|
|
// 星星
|
|
for (const s of stars) {
|
|
s.tw += 0.02 + s.z * 0.04;
|
|
const alpha = 0.4 + Math.sin(s.tw) * 0.35 + s.z * 0.3;
|
|
if (s.hue >= 0) {
|
|
ctx!.fillStyle = palette[s.hue];
|
|
ctx!.globalAlpha = Math.max(0, Math.min(1, alpha));
|
|
ctx!.beginPath();
|
|
ctx!.arc(s.x, s.y, s.r * 1.6, 0, Math.PI * 2);
|
|
ctx!.fill();
|
|
// 光晕
|
|
ctx!.globalAlpha = Math.max(0, Math.min(1, alpha * 0.25));
|
|
ctx!.beginPath();
|
|
ctx!.arc(s.x, s.y, s.r * 4, 0, Math.PI * 2);
|
|
ctx!.fill();
|
|
} else {
|
|
ctx!.fillStyle = `rgba(220,230,255,${Math.max(0, Math.min(1, alpha))})`;
|
|
ctx!.beginPath();
|
|
ctx!.arc(s.x, s.y, s.r, 0, Math.PI * 2);
|
|
ctx!.fill();
|
|
}
|
|
// 缓慢漂移
|
|
s.y += 0.02 + s.z * 0.05;
|
|
if (s.y > h + 2) {
|
|
s.y = -2;
|
|
s.x = Math.random() * w;
|
|
}
|
|
}
|
|
ctx!.globalAlpha = 1;
|
|
|
|
// 偶尔生成星尘
|
|
if (Math.random() < 0.04) spawnDust();
|
|
// 星尘
|
|
ctx!.globalCompositeOperation = "screen";
|
|
for (let i = dust.length - 1; i >= 0; i--) {
|
|
const d = dust[i];
|
|
d.life += dt;
|
|
d.x += d.vx * dt * 0.06;
|
|
d.y += d.vy * dt * 0.06;
|
|
if (d.life > d.maxLife || d.y < -10) {
|
|
dust.splice(i, 1);
|
|
continue;
|
|
}
|
|
const a = Math.sin((d.life / d.maxLife) * Math.PI) * 0.7;
|
|
ctx!.fillStyle = d.color;
|
|
ctx!.globalAlpha = a;
|
|
ctx!.beginPath();
|
|
ctx!.arc(d.x, d.y, 1.6, 0, Math.PI * 2);
|
|
ctx!.fill();
|
|
ctx!.globalAlpha = a * 0.2;
|
|
ctx!.beginPath();
|
|
ctx!.arc(d.x, d.y, 5, 0, Math.PI * 2);
|
|
ctx!.fill();
|
|
}
|
|
ctx!.globalAlpha = 1;
|
|
ctx!.globalCompositeOperation = "source-over";
|
|
|
|
raf = requestAnimationFrame(draw);
|
|
}
|
|
raf = requestAnimationFrame(draw);
|
|
|
|
return () => {
|
|
cancelAnimationFrame(raf);
|
|
ro.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<canvas
|
|
ref={canvasRef}
|
|
className={className}
|
|
aria-hidden="true"
|
|
/>
|
|
);
|
|
}
|