<!-- 烟花效果 | 礼花效果 -->
|
<template>
|
<canvas
|
ref="canvasRef"
|
class="fixed top-0 left-0 z-[9999] w-full h-full pointer-events-none"
|
></canvas>
|
</template>
|
|
<script setup>
|
import { useEventListener } from '@vueuse/core'
|
import { mittBus } from '@/utils/sys'
|
import bp from '@/assets/images/ceremony/hb.png'
|
import sd from '@/assets/images/ceremony/sd.png'
|
import yd from '@/assets/images/ceremony/yd.png'
|
defineOptions({ name: 'ArtFireworksEffect' })
|
const CONFIG = {
|
// 性能相关配置
|
POOL_SIZE: 600,
|
// 对象池大小,影响同时存在的最大粒子数
|
PARTICLES_PER_BURST: 200,
|
// 每次爆炸的粒子数量,影响视觉效果密度
|
// 粒子尺寸配置
|
SIZES: {
|
RECTANGLE: { WIDTH: 24, HEIGHT: 12 },
|
// 矩形粒子尺寸
|
SQUARE: { SIZE: 12 },
|
// 正方形粒子尺寸
|
CIRCLE: { SIZE: 12 },
|
// 圆形粒子尺寸
|
TRIANGLE: { SIZE: 10 },
|
// 三角形粒子尺寸
|
OVAL: { WIDTH: 24, HEIGHT: 12 },
|
// 椭圆粒子尺寸
|
IMAGE: { WIDTH: 30, HEIGHT: 30 }
|
// 图片粒子尺寸
|
},
|
// 旋转动画配置
|
ROTATION: {
|
BASE_SPEED: 2,
|
// 基础旋转速度
|
RANDOM_SPEED: 3,
|
// 额外随机旋转速度范围
|
DECAY: 0.98
|
// 旋转速度衰减系数 (越小衰减越快)
|
},
|
// 物理效果配置
|
PHYSICS: {
|
GRAVITY: 0.525,
|
// 重力加速度,影响粒子下落速度
|
VELOCITY_THRESHOLD: 10,
|
// 速度阈值,超过时开始透明度衰减
|
OPACITY_DECAY: 0.02
|
// 透明度衰减速度,影响粒子消失快慢
|
},
|
// 粒子颜色配置 - 使用RGBA格式支持透明度
|
COLORS: [
|
'rgba(255, 68, 68, 1)',
|
// 红色系
|
'rgba(255, 68, 68, 0.9)',
|
'rgba(255, 68, 68, 0.8)',
|
'rgba(255, 116, 188, 1)',
|
// 粉色系
|
'rgba(255, 116, 188, 0.9)',
|
'rgba(255, 116, 188, 0.8)',
|
'rgba(68, 68, 255, 0.8)',
|
// 蓝色系
|
'rgba(92, 202, 56, 0.7)',
|
// 绿色系
|
'rgba(255, 68, 255, 0.8)',
|
// 紫色系
|
'rgba(68, 255, 255, 0.7)',
|
// 青色系
|
'rgba(255, 136, 68, 0.7)',
|
// 橙色系
|
'rgba(68, 136, 255, 1)',
|
// 蓝色系
|
'rgba(250, 198, 122, 0.8)'
|
// 金色系
|
],
|
// 粒子形状配置 - 矩形出现概率更高,营造更丰富的视觉效果
|
SHAPES: [
|
'rectangle',
|
'rectangle',
|
'rectangle',
|
'rectangle',
|
'rectangle',
|
'rectangle',
|
'rectangle',
|
'circle',
|
'triangle',
|
'oval'
|
]
|
}
|
const canvasRef = ref()
|
const ctx = ref(null)
|
class FireworkSystem {
|
constructor() {
|
this.particlePool = []
|
this.activeParticles = []
|
this.poolIndex = 0
|
this.imageCache = {}
|
this.animationId = 0
|
this.canvasWidth = 0
|
this.canvasHeight = 0
|
this.animate = () => {
|
this.updateParticles()
|
this.render()
|
this.animationId = requestAnimationFrame(this.animate)
|
}
|
this.initializePool()
|
}
|
/**
|
* 初始化对象池
|
* 预先创建指定数量的粒子对象,避免运行时频繁创建
|
*/
|
initializePool() {
|
for (let i = 0; i < CONFIG.POOL_SIZE; i++) {
|
this.particlePool.push(this.createParticle())
|
}
|
}
|
/**
|
* 创建一个新的粒子对象
|
* 返回初始化状态的粒子
|
*/
|
createParticle() {
|
return {
|
x: 0,
|
y: 0,
|
vx: 0,
|
vy: 0,
|
color: '',
|
rotation: 0,
|
rotationSpeed: 0,
|
scale: 1,
|
shape: 'circle',
|
opacity: 1,
|
active: false
|
}
|
}
|
/**
|
* 从对象池获取可用粒子 (性能优化版本)
|
* 使用循环索引而非Array.find(),时间复杂度从O(n)降至O(1)
|
* @returns 可用的粒子对象或null
|
*/
|
getAvailableParticle() {
|
for (let i = 0; i < CONFIG.POOL_SIZE; i++) {
|
const index = (this.poolIndex + i) % CONFIG.POOL_SIZE
|
const particle = this.particlePool[index]
|
if (!particle.active) {
|
this.poolIndex = (index + 1) % CONFIG.POOL_SIZE
|
particle.active = true
|
return particle
|
}
|
}
|
return null
|
}
|
/**
|
* 预加载单个图片资源
|
* @param url 图片URL
|
* @returns Promise<HTMLImageElement>
|
*/
|
async preloadImage(url) {
|
if (this.imageCache[url]) {
|
return this.imageCache[url]
|
}
|
return new Promise((resolve, reject) => {
|
const img = new Image()
|
img.crossOrigin = 'anonymous'
|
img.onload = () => {
|
this.imageCache[url] = img
|
resolve(img)
|
}
|
img.onerror = reject
|
img.src = url
|
})
|
}
|
/**
|
* 预加载所有需要的图片资源
|
* 在组件初始化时调用,确保图片ready
|
*/
|
async preloadAllImages() {
|
const imageUrls = [bp, sd, yd]
|
try {
|
await Promise.all(imageUrls.map((url) => this.preloadImage(url)))
|
} catch (error) {
|
console.error('Image preloading failed:', error)
|
}
|
}
|
/**
|
* 创建烟花爆炸效果
|
* @param imageUrl 可选的图片URL,如果提供则使用图片粒子
|
*/
|
createFirework(imageUrl) {
|
const startX = Math.random() * this.canvasWidth
|
const startY = this.canvasHeight
|
const availableShapes = imageUrl && this.imageCache[imageUrl] ? ['image'] : CONFIG.SHAPES
|
const particles = []
|
for (let i = 0; i < CONFIG.PARTICLES_PER_BURST; i++) {
|
const particle = this.getAvailableParticle()
|
if (!particle) continue
|
const angle = (Math.PI * i) / (CONFIG.PARTICLES_PER_BURST / 2)
|
const speed = (12 + Math.random() * 6) * 1.5
|
const spread = Math.random() * Math.PI * 2
|
particle.x = startX
|
particle.y = startY
|
particle.vx = Math.cos(angle) * Math.cos(spread) * speed * (Math.random() * 0.5 + 0.5)
|
particle.vy = Math.sin(angle) * speed - 15
|
particle.color = CONFIG.COLORS[Math.floor(Math.random() * CONFIG.COLORS.length)]
|
particle.rotation = Math.random() * 360
|
particle.rotationSpeed =
|
(Math.random() * CONFIG.ROTATION.RANDOM_SPEED + CONFIG.ROTATION.BASE_SPEED) *
|
(Math.random() > 0.5 ? 1 : -1)
|
particle.scale = 0.8 + Math.random() * 0.4
|
particle.shape = availableShapes[Math.floor(Math.random() * availableShapes.length)]
|
particle.opacity = 1
|
particle.imageUrl = imageUrl && this.imageCache[imageUrl] ? imageUrl : void 0
|
particles.push(particle)
|
}
|
this.activeParticles.push(...particles)
|
}
|
/**
|
* 更新所有粒子的物理状态 (性能优化版本)
|
* 包括位置、速度、旋转、透明度等
|
*/
|
updateParticles() {
|
const { GRAVITY, VELOCITY_THRESHOLD, OPACITY_DECAY } = CONFIG.PHYSICS
|
const { DECAY } = CONFIG.ROTATION
|
for (let i = this.activeParticles.length - 1; i >= 0; i--) {
|
const particle = this.activeParticles[i]
|
particle.x += particle.vx
|
particle.y += particle.vy
|
particle.vy += GRAVITY
|
particle.rotation += particle.rotationSpeed
|
particle.rotationSpeed *= DECAY
|
if (particle.vy > VELOCITY_THRESHOLD) {
|
particle.opacity -= OPACITY_DECAY
|
if (particle.opacity <= 0) {
|
this.recycleParticle(i)
|
continue
|
}
|
}
|
if (this.isOutOfBounds(particle)) {
|
this.recycleParticle(i)
|
}
|
}
|
}
|
/**
|
* 回收粒子到对象池
|
* @param index 要回收的粒子在活动数组中的索引
|
*/
|
recycleParticle(index) {
|
const particle = this.activeParticles[index]
|
particle.active = false
|
this.activeParticles.splice(index, 1)
|
}
|
/**
|
* 检查粒子是否超出屏幕边界
|
* @param particle 要检查的粒子
|
* @returns 是否超出边界
|
*/
|
isOutOfBounds(particle) {
|
const margin = 100
|
return (
|
particle.x < -margin ||
|
particle.x > this.canvasWidth + margin ||
|
particle.y < -margin ||
|
particle.y > this.canvasHeight + margin
|
)
|
}
|
/**
|
* 绘制单个粒子
|
* @param particle 要绘制的粒子对象
|
*/
|
drawParticle(particle) {
|
if (!ctx.value) return
|
ctx.value.save()
|
ctx.value.globalAlpha = particle.opacity
|
ctx.value.translate(particle.x, particle.y)
|
ctx.value.rotate((particle.rotation * Math.PI) / 180)
|
ctx.value.scale(particle.scale, particle.scale)
|
this.renderShape(particle)
|
ctx.value.restore()
|
}
|
/**
|
* 根据粒子类型渲染对应的形状
|
* @param particle 要渲染的粒子
|
*/
|
renderShape(particle) {
|
if (!ctx.value) return
|
const { SIZES } = CONFIG
|
ctx.value.fillStyle = particle.color
|
switch (particle.shape) {
|
case 'rectangle':
|
ctx.value.fillRect(
|
-SIZES.RECTANGLE.WIDTH / 2,
|
-SIZES.RECTANGLE.HEIGHT / 2,
|
SIZES.RECTANGLE.WIDTH,
|
SIZES.RECTANGLE.HEIGHT
|
)
|
break
|
case 'square':
|
ctx.value.fillRect(
|
-SIZES.SQUARE.SIZE / 2,
|
-SIZES.SQUARE.SIZE / 2,
|
SIZES.SQUARE.SIZE,
|
SIZES.SQUARE.SIZE
|
)
|
break
|
case 'circle':
|
ctx.value.beginPath()
|
ctx.value.arc(0, 0, SIZES.CIRCLE.SIZE / 2, 0, Math.PI * 2)
|
ctx.value.fill()
|
break
|
case 'triangle':
|
ctx.value.beginPath()
|
ctx.value.moveTo(0, -SIZES.TRIANGLE.SIZE)
|
ctx.value.lineTo(SIZES.TRIANGLE.SIZE, SIZES.TRIANGLE.SIZE)
|
ctx.value.lineTo(-SIZES.TRIANGLE.SIZE, SIZES.TRIANGLE.SIZE)
|
ctx.value.closePath()
|
ctx.value.fill()
|
break
|
case 'oval':
|
ctx.value.beginPath()
|
ctx.value.ellipse(0, 0, SIZES.OVAL.WIDTH / 2, SIZES.OVAL.HEIGHT / 2, 0, 0, Math.PI * 2)
|
ctx.value.fill()
|
break
|
case 'image':
|
this.renderImage(particle)
|
break
|
}
|
}
|
/**
|
* 渲染图片类型的粒子
|
* @param particle 包含图片URL的粒子对象
|
*/
|
renderImage(particle) {
|
if (!ctx.value || !particle.imageUrl) return
|
const img = this.imageCache[particle.imageUrl]
|
if (img?.complete) {
|
const { WIDTH, HEIGHT } = CONFIG.SIZES.IMAGE
|
ctx.value.drawImage(img, -WIDTH / 2, -HEIGHT / 2, WIDTH, HEIGHT)
|
}
|
}
|
/**
|
* 渲染所有活动粒子到画布
|
* 清除画布并重新绘制所有粒子
|
*/
|
render() {
|
if (!ctx.value || !canvasRef.value) return
|
ctx.value.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
|
ctx.value.globalCompositeOperation = 'lighter'
|
for (const particle of this.activeParticles) {
|
this.drawParticle(particle)
|
}
|
}
|
/**
|
* 更新画布尺寸缓存
|
* 在窗口大小改变时调用
|
* @param width 新的画布宽度
|
* @param height 新的画布高度
|
*/
|
updateCanvasSize(width, height) {
|
this.canvasWidth = width
|
this.canvasHeight = height
|
}
|
/**
|
* 启动动画循环
|
*/
|
start() {
|
this.animate()
|
}
|
/**
|
* 停止动画循环
|
* 在组件卸载时调用,避免内存泄漏
|
*/
|
stop() {
|
if (this.animationId) {
|
cancelAnimationFrame(this.animationId)
|
this.animationId = 0
|
}
|
}
|
/**
|
* 获取当前活动粒子数量
|
* 用于调试和性能监控
|
* @returns 活动粒子数量
|
*/
|
getActiveParticleCount() {
|
return this.activeParticles.length
|
}
|
}
|
const fireworkSystem = new FireworkSystem()
|
const handleKeyPress = (event) => {
|
const isFireworkShortcut =
|
(event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'p') ||
|
(event.metaKey && event.shiftKey && event.key.toLowerCase() === 'p')
|
if (isFireworkShortcut) {
|
event.preventDefault()
|
fireworkSystem.createFirework()
|
}
|
}
|
const resizeCanvas = () => {
|
if (!canvasRef.value) return
|
const { innerWidth, innerHeight } = window
|
canvasRef.value.width = innerWidth
|
canvasRef.value.height = innerHeight
|
fireworkSystem.updateCanvasSize(innerWidth, innerHeight)
|
}
|
const handleFireworkTrigger = (event) => {
|
const imageUrl = event
|
fireworkSystem.createFirework(imageUrl)
|
}
|
onMounted(async () => {
|
if (!canvasRef.value) return
|
ctx.value = canvasRef.value.getContext('2d')
|
if (!ctx.value) return
|
resizeCanvas()
|
await fireworkSystem.preloadAllImages()
|
fireworkSystem.start()
|
useEventListener(window, 'keydown', handleKeyPress)
|
useEventListener(window, 'resize', resizeCanvas)
|
mittBus.on('triggerFireworks', handleFireworkTrigger)
|
})
|
onUnmounted(() => {
|
fireworkSystem.stop()
|
mittBus.off('triggerFireworks', handleFireworkTrigger)
|
})
|
</script>
|