AI-News/frontend/app/components/home/InteractiveTechBackground.vue
2025-12-04 10:04:21 +08:00

466 lines
11 KiB
Vue

<template>
<div class="interactive-bg">
<canvas ref="canvas"></canvas>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const canvas = ref<HTMLCanvasElement | null>(null)
let ctx: CanvasRenderingContext2D | null = null
let animationId: number
let particles: Particle[] = []
let airflowParticles: AirflowParticle[] = []
let explosions: ExplosionParticle[] = []
let nebulas: Nebula[] = []
let shootingStars: ShootingStar[] = []
let mouse = { x: -1000, y: -1000 }
let lastMouse = { x: -1000, y: -1000 }
// Configuration
const PARTICLE_COUNT = 50
const NEBULA_COUNT = 4
const SHOOTING_STAR_CHANCE = 0.005
class Nebula {
x: number
y: number
radius: number
color: string
vx: number
vy: number
constructor(w: number, h: number) {
this.x = Math.random() * w
this.y = Math.random() * h
this.radius = Math.random() * 200 + 150
this.vx = (Math.random() - 0.5) * 0.2
this.vy = (Math.random() - 0.5) * 0.2
const colors = [
'rgba(76, 29, 149, 0.15)',
'rgba(30, 58, 138, 0.15)',
'rgba(88, 28, 135, 0.1)'
]
this.color = colors[Math.floor(Math.random() * colors.length)]
}
update(w: number, h: number) {
this.x += this.vx
this.y += this.vy
if (this.x < -this.radius) this.x = w + this.radius
if (this.x > w + this.radius) this.x = -this.radius
if (this.y < -this.radius) this.y = h + this.radius
if (this.y > h + this.radius) this.y = -this.radius
}
draw() {
if (!ctx) return
ctx.beginPath()
const gradient = ctx.createRadialGradient(this.x, this.y, 0, this.x, this.y, this.radius)
gradient.addColorStop(0, this.color)
gradient.addColorStop(1, 'rgba(0,0,0,0)')
ctx.fillStyle = gradient
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2)
ctx.fill()
}
}
class Particle {
x: number
y: number
vx: number
vy: number
baseSize: number
size: number
color: string
glowColor: string
isGold: boolean
pulseAngle: number
pulseSpeed: number
constructor(w: number, h: number, startBottom: boolean = true) {
this.x = Math.random() * w
this.y = startBottom ? h + Math.random() * 100 : Math.random() * h
this.vx = (Math.random() - 0.5) * 0.3
this.vy = -Math.random() * 0.6 - 0.2
this.isGold = Math.random() < 0.1
this.pulseAngle = Math.random() * Math.PI * 2
this.pulseSpeed = 0.05
if (this.isGold) {
this.baseSize = Math.random() * 8 + 6
this.color = '#FDB813'
this.glowColor = 'rgba(253, 184, 19, 0.6)'
} else {
this.baseSize = Math.random() * 4 + 2
const colors = [
{ c: '#ffffff', g: 'rgba(255, 255, 255, 0.4)' },
{ c: '#6366f1', g: 'rgba(99, 102, 241, 0.4)' },
{ c: '#06b6d4', g: 'rgba(6, 182, 212, 0.4)' },
]
const type = colors[Math.floor(Math.random() * colors.length)]
this.color = type.c
this.glowColor = type.g
}
this.size = this.baseSize
}
update(w: number, h: number) {
this.x += this.vx
this.y += this.vy
if (this.isGold) {
this.pulseAngle += this.pulseSpeed
this.size = this.baseSize + Math.sin(this.pulseAngle) * 1.5
}
if (this.y < -50) {
this.y = h + 50
this.x = Math.random() * w
}
if (this.x < -50) this.x = w + 50
if (this.x > w + 50) this.x = -50
}
draw() {
if (!ctx) return
ctx.beginPath()
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2)
ctx.fillStyle = this.color
ctx.shadowBlur = this.isGold ? 25 + Math.sin(this.pulseAngle) * 5 : 10
ctx.shadowColor = this.glowColor
ctx.fill()
ctx.shadowBlur = 0
}
}
class ShootingStar {
x: number
y: number
vx: number
vy: number
length: number
life: number
constructor(w: number, h: number) {
this.x = Math.random() * w
this.y = Math.random() * (h * 0.5)
this.vx = (Math.random() * 10 + 10) * (Math.random() < 0.5 ? 1 : -1)
this.vy = Math.random() * 5 + 2
this.length = Math.random() * 80 + 50
this.life = 1.0
}
update() {
this.x += this.vx
this.y += this.vy
this.life -= 0.02
}
draw() {
if (!ctx || this.life <= 0) return
const tailX = this.x - this.vx * 3
const tailY = this.y - this.vy * 3
const gradient = ctx.createLinearGradient(this.x, this.y, tailX, tailY)
gradient.addColorStop(0, `rgba(255, 255, 255, ${this.life})`)
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`)
ctx.beginPath()
ctx.moveTo(this.x, this.y)
ctx.lineTo(tailX, tailY)
ctx.strokeStyle = gradient
ctx.lineWidth = 2
ctx.lineCap = 'round'
ctx.stroke()
ctx.beginPath()
ctx.arc(this.x, this.y, 2, 0, Math.PI * 2)
ctx.fillStyle = `rgba(255, 255, 255, ${this.life})`
ctx.shadowBlur = 10
ctx.shadowColor = 'white'
ctx.fill()
ctx.shadowBlur = 0
}
}
class AirflowParticle {
x: number
y: number
vx: number
vy: number
size: number
life: number
decay: number
color: string
constructor(x: number, y: number, mouseVx: number, mouseVy: number) {
this.x = x
this.y = y
const speed = Math.sqrt(mouseVx * mouseVx + mouseVy * mouseVy)
const dirX = speed > 0 ? mouseVx / speed : 0
const dirY = speed > 0 ? mouseVy / speed : 0
const spread = 0.6
const angle = Math.atan2(-dirY, -dirX) + (Math.random() - 0.5) * spread
const ejectSpeed = Math.random() * 3 + 1
this.vx = Math.cos(angle) * ejectSpeed
this.vy = Math.sin(angle) * ejectSpeed
this.size = Math.random() * 3 + 2
this.life = 1.0
this.decay = Math.random() * 0.02 + 0.015
this.color = `255, 255, 255`
}
update() {
this.x += this.vx
this.y += this.vy
this.life -= this.decay
this.size += 0.3
this.vx *= 0.94
this.vy *= 0.94
}
draw() {
if (!ctx || this.life <= 0) return
ctx.beginPath()
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2)
const gradient = ctx.createRadialGradient(this.x, this.y, 0, this.x, this.y, this.size)
gradient.addColorStop(0, `rgba(${this.color}, ${this.life * 0.8})`)
gradient.addColorStop(1, `rgba(${this.color}, 0)`)
ctx.fillStyle = gradient
ctx.fill()
}
}
class ExplosionParticle {
x: number
y: number
vx: number
vy: number
life: number
color: string
size: number
constructor(x: number, y: number, color: string) {
this.x = x
this.y = y
const angle = Math.random() * Math.PI * 2
const speed = Math.random() * 6 + 2
this.vx = Math.cos(angle) * speed
this.vy = Math.sin(angle) * speed
this.life = 1.0
this.color = color
this.size = Math.random() * 3 + 1
}
update() {
this.x += this.vx
this.y += this.vy
this.life -= Math.random() * 0.02 + 0.01
this.vx *= 0.95
this.vy *= 0.95
}
draw() {
if (!ctx || this.life <= 0) return
ctx.globalAlpha = this.life
ctx.fillStyle = this.color
ctx.beginPath()
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2)
ctx.fill()
ctx.globalAlpha = 1.0
}
}
const init = () => {
if (!canvas.value) return
ctx = canvas.value.getContext('2d')
if (!ctx) return
const resize = () => {
if (!canvas.value) return
canvas.value.width = canvas.value.offsetWidth
canvas.value.height = canvas.value.offsetHeight
particles = []
nebulas = []
shootingStars = []
for (let i = 0; i < PARTICLE_COUNT; i++) {
particles.push(new Particle(canvas.value.width, canvas.value.height, false))
}
for (let i = 0; i < NEBULA_COUNT; i++) {
nebulas.push(new Nebula(canvas.value.width, canvas.value.height))
}
}
window.addEventListener('resize', resize)
resize()
const onMouseMove = (e: MouseEvent) => {
if (!canvas.value) return
const rect = canvas.value.getBoundingClientRect()
lastMouse.x = mouse.x
lastMouse.y = mouse.y
mouse.x = e.clientX - rect.left
mouse.y = e.clientY - rect.top
const dx = mouse.x - lastMouse.x
const dy = mouse.y - lastMouse.y
const speed = Math.sqrt(dx * dx + dy * dy)
if (lastMouse.x !== -1000 && speed > 1) {
const count = Math.min(Math.floor(speed / 1.5), 12)
for (let i = 0; i < count; i++) {
const t = Math.random()
const ix = lastMouse.x + dx * t
const iy = lastMouse.y + dy * t
airflowParticles.push(new AirflowParticle(ix, iy, dx, dy))
}
}
checkCollisions()
}
const onMouseLeave = () => {
mouse.x = -1000
mouse.y = -1000
lastMouse.x = -1000
lastMouse.y = -1000
}
canvas.value.addEventListener('mousemove', onMouseMove)
canvas.value.addEventListener('mouseleave', onMouseLeave)
const checkCollisions = () => {
const hitRadius = 40
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i]
const dx = mouse.x - p.x
const dy = mouse.y - p.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist < hitRadius) {
createExplosion(p.x, p.y, p.color)
particles.splice(i, 1)
if (canvas.value) {
particles.push(new Particle(canvas.value.width, canvas.value.height, true))
}
}
}
}
const createExplosion = (x: number, y: number, color: string) => {
for (let i = 0; i < 50; i++) {
explosions.push(new ExplosionParticle(x, y, color))
}
}
const animate = () => {
if (!ctx || !canvas.value) return
ctx.clearRect(0, 0, canvas.value.width, canvas.value.height)
// 1. Background Nebula
nebulas.forEach(n => {
n.update(canvas.value!.width, canvas.value!.height)
n.draw()
})
// 2. Shooting Stars
if (Math.random() < SHOOTING_STAR_CHANCE) {
shootingStars.push(new ShootingStar(canvas.value.width, canvas.value.height))
}
for (let i = shootingStars.length - 1; i >= 0; i--) {
shootingStars[i].update()
shootingStars[i].draw()
if (shootingStars[i].life <= 0 ||
shootingStars[i].x < -100 || shootingStars[i].x > canvas.value.width + 100 ||
shootingStars[i].y > canvas.value.height + 100) {
shootingStars.splice(i, 1)
}
}
// 3. Airflow
ctx.globalCompositeOperation = 'lighter'
for (let i = airflowParticles.length - 1; i >= 0; i--) {
airflowParticles[i].update()
airflowParticles[i].draw()
if (airflowParticles[i].life <= 0) {
airflowParticles.splice(i, 1)
}
}
ctx.globalCompositeOperation = 'source-over'
// 4. Particles
particles.forEach(p => {
p.update(canvas.value!.width, canvas.value!.height)
p.draw()
})
// 5. Explosions
for (let i = explosions.length - 1; i >= 0; i--) {
explosions[i].update()
explosions[i].draw()
if (explosions[i].life <= 0) {
explosions.splice(i, 1)
}
}
// 6. Vignette
const w = canvas.value.width
const h = canvas.value.height
const radius = Math.max(w, h) * 0.8
const gradient = ctx.createRadialGradient(w/2, h/2, radius * 0.5, w/2, h/2, radius)
gradient.addColorStop(0, 'rgba(0,0,0,0)')
gradient.addColorStop(1, 'rgba(0,0,0,0.4)')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, w, h)
animationId = requestAnimationFrame(animate)
}
animate()
}
onMounted(() => {
init()
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationId)
window.removeEventListener('resize', () => {})
})
</script>
<style scoped>
.interactive-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: auto;
z-index: 0;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
</style>