Files
hunli/vite-tailwindcss/src/components/CanvasEffects.vue
2026-08-01 14:55:21 +08:00

191 lines
4.7 KiB
Vue

<template>
<canvas ref="canvasRef" class="canvas-effects" aria-hidden="true"></canvas>
</template>
<script setup>
import { onMounted, onUnmounted, watch, ref } from 'vue'
const props = defineProps({
petalEnabled: { type: Boolean, default: true },
bubbleEnabled: { type: Boolean, default: true },
/** 预览/抽屉等遮罩时关闭粒子绘制 */
active: { type: Boolean, default: true },
})
const canvasRef = ref(null)
const petals = []
const hearts = []
const petalColors = ['#f7dce3', '#fbeee3', '#f3c9d6', '#efdcc8', '#f9e7d6']
const heartColors = ['#c9b08a', '#e2b3c0', '#d4af37', '#e8c9d2']
let ctx = null
let rafId = 0
let width = 0
let height = 0
let dpr = 1
let lastTime = 0
let resizeObserver = null
let reduceMotion = false
const rand = (min, max) => min + Math.random() * (max - min)
function resetPetal(p, initial = false) {
p.x = rand(0, width)
p.y = initial ? rand(-height, height) : rand(-80, -20)
p.size = rand(8, 22)
p.speed = rand(18, 42)
p.sway = rand(12, 34)
p.phase = rand(0, Math.PI * 2)
p.rotation = rand(0, Math.PI * 2)
p.rotationSpeed = rand(-0.9, 0.9)
p.opacity = rand(0.28, 0.7)
p.color = petalColors[Math.floor(rand(0, petalColors.length))]
}
function resetHeart(h, initial = false) {
h.x = rand(0, width)
h.y = initial ? rand(0, height) : height + rand(20, 90)
h.size = rand(10, 22)
h.speed = rand(12, 32)
h.sway = rand(8, 26)
h.phase = rand(0, Math.PI * 2)
h.opacity = rand(0.1, 0.32)
h.color = heartColors[Math.floor(rand(0, heartColors.length))]
}
function seedParticles() {
petals.length = 0
hearts.length = 0
for (let i = 0; i < 18; i += 1) {
const p = {}
resetPetal(p, true)
petals.push(p)
}
for (let i = 0; i < 12; i += 1) {
const h = {}
resetHeart(h, true)
hearts.push(h)
}
}
function resize() {
const canvas = canvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
width = Math.max(1, rect.width)
height = Math.max(1, rect.height)
dpr = Math.min(window.devicePixelRatio || 1, 2)
canvas.width = Math.round(width * dpr)
canvas.height = Math.round(height * dpr)
ctx = canvas.getContext('2d')
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
seedParticles()
}
function drawPetal(p, now) {
const x = p.x + Math.sin(now * 0.0014 + p.phase) * p.sway
ctx.save()
ctx.translate(x, p.y)
ctx.rotate(p.rotation)
ctx.globalAlpha = p.opacity
ctx.fillStyle = p.color
ctx.beginPath()
ctx.moveTo(0, -p.size * 0.56)
ctx.bezierCurveTo(-p.size * 0.58, -p.size * 0.18, -p.size * 0.36, p.size * 0.48, 0, p.size * 0.58)
ctx.bezierCurveTo(p.size * 0.36, p.size * 0.48, p.size * 0.58, -p.size * 0.18, 0, -p.size * 0.56)
ctx.fill()
ctx.strokeStyle = 'rgba(255,255,255,0.42)'
ctx.lineWidth = 0.8
ctx.beginPath()
ctx.moveTo(0, -p.size * 0.38)
ctx.lineTo(0, p.size * 0.42)
ctx.stroke()
ctx.restore()
}
function drawHeart(h, now) {
const x = h.x + Math.sin(now * 0.001 + h.phase) * h.sway
const s = h.size / 18
ctx.save()
ctx.translate(x, h.y)
ctx.scale(s, s)
ctx.globalAlpha = h.opacity
ctx.fillStyle = h.color
ctx.beginPath()
ctx.moveTo(0, 7)
ctx.bezierCurveTo(-18, -6, -10, -22, 0, -12)
ctx.bezierCurveTo(10, -22, 18, -6, 0, 7)
ctx.fill()
ctx.restore()
}
function tick(now) {
if (!ctx) return
if (!props.active || reduceMotion || (!props.petalEnabled && !props.bubbleEnabled)) {
ctx.clearRect(0, 0, width, height)
rafId = 0
return
}
const delta = Math.min(48, now - (lastTime || now)) / 1000
lastTime = now
ctx.clearRect(0, 0, width, height)
if (props.petalEnabled) {
for (const p of petals) {
p.y += p.speed * delta
p.rotation += p.rotationSpeed * delta
if (p.y > height + 80) resetPetal(p)
drawPetal(p, now)
}
}
if (props.bubbleEnabled) {
for (const h of hearts) {
h.y -= h.speed * delta
if (h.y < -80) resetHeart(h)
drawHeart(h, now)
}
}
rafId = requestAnimationFrame(tick)
}
function start() {
cancelAnimationFrame(rafId)
lastTime = 0
if (!props.active || reduceMotion || (!props.petalEnabled && !props.bubbleEnabled)) {
ctx?.clearRect(0, 0, width, height)
rafId = 0
return
}
rafId = requestAnimationFrame(tick)
}
onMounted(() => {
reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches || false
resize()
resizeObserver = new ResizeObserver(resize)
resizeObserver.observe(canvasRef.value)
start()
})
watch(() => [props.petalEnabled, props.bubbleEnabled, props.active], start)
onUnmounted(() => {
cancelAnimationFrame(rafId)
resizeObserver?.disconnect()
})
</script>
<style scoped>
.canvas-effects {
position: fixed;
inset: 0;
z-index: 1;
width: 100%;
height: 100vh;
pointer-events: none;
}
</style>