Files
nl-blogs/client/src/components/StarBackground.vue
2026-07-14 12:47:53 +08:00

471 lines
10 KiB
Vue

<template>
<canvas ref="canvasRef" class="ambient-bg-canvas fixed inset-0 w-full h-full z-0 pointer-events-none"></canvas>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { useTheme } from '../composables/useTheme'
const canvasRef = ref<HTMLCanvasElement | null>(null)
const { resolvedTheme } = useTheme()
const config = {
starCount: 150,
dustCount: 80,
meteorChance: 0.003,
orbCount: 3,
}
let running = false
let reduceMotion = false
let time = 0
interface Star {
x: number
y: number
radius: number
alpha: number
fadingOut: boolean
speed: number
}
interface Meteor {
x: number
y: number
length: number
speed: number
angle: number
alpha: number
width: number
}
interface Wave {
x: number
y: number
radius: number
alpha: number
lineWidth: number
hue: number
}
interface Particle {
x: number
y: number
vx: number
vy: number
life: number
color: string
size: number
}
interface Dust {
x: number
y: number
radius: number
alpha: number
fadingOut: boolean
speed: number
baseAlpha: number
}
interface Orb {
baseX: number
baseY: number
radius: number
ampX: number
ampY: number
phaseX: number
phaseY: number
speed: number
alpha: number
color: string
}
let ctx: CanvasRenderingContext2D | null = null
let animationFrameId: number
let width = 0
let height = 0
const stars: Star[] = []
const meteors: Meteor[] = []
const waves: Wave[] = []
const particles: Particle[] = []
const dust: Dust[] = []
const orbs: Orb[] = []
const prefersReducedMotion = () =>
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
const clearPools = () => {
stars.length = 0
meteors.length = 0
waves.length = 0
particles.length = 0
dust.length = 0
orbs.length = 0
}
const initStars = () => {
stars.length = 0
for (let i = 0; i < config.starCount; i++) {
stars.push({
x: Math.random() * width,
y: Math.random() * height,
radius: Math.random() * 1.5,
alpha: Math.random(),
fadingOut: Math.random() > 0.5,
speed: Math.random() * 0.02 + 0.005,
})
}
}
const initDust = () => {
dust.length = 0
for (let i = 0; i < config.dustCount; i++) {
const baseAlpha = Math.random() * 0.35 + 0.15
dust.push({
x: Math.random() * width,
y: Math.random() * height,
radius: Math.random() * 1.8 + 0.4,
alpha: baseAlpha,
fadingOut: Math.random() > 0.5,
speed: Math.random() * 0.008 + 0.003,
baseAlpha,
})
}
}
const initOrbs = () => {
orbs.length = 0
const palette = [
'rgba(212, 179, 131, 0.22)',
'rgba(196, 150, 90, 0.16)',
'rgba(232, 200, 150, 0.18)',
]
const placements = [
{ bx: 0.15, by: 0.2 },
{ bx: 0.82, by: 0.35 },
{ bx: 0.45, by: 0.78 },
]
for (let i = 0; i < config.orbCount; i++) {
const p = placements[i] || { bx: Math.random(), by: Math.random() }
orbs.push({
baseX: p.bx * width,
baseY: p.by * height,
radius: Math.min(width, height) * (0.28 + Math.random() * 0.12),
ampX: 40 + Math.random() * 60,
ampY: 30 + Math.random() * 50,
phaseX: Math.random() * Math.PI * 2,
phaseY: Math.random() * Math.PI * 2,
speed: 0.00025 + Math.random() * 0.0002,
alpha: 0.55 + Math.random() * 0.25,
color: palette[i % palette.length],
})
}
}
const initForTheme = () => {
clearPools()
if (resolvedTheme.value === 'dark') {
initStars()
} else {
initOrbs()
initDust()
}
}
const createMeteor = () => {
meteors.push({
x: Math.random() * width,
y: 0,
length: Math.random() * 80 + 20,
speed: Math.random() * 10 + 5,
angle: Math.PI / 4,
alpha: 1,
width: Math.random() * 1.5 + 0.5,
})
}
const updateAndDrawMeteors = () => {
if (Math.random() < config.meteorChance) createMeteor()
for (let i = meteors.length - 1; i >= 0; i--) {
const m = meteors[i]
if (!ctx) break
const endX = m.x - m.length * Math.cos(m.angle)
const endY = m.y - m.length * Math.sin(m.angle)
const gradient = ctx.createLinearGradient(m.x, m.y, endX, endY)
gradient.addColorStop(0, `rgba(255, 255, 255, ${m.alpha})`)
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
ctx.beginPath()
ctx.moveTo(m.x, m.y)
ctx.lineTo(endX, endY)
ctx.lineWidth = m.width
ctx.strokeStyle = gradient
ctx.stroke()
m.x += m.speed * Math.cos(m.angle)
m.y += m.speed * Math.sin(m.angle)
if (m.x > width + 100 || m.y > height + 100) {
meteors.splice(i, 1)
}
}
}
const updateAndDrawWaves = (isLight: boolean) => {
for (let i = waves.length - 1; i >= 0; i--) {
const w = waves[i]
if (!ctx) break
ctx.beginPath()
ctx.arc(w.x, w.y, w.radius, 0, Math.PI * 2)
ctx.strokeStyle = isLight
? `hsla(${w.hue}, 45%, 48%, ${w.alpha * 0.85})`
: `hsla(${w.hue}, 70%, 60%, ${w.alpha})`
ctx.lineWidth = w.lineWidth
ctx.stroke()
w.radius += isLight ? 1.6 : 2
w.alpha -= 0.02
w.lineWidth *= 0.95
if (w.alpha <= 0) waves.splice(i, 1)
}
}
const updateAndDrawParticles = () => {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i]
if (!ctx) break
ctx.fillStyle = p.color
ctx.beginPath()
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2)
ctx.fill()
p.x += p.vx
p.y += p.vy
p.vy += 0.05
p.vx *= 0.96
p.vy *= 0.96
p.life -= 0.02
p.size *= 0.96
if (p.life <= 0) particles.splice(i, 1)
}
}
const drawDark = () => {
if (!ctx) return
ctx.fillStyle = '#FFF'
stars.forEach((star) => {
if (!reduceMotion) {
if (star.fadingOut) {
star.alpha -= star.speed
if (star.alpha < 0) {
star.alpha = 0
star.fadingOut = false
}
} else {
star.alpha += star.speed
if (star.alpha > 1) {
star.alpha = 1
star.fadingOut = true
}
}
}
ctx!.globalAlpha = star.alpha
ctx!.beginPath()
ctx!.arc(star.x, star.y, star.radius, 0, Math.PI * 2)
ctx!.fill()
})
ctx.globalAlpha = 1
if (!reduceMotion) {
updateAndDrawMeteors()
}
updateAndDrawWaves(false)
updateAndDrawParticles()
}
const drawLight = () => {
if (!ctx) return
// Aurora orbs
orbs.forEach((orb) => {
const x = reduceMotion
? orb.baseX
: orb.baseX + Math.sin(time * orb.speed + orb.phaseX) * orb.ampX
const y = reduceMotion
? orb.baseY
: orb.baseY + Math.cos(time * orb.speed * 0.85 + orb.phaseY) * orb.ampY
const mid = orb.color.replace(/,\s*[\d.]+\)$/, ', 0.08)')
const gradient = ctx!.createRadialGradient(x, y, 0, x, y, orb.radius)
gradient.addColorStop(0, orb.color)
gradient.addColorStop(0.45, mid)
gradient.addColorStop(1, 'rgba(212, 179, 131, 0)')
ctx!.globalAlpha = orb.alpha
ctx!.fillStyle = gradient
ctx!.beginPath()
ctx!.arc(x, y, orb.radius, 0, Math.PI * 2)
ctx!.fill()
})
ctx.globalAlpha = 1
// Gold dust
dust.forEach((d) => {
if (!reduceMotion) {
if (d.fadingOut) {
d.alpha -= d.speed
if (d.alpha < d.baseAlpha * 0.25) {
d.alpha = d.baseAlpha * 0.25
d.fadingOut = false
}
} else {
d.alpha += d.speed
if (d.alpha > d.baseAlpha) {
d.alpha = d.baseAlpha
d.fadingOut = true
}
}
}
ctx!.globalAlpha = d.alpha
ctx!.fillStyle = 'rgb(196, 155, 95)'
ctx!.beginPath()
ctx!.arc(d.x, d.y, d.radius, 0, Math.PI * 2)
ctx!.fill()
})
ctx.globalAlpha = 1
updateAndDrawWaves(true)
updateAndDrawParticles()
}
const render = () => {
if (!running || !ctx || !canvasRef.value) return
ctx.clearRect(0, 0, width, height)
if (!reduceMotion) time += 16
if (resolvedTheme.value === 'dark') {
drawDark()
} else {
drawLight()
}
animationFrameId = requestAnimationFrame(render)
}
const handleClick = (e: MouseEvent) => {
if (!running || reduceMotion) return
const isLight = resolvedTheme.value === 'light'
const hue = isLight ? 35 + Math.floor(Math.random() * 12) : Math.floor(Math.random() * 360)
waves.push({
x: e.clientX,
y: e.clientY,
radius: 0,
alpha: isLight ? 0.9 : 1,
lineWidth: isLight ? 2.5 : 3,
hue,
})
const particleCount = 10
for (let i = 0; i < particleCount; i++) {
const angle = ((Math.PI * 2) / particleCount) * i
const speed = Math.random() * 3 + 1
particles.push({
x: e.clientX,
y: e.clientY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 1.0,
color: isLight
? `hsla(${hue}, 50%, 52%, 0.9)`
: `hsla(${hue}, 80%, 70%, 1)`,
size: Math.random() * 2 + 1,
})
}
if (!isLight) createMeteor()
}
const handleResize = () => {
if (!canvasRef.value) return
width = window.innerWidth
height = window.innerHeight
canvasRef.value.width = width
canvasRef.value.height = height
initForTheme()
}
const startLoop = () => {
if (running) return
running = true
render()
}
const stopLoop = () => {
running = false
cancelAnimationFrame(animationFrameId)
if (ctx && canvasRef.value) {
ctx.clearRect(0, 0, width, height)
}
}
const syncMotionPreference = () => {
reduceMotion = prefersReducedMotion()
}
let motionQuery: MediaQueryList | null = null
const onMotionChange = () => {
syncMotionPreference()
}
onMounted(() => {
if (!canvasRef.value) return
ctx = canvasRef.value.getContext('2d')
syncMotionPreference()
handleResize()
window.addEventListener('resize', handleResize)
window.addEventListener('click', handleClick)
motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
motionQuery.addEventListener('change', onMotionChange)
// Always run ambient layer for both themes (static draw when reduced motion)
startLoop()
})
watch(resolvedTheme, () => {
clearPools()
initForTheme()
if (!running) startLoop()
})
onUnmounted(() => {
stopLoop()
window.removeEventListener('resize', handleResize)
window.removeEventListener('click', handleClick)
motionQuery?.removeEventListener('change', onMotionChange)
})
</script>
<style scoped>
canvas {
display: block;
}
</style>