Files
echo-nexus/src/components/game/StarfieldCanvas.tsx
T

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