Files
SuBlog/components/layout/StarField.client.vue
2026-06-30 00:06:02 +08:00

152 lines
3.9 KiB
Vue

<script setup lang="ts">
const canvasRef = ref<HTMLCanvasElement>()
let ctx: CanvasRenderingContext2D | null = null
let animId = 0
let stars: Star[] = []
let shootingStars: ShootingStar[] = []
let nextShootTime = 0
interface Star {
x: number
y: number
size: number
baseAlpha: number
twinkleSpeed: number
twinklePhase: number
}
interface ShootingStar {
x: number
y: number
speed: number
length: number
alpha: number
angle: number
}
function init() {
const canvas = canvasRef.value
if (!canvas || ctx) return
canvas.width = window.innerWidth
canvas.height = window.innerHeight
ctx = canvas.getContext('2d')
const count = Math.floor((canvas.width * canvas.height) / 2000)
stars = Array.from({ length: count }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
size: 0.3 + Math.random() * 2.0,
baseAlpha: 0.3 + Math.random() * 0.7,
twinkleSpeed: 0.2 + Math.random() * 0.8,
twinklePhase: Math.random() * Math.PI * 2,
}))
}
function scheduleShootingStar() {
nextShootTime = Date.now() + 2000 + Math.random() * 8000
}
function spawnShootingStar(w: number, h: number) {
const angle = Math.PI / 4 + (Math.random() - 0.5) * 0.3
shootingStars.push({
x: 0,
y: Math.random() * h * 0.4,
speed: 6 + Math.random() * 8,
length: 60 + Math.random() * 100,
alpha: 1,
angle,
})
}
function isDark(): boolean {
return document.documentElement.classList.contains('dark')
}
function animate() {
const canvas = canvasRef.value
if (!canvas || !ctx) return
if (!isDark()) {
ctx.clearRect(0, 0, canvas.width, canvas.height)
animId = requestAnimationFrame(animate)
return
}
const { width, height } = canvas
ctx.clearRect(0, 0, width, height)
const now = Date.now()
// Stars
for (const s of stars) {
const alpha = s.baseAlpha * (0.5 + 0.5 * Math.sin(now * s.twinkleSpeed * 0.001 + s.twinklePhase))
ctx.beginPath()
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2)
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`
ctx.fill()
}
// Shooting stars
if (now > nextShootTime && shootingStars.length < 4) {
spawnShootingStar(width, height)
scheduleShootingStar()
}
for (let i = shootingStars.length - 1; i >= 0; i--) {
const ss = shootingStars[i]
ss.x += Math.cos(ss.angle) * ss.speed
ss.y += Math.sin(ss.angle) * ss.speed
ss.alpha -= 0.005
if (ss.alpha <= 0 || ss.x > width + ss.length || ss.y > height) {
shootingStars.splice(i, 1)
continue
}
const tailX = ss.x - Math.cos(ss.angle) * ss.length
const tailY = ss.y - Math.sin(ss.angle) * ss.length
const gradient = ctx.createLinearGradient(tailX, tailY, ss.x, ss.y)
gradient.addColorStop(0, `rgba(255, 255, 255, 0)`)
gradient.addColorStop(1, `rgba(255, 255, 255, ${ss.alpha})`)
ctx.beginPath()
ctx.moveTo(tailX, tailY)
ctx.lineTo(ss.x, ss.y)
ctx.strokeStyle = gradient
ctx.lineWidth = 1.5
ctx.stroke()
}
animId = requestAnimationFrame(animate)
}
onMounted(async () => {
if (typeof window === 'undefined' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
await nextTick()
init()
animate()
const onResize = () => {
const canvas = canvasRef.value
if (!canvas) return
canvas.width = window.innerWidth
canvas.height = window.innerHeight
const count = Math.floor((canvas.width * canvas.height) / 2000)
stars = Array.from({ length: count }, () => ({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
size: 0.3 + Math.random() * 2.0,
baseAlpha: 0.3 + Math.random() * 0.7,
twinkleSpeed: 0.2 + Math.random() * 0.8,
twinklePhase: Math.random() * Math.PI * 2,
}))
}
window.addEventListener('resize', onResize)
})
const style = `position: fixed; inset: 0; z-index: 0; pointer-events: none;`
</script>
<template>
<canvas ref="canvasRef" :style="style" />
</template>