466 lines
11 KiB
Vue
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>
|