2026-01-20 11:12:01 +08:00
|
|
|
<template>
|
|
|
|
|
<canvas ref="canvasRef" class="fixed inset-0 w-full h-full z-0 pointer-events-none"></canvas>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
import { onMounted, onUnmounted, ref } from 'vue'
|
|
|
|
|
|
|
|
|
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
|
|
|
|
|
2026-01-20 15:23:37 +08:00
|
|
|
// --- 配置参数 ---
|
2026-01-20 11:12:01 +08:00
|
|
|
const config = {
|
2026-01-20 15:27:45 +08:00
|
|
|
starCount: 150, // 恢复到经典数量,避免过于密集
|
|
|
|
|
meteorChance: 0.003, // 自然流星概率
|
2026-01-20 11:12:01 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 类型定义
|
|
|
|
|
interface Star {
|
|
|
|
|
x: number
|
|
|
|
|
y: number
|
2026-01-20 15:27:45 +08:00
|
|
|
radius: number
|
|
|
|
|
alpha: number
|
|
|
|
|
fadingOut: boolean
|
|
|
|
|
speed: number // 闪烁速度
|
2026-01-20 11:12:01 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Meteor {
|
|
|
|
|
x: number
|
|
|
|
|
y: number
|
|
|
|
|
length: number
|
|
|
|
|
speed: number
|
|
|
|
|
angle: number
|
2026-01-20 15:23:37 +08:00
|
|
|
alpha: number
|
|
|
|
|
width: number
|
2026-01-20 11:12:01 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Wave {
|
|
|
|
|
x: number
|
|
|
|
|
y: number
|
|
|
|
|
radius: number
|
|
|
|
|
alpha: number
|
2026-01-20 15:23:37 +08:00
|
|
|
lineWidth: number
|
|
|
|
|
hue: number
|
2026-01-20 11:12:01 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Particle {
|
|
|
|
|
x: number
|
|
|
|
|
y: number
|
|
|
|
|
vx: number
|
|
|
|
|
vy: number
|
|
|
|
|
life: number
|
|
|
|
|
color: string
|
2026-01-20 15:23:37 +08:00
|
|
|
size: number
|
2026-01-20 11:12:01 +08:00
|
|
|
}
|
|
|
|
|
|
2026-01-20 15:23:37 +08:00
|
|
|
// 状态
|
2026-01-20 11:12:01 +08:00
|
|
|
let ctx: CanvasRenderingContext2D | null = null
|
|
|
|
|
let animationFrameId: number
|
|
|
|
|
let width = 0
|
|
|
|
|
let height = 0
|
|
|
|
|
|
2026-01-20 15:23:37 +08:00
|
|
|
// 对象池
|
2026-01-20 11:12:01 +08:00
|
|
|
const stars: Star[] = []
|
|
|
|
|
const meteors: Meteor[] = []
|
|
|
|
|
const waves: Wave[] = []
|
|
|
|
|
const particles: Particle[] = []
|
|
|
|
|
|
2026-01-20 15:27:45 +08:00
|
|
|
// 初始化星星 (经典随机分布)
|
2026-01-20 11:12:01 +08:00
|
|
|
const initStars = () => {
|
|
|
|
|
stars.length = 0
|
|
|
|
|
for (let i = 0; i < config.starCount; i++) {
|
|
|
|
|
stars.push({
|
2026-01-20 15:27:45 +08:00
|
|
|
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
|
2026-01-20 11:12:01 +08:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 15:23:37 +08:00
|
|
|
// 渲染循环
|
|
|
|
|
const render = () => {
|
|
|
|
|
if (!ctx || !canvasRef.value) return
|
|
|
|
|
|
|
|
|
|
// 1. 清空画布
|
|
|
|
|
ctx.clearRect(0, 0, width, height)
|
2026-01-20 11:12:01 +08:00
|
|
|
|
2026-01-20 15:27:45 +08:00
|
|
|
// 2. 绘制星星 (经典呼吸闪烁)
|
|
|
|
|
ctx.fillStyle = '#FFF'
|
2026-01-20 11:12:01 +08:00
|
|
|
stars.forEach(star => {
|
2026-01-20 15:27:45 +08:00
|
|
|
if (star.fadingOut) {
|
|
|
|
|
star.alpha -= star.speed
|
|
|
|
|
if (star.alpha < 0) {
|
|
|
|
|
star.alpha = 0
|
|
|
|
|
star.fadingOut = false
|
|
|
|
|
}
|
2026-01-20 11:12:01 +08:00
|
|
|
} else {
|
2026-01-20 15:27:45 +08:00
|
|
|
star.alpha += star.speed
|
|
|
|
|
if (star.alpha > 1) {
|
|
|
|
|
star.alpha = 1
|
|
|
|
|
star.fadingOut = true
|
|
|
|
|
}
|
2026-01-20 11:12:01 +08:00
|
|
|
}
|
|
|
|
|
|
2026-01-20 15:27:45 +08:00
|
|
|
ctx!.globalAlpha = star.alpha
|
|
|
|
|
ctx!.beginPath()
|
|
|
|
|
ctx!.arc(star.x, star.y, star.radius, 0, Math.PI * 2)
|
2026-01-20 15:23:37 +08:00
|
|
|
ctx!.fill()
|
2026-01-20 11:12:01 +08:00
|
|
|
})
|
2026-01-20 15:27:45 +08:00
|
|
|
ctx.globalAlpha = 1
|
2026-01-20 15:23:37 +08:00
|
|
|
|
|
|
|
|
// 3. 绘制流星
|
|
|
|
|
updateAndDrawMeteors()
|
|
|
|
|
|
2026-01-20 15:27:45 +08:00
|
|
|
// 4. 绘制波纹 (Shockwave - 随机颜色)
|
2026-01-20 15:23:37 +08:00
|
|
|
updateAndDrawWaves()
|
|
|
|
|
|
2026-01-20 15:27:45 +08:00
|
|
|
// 5. 绘制粒子 (Sparks - 物理效果)
|
2026-01-20 15:23:37 +08:00
|
|
|
updateAndDrawParticles()
|
|
|
|
|
|
|
|
|
|
animationFrameId = requestAnimationFrame(render)
|
2026-01-20 11:12:01 +08:00
|
|
|
}
|
|
|
|
|
|
2026-01-20 15:23:37 +08:00
|
|
|
// --- 辅助绘制函数 ---
|
2026-01-20 11:12:01 +08:00
|
|
|
|
2026-01-20 15:23:37 +08:00
|
|
|
const updateAndDrawMeteors = () => {
|
2026-01-20 15:27:45 +08:00
|
|
|
if (Math.random() < config.meteorChance) createMeteor()
|
2026-01-20 11:12:01 +08:00
|
|
|
|
|
|
|
|
for (let i = meteors.length - 1; i >= 0; i--) {
|
|
|
|
|
const m = meteors[i]
|
2026-01-20 15:23:37 +08:00
|
|
|
if (!ctx) break
|
2026-01-20 11:12:01 +08:00
|
|
|
|
|
|
|
|
const endX = m.x - m.length * Math.cos(m.angle)
|
|
|
|
|
const endY = m.y - m.length * Math.sin(m.angle)
|
|
|
|
|
|
2026-01-20 15:27:45 +08:00
|
|
|
// 流星尾巴渐变
|
2026-01-20 11:12:01 +08:00
|
|
|
const gradient = ctx.createLinearGradient(m.x, m.y, endX, endY)
|
2026-01-20 15:23:37 +08:00
|
|
|
gradient.addColorStop(0, `rgba(255, 255, 255, ${m.alpha})`)
|
|
|
|
|
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
|
2026-01-20 11:12:01 +08:00
|
|
|
|
|
|
|
|
ctx.beginPath()
|
|
|
|
|
ctx.moveTo(m.x, m.y)
|
|
|
|
|
ctx.lineTo(endX, endY)
|
2026-01-20 15:23:37 +08:00
|
|
|
ctx.lineWidth = m.width
|
|
|
|
|
ctx.strokeStyle = gradient
|
2026-01-20 11:12:01 +08:00
|
|
|
ctx.stroke()
|
|
|
|
|
|
|
|
|
|
m.x += m.speed * Math.cos(m.angle)
|
|
|
|
|
m.y += m.speed * Math.sin(m.angle)
|
|
|
|
|
|
2026-01-20 15:27:45 +08:00
|
|
|
if (m.x > width + 100 || m.y > height + 100) {
|
|
|
|
|
meteors.splice(i, 1)
|
2026-01-20 11:12:01 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 15:23:37 +08:00
|
|
|
const updateAndDrawWaves = () => {
|
2026-01-20 11:12:01 +08:00
|
|
|
for (let i = waves.length - 1; i >= 0; i--) {
|
|
|
|
|
const w = waves[i]
|
2026-01-20 15:23:37 +08:00
|
|
|
if (!ctx) break
|
|
|
|
|
|
2026-01-20 11:12:01 +08:00
|
|
|
ctx.beginPath()
|
|
|
|
|
ctx.arc(w.x, w.y, w.radius, 0, Math.PI * 2)
|
2026-01-20 15:27:45 +08:00
|
|
|
// 使用 HSL 随机色
|
|
|
|
|
ctx.strokeStyle = `hsla(${w.hue}, 70%, 60%, ${w.alpha})`
|
2026-01-20 15:23:37 +08:00
|
|
|
ctx.lineWidth = w.lineWidth
|
2026-01-20 11:12:01 +08:00
|
|
|
ctx.stroke()
|
|
|
|
|
|
2026-01-20 15:27:45 +08:00
|
|
|
w.radius += 2
|
|
|
|
|
w.alpha -= 0.02
|
2026-01-20 15:23:37 +08:00
|
|
|
w.lineWidth *= 0.95
|
2026-01-20 11:12:01 +08:00
|
|
|
|
2026-01-20 15:27:45 +08:00
|
|
|
if (w.alpha <= 0) waves.splice(i, 1)
|
2026-01-20 11:12:01 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 15:23:37 +08:00
|
|
|
const updateAndDrawParticles = () => {
|
2026-01-20 11:12:01 +08:00
|
|
|
for (let i = particles.length - 1; i >= 0; i--) {
|
|
|
|
|
const p = particles[i]
|
2026-01-20 15:23:37 +08:00
|
|
|
if (!ctx) break
|
|
|
|
|
|
2026-01-20 11:12:01 +08:00
|
|
|
ctx.fillStyle = p.color
|
|
|
|
|
ctx.beginPath()
|
2026-01-20 15:23:37 +08:00
|
|
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2)
|
2026-01-20 11:12:01 +08:00
|
|
|
ctx.fill()
|
|
|
|
|
|
|
|
|
|
p.x += p.vx
|
|
|
|
|
p.y += p.vy
|
2026-01-20 15:27:45 +08:00
|
|
|
p.vy += 0.05 // 重力
|
2026-01-20 15:23:37 +08:00
|
|
|
p.vx *= 0.96
|
|
|
|
|
p.vy *= 0.96
|
|
|
|
|
|
2026-01-20 11:12:01 +08:00
|
|
|
p.life -= 0.02
|
2026-01-20 15:27:45 +08:00
|
|
|
p.size *= 0.96
|
2026-01-20 11:12:01 +08:00
|
|
|
|
2026-01-20 15:23:37 +08:00
|
|
|
if (p.life <= 0) particles.splice(i, 1)
|
2026-01-20 11:12:01 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 19:59:50 +08:00
|
|
|
const createMeteor = () => {
|
2026-01-20 15:23:37 +08:00
|
|
|
const startX = Math.random() * width
|
2026-01-20 15:27:45 +08:00
|
|
|
const angle = Math.PI / 4 // 45度
|
2026-01-20 15:23:37 +08:00
|
|
|
meteors.push({
|
|
|
|
|
x: startX,
|
2026-01-20 15:27:45 +08:00
|
|
|
y: 0,
|
|
|
|
|
length: Math.random() * 80 + 20,
|
|
|
|
|
speed: Math.random() * 10 + 5,
|
2026-01-20 15:23:37 +08:00
|
|
|
angle: angle,
|
|
|
|
|
alpha: 1,
|
2026-01-20 15:27:45 +08:00
|
|
|
width: Math.random() * 1.5 + 0.5
|
2026-01-20 15:23:37 +08:00
|
|
|
})
|
2026-01-20 11:12:01 +08:00
|
|
|
}
|
|
|
|
|
|
2026-01-20 15:23:37 +08:00
|
|
|
// 交互处理
|
2026-01-20 11:12:01 +08:00
|
|
|
const handleClick = (e: MouseEvent) => {
|
2026-01-20 15:23:37 +08:00
|
|
|
const hue = Math.floor(Math.random() * 360)
|
|
|
|
|
|
2026-01-20 15:27:45 +08:00
|
|
|
// 1. 波纹
|
2026-01-20 11:12:01 +08:00
|
|
|
waves.push({
|
|
|
|
|
x: e.clientX,
|
|
|
|
|
y: e.clientY,
|
2026-01-20 15:27:45 +08:00
|
|
|
radius: 0,
|
2026-01-20 15:23:37 +08:00
|
|
|
alpha: 1,
|
2026-01-20 15:27:45 +08:00
|
|
|
lineWidth: 3,
|
2026-01-20 15:23:37 +08:00
|
|
|
hue: hue
|
2026-01-20 11:12:01 +08:00
|
|
|
})
|
|
|
|
|
|
2026-01-20 15:27:45 +08:00
|
|
|
// 2. 粒子
|
|
|
|
|
const particleCount = 10
|
2026-01-20 11:12:01 +08:00
|
|
|
for (let i = 0; i < particleCount; i++) {
|
2026-01-20 15:27:45 +08:00
|
|
|
const angle = (Math.PI * 2 / particleCount) * i
|
|
|
|
|
const speed = Math.random() * 3 + 1
|
2026-01-20 11:12:01 +08:00
|
|
|
particles.push({
|
|
|
|
|
x: e.clientX,
|
|
|
|
|
y: e.clientY,
|
|
|
|
|
vx: Math.cos(angle) * speed,
|
|
|
|
|
vy: Math.sin(angle) * speed,
|
|
|
|
|
life: 1.0,
|
2026-01-20 15:27:45 +08:00
|
|
|
color: `hsla(${hue}, 80%, 70%, 1)`, // 粒子跟随波纹颜色
|
|
|
|
|
size: Math.random() * 2 + 1
|
2026-01-20 11:12:01 +08:00
|
|
|
})
|
|
|
|
|
}
|
2026-01-20 15:23:37 +08:00
|
|
|
|
2026-01-20 15:27:45 +08:00
|
|
|
// 3. 点击触发流星
|
2026-01-20 20:52:13 +08:00
|
|
|
createMeteor()
|
2026-01-20 11:12:01 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleResize = () => {
|
|
|
|
|
if (canvasRef.value) {
|
|
|
|
|
width = window.innerWidth
|
|
|
|
|
height = window.innerHeight
|
|
|
|
|
canvasRef.value.width = width
|
|
|
|
|
canvasRef.value.height = height
|
|
|
|
|
initStars()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
if (canvasRef.value) {
|
|
|
|
|
ctx = canvasRef.value.getContext('2d')
|
|
|
|
|
handleResize()
|
|
|
|
|
|
|
|
|
|
window.addEventListener('resize', handleResize)
|
|
|
|
|
window.addEventListener('click', handleClick)
|
2026-01-20 15:23:37 +08:00
|
|
|
|
|
|
|
|
render()
|
2026-01-20 11:12:01 +08:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
cancelAnimationFrame(animationFrameId)
|
|
|
|
|
window.removeEventListener('resize', handleResize)
|
|
|
|
|
window.removeEventListener('click', handleClick)
|
|
|
|
|
})
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
canvas {
|
|
|
|
|
display: block;
|
|
|
|
|
}
|
|
|
|
|
</style>
|