1. 礼物特效
This commit is contained in:
854
vite-tailwindcss/src/components/GiftEffect.vue
Normal file
854
vite-tailwindcss/src/components/GiftEffect.vue
Normal file
@@ -0,0 +1,854 @@
|
||||
<template>
|
||||
<canvas ref="canvasRef" class="gift-effect-canvas" aria-hidden="true" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { GIFT_LABEL_MAP } from '@/utils/giftTypes'
|
||||
|
||||
const emit = defineEmits(['play-start'])
|
||||
const canvasRef = ref(null)
|
||||
|
||||
const MAX_PARTICLES = 280
|
||||
const MAX_RINGS = 12
|
||||
const MAX_TEXTS = 3
|
||||
const DPR_CAP = 2
|
||||
|
||||
const GIFT_STYLE = {
|
||||
'520': {
|
||||
duration: 3000,
|
||||
fontSizeRatio: 0.24,
|
||||
fontSizeMax: 120,
|
||||
peakScale: 1.42,
|
||||
flash: 0.75,
|
||||
flashMs: 220,
|
||||
flashColor: 'rgba(255, 80, 130, 0.55)',
|
||||
particleMode: 'heartBurst',
|
||||
palette: ['#ff2442', '#ff2d55', '#ff6b8a', '#ff4770', '#ff1a3d'],
|
||||
glowPalette: ['#ff8da6', '#ffb0c4', '#ff6b9d'],
|
||||
gradient: ['#ffb3c6', '#ff2d55', '#e61a3d', '#b3001e'],
|
||||
glowColor: '#ff6b9d',
|
||||
strokeColor: 'rgba(255,255,255,0.85)',
|
||||
showHeartOutline: false,
|
||||
orb: false,
|
||||
aura: true,
|
||||
secondaryBurst: true,
|
||||
},
|
||||
'1314': {
|
||||
duration: 4000,
|
||||
fontSizeRatio: 0.26,
|
||||
fontSizeMax: 136,
|
||||
peakScale: 1.55,
|
||||
flash: 0.9,
|
||||
flashMs: 320,
|
||||
flashColor: 'rgba(255, 220, 100, 0.6)',
|
||||
particleMode: 'goldenRain',
|
||||
palette: ['#ffd700', '#ffcc00', '#ffb800', '#ffe44d', '#ffab00'],
|
||||
glowPalette: ['#ffe88d', '#fff0a8', '#fff5cc'],
|
||||
gradient: ['#fff8c0', '#ffd700', '#ffb800', '#e6a000'],
|
||||
glowColor: '#ffe88d',
|
||||
strokeColor: 'rgba(255,255,255,0.92)',
|
||||
showHeartOutline: true,
|
||||
orb: true,
|
||||
aura: true,
|
||||
secondaryBurst: true,
|
||||
},
|
||||
forever: {
|
||||
duration: 3600,
|
||||
fontSizeRatio: 0.17,
|
||||
fontSizeMax: 78,
|
||||
peakScale: 1.32,
|
||||
flash: 0.6,
|
||||
flashMs: 260,
|
||||
flashColor: 'rgba(196, 30, 58, 0.45)',
|
||||
particleMode: 'dualWave',
|
||||
palette: ['#c41e3a', '#9b1b30', '#e63946', '#ff6b6b', '#ff2442'],
|
||||
glowPalette: ['#ff8a9a', '#ffb3c1'],
|
||||
gradient: ['#ffb3c1', '#c41e3a', '#9b1b30', '#6b0f1a'],
|
||||
glowColor: '#ff4d6d',
|
||||
strokeColor: 'rgba(255,240,240,0.88)',
|
||||
showHeartOutline: false,
|
||||
orb: false,
|
||||
aura: true,
|
||||
secondaryBurst: true,
|
||||
},
|
||||
bald: {
|
||||
duration: 3800,
|
||||
fontSizeRatio: 0.17,
|
||||
fontSizeMax: 78,
|
||||
peakScale: 1.26,
|
||||
flash: 0.45,
|
||||
flashMs: 300,
|
||||
flashColor: 'rgba(245, 240, 224, 0.5)',
|
||||
particleMode: 'softPetals',
|
||||
palette: ['#f5f0e0', '#e8e4d9', '#d4c5a0', '#c9b896', '#fffaf0'],
|
||||
glowPalette: ['#fffaf0', '#f0e6c8'],
|
||||
gradient: ['#ffffff', '#f5f0e0', '#d4c5a0', '#b8a878'],
|
||||
glowColor: '#f5f0e0',
|
||||
strokeColor: 'rgba(180,160,100,0.55)',
|
||||
showHeartOutline: false,
|
||||
orb: false,
|
||||
aura: true,
|
||||
secondaryBurst: false,
|
||||
},
|
||||
concentric: {
|
||||
duration: 3800,
|
||||
fontSizeRatio: 0.17,
|
||||
fontSizeMax: 78,
|
||||
peakScale: 1.34,
|
||||
flash: 0.55,
|
||||
flashMs: 240,
|
||||
flashColor: 'rgba(212, 165, 116, 0.45)',
|
||||
particleMode: 'ring',
|
||||
palette: ['#d4a574', '#c9896b', '#e8b4b8', '#b76e79', '#f0d5b8'],
|
||||
glowPalette: ['#f0d5b8', '#f5c6c8'],
|
||||
gradient: ['#f5dcc4', '#d4a574', '#b76e79', '#8b4a52'],
|
||||
glowColor: '#e8c4a0',
|
||||
strokeColor: 'rgba(255,255,255,0.8)',
|
||||
showHeartOutline: false,
|
||||
orb: true,
|
||||
aura: true,
|
||||
secondaryBurst: true,
|
||||
},
|
||||
heaven: {
|
||||
duration: 3600,
|
||||
fontSizeRatio: 0.17,
|
||||
fontSizeMax: 78,
|
||||
peakScale: 1.3,
|
||||
flash: 0.5,
|
||||
flashMs: 280,
|
||||
flashColor: 'rgba(90, 160, 255, 0.4)',
|
||||
particleMode: 'stars',
|
||||
palette: ['#3d8bfd', '#5ba3ff', '#89c2ff', '#4a90a4', '#cce4ff'],
|
||||
glowPalette: ['#a8d4ff', '#cce4ff'],
|
||||
gradient: ['#cce4ff', '#5ba3ff', '#3d8bfd', '#1a5bb5'],
|
||||
glowColor: '#7eb6ff',
|
||||
strokeColor: 'rgba(255,255,255,0.88)',
|
||||
showHeartOutline: false,
|
||||
orb: true,
|
||||
aura: true,
|
||||
secondaryBurst: true,
|
||||
},
|
||||
moisten: {
|
||||
duration: 3800,
|
||||
fontSizeRatio: 0.17,
|
||||
fontSizeMax: 78,
|
||||
peakScale: 1.28,
|
||||
flash: 0.42,
|
||||
flashMs: 300,
|
||||
flashColor: 'rgba(64, 180, 166, 0.4)',
|
||||
particleMode: 'ripple',
|
||||
palette: ['#2a9d8f', '#40b4a6', '#5ecfc2', '#1d7874', '#9ee8de'],
|
||||
glowPalette: ['#9ee8de', '#c5f5ef'],
|
||||
gradient: ['#c5f5ef', '#40b4a6', '#2a9d8f', '#165e5a'],
|
||||
glowColor: '#7dd3c7',
|
||||
strokeColor: 'rgba(255,255,255,0.85)',
|
||||
showHeartOutline: false,
|
||||
orb: false,
|
||||
aura: true,
|
||||
secondaryBurst: true,
|
||||
},
|
||||
match: {
|
||||
duration: 3600,
|
||||
fontSizeRatio: 0.17,
|
||||
fontSizeMax: 78,
|
||||
peakScale: 1.36,
|
||||
flash: 0.58,
|
||||
flashMs: 260,
|
||||
flashColor: 'rgba(232, 93, 117, 0.45)',
|
||||
particleMode: 'twin',
|
||||
palette: ['#e85d75', '#f0a060', '#ff8a65', '#d4a574', '#ffb3c1'],
|
||||
glowPalette: ['#ffb3c1', '#ffd4a8'],
|
||||
gradient: ['#ffd4a8', '#e85d75', '#d4a574', '#b5475a'],
|
||||
glowColor: '#f0a060',
|
||||
strokeColor: 'rgba(255,255,255,0.88)',
|
||||
showHeartOutline: false,
|
||||
orb: false,
|
||||
aura: true,
|
||||
secondaryBurst: true,
|
||||
},
|
||||
}
|
||||
|
||||
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]
|
||||
const rand = (min, max) => min + Math.random() * (max - min)
|
||||
|
||||
let ctx = null
|
||||
let W = 0
|
||||
let H = 0
|
||||
let dpr = 1
|
||||
let texts = []
|
||||
let particles = []
|
||||
let rings = []
|
||||
let flashAlpha = 0
|
||||
let flashDecay = 0
|
||||
let flashColor = 'rgba(255,255,255,0.5)'
|
||||
let rafId = 0
|
||||
let running = false
|
||||
let playQueue = []
|
||||
let queueTimer = null
|
||||
let secondaryTimers = []
|
||||
|
||||
function resizeCanvas() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP)
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
W = rect.width
|
||||
H = rect.height
|
||||
canvas.width = Math.max(1, Math.floor(W * dpr))
|
||||
canvas.height = Math.max(1, Math.floor(H * dpr))
|
||||
ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.scale(dpr, dpr)
|
||||
}
|
||||
|
||||
function drawHeartPath(context, cx, cy, size) {
|
||||
const s = size
|
||||
context.beginPath()
|
||||
context.moveTo(cx, cy + s * 0.55)
|
||||
context.bezierCurveTo(cx - s * 0.85, cy + s * 0.25, cx - s * 0.95, cy - s * 0.2, cx - s * 0.45, cy - s * 1.05)
|
||||
context.bezierCurveTo(cx, cy - s * 0.55, cx + s * 0.45, cy - s * 1.05, cx + s * 0.45, cy - s * 1.05)
|
||||
context.bezierCurveTo(cx + s * 0.95, cy - s * 0.2, cx + s * 0.85, cy + s * 0.25, cx, cy + s * 0.55)
|
||||
context.closePath()
|
||||
}
|
||||
|
||||
function drawPetal(context, x, y, size, rot) {
|
||||
context.save()
|
||||
context.translate(x, y)
|
||||
context.rotate(rot)
|
||||
context.beginPath()
|
||||
context.moveTo(0, -size)
|
||||
context.quadraticCurveTo(size * 0.7, -size * 0.2, 0, size)
|
||||
context.quadraticCurveTo(-size * 0.7, -size * 0.2, 0, -size)
|
||||
context.closePath()
|
||||
context.fill()
|
||||
context.restore()
|
||||
}
|
||||
|
||||
function createTextEffect(giftType, now, count = 1) {
|
||||
const style = GIFT_STYLE[giftType] || GIFT_STYLE['520']
|
||||
const base = GIFT_LABEL_MAP[giftType] || giftType
|
||||
const n = Math.max(1, Number(count) || 1)
|
||||
const text = n > 1 ? `${base}×${n}` : base
|
||||
// 带倍数时略缩小字号,避免出屏
|
||||
const sizeBoost = n > 1 ? 0.88 : 1
|
||||
return {
|
||||
text,
|
||||
giftType,
|
||||
count: n,
|
||||
birthTime: now,
|
||||
duration: style.duration,
|
||||
fontSize: Math.min(W * style.fontSizeRatio, style.fontSizeMax) * sizeBoost,
|
||||
glowColor: style.glowColor,
|
||||
strokeColor: style.strokeColor,
|
||||
peakScale: style.peakScale,
|
||||
gradient: style.gradient,
|
||||
showHeartOutline: style.showHeartOutline,
|
||||
orb: style.orb,
|
||||
aura: style.aura,
|
||||
wobble: rand(-0.04, 0.04),
|
||||
}
|
||||
}
|
||||
|
||||
function textProgress(t, now) {
|
||||
return Math.min((now - t.birthTime) / t.duration, 1)
|
||||
}
|
||||
|
||||
function textScale(t, now) {
|
||||
const p = textProgress(t, now)
|
||||
if (p < 0.18) {
|
||||
const u = p / 0.18
|
||||
return t.peakScale * (1 - (1 - u) ** 4 + Math.sin(u * Math.PI) * 0.18 * (1 - u))
|
||||
}
|
||||
if (p < 0.55) return t.peakScale - (t.peakScale - 1.05) * ((p - 0.18) / 0.37) * 0.45
|
||||
return 1.05 - ((p - 0.55) / 0.45) * 0.28
|
||||
}
|
||||
|
||||
function textOpacity(t, now) {
|
||||
const p = textProgress(t, now)
|
||||
if (p < 0.55) return 1
|
||||
if (p < 0.82) return 1 - ((p - 0.55) / 0.27) * 0.45
|
||||
return 0.55 * (1 - ((p - 0.82) / 0.18) ** 2)
|
||||
}
|
||||
|
||||
function textGlow(t, now) {
|
||||
const p = textProgress(t, now)
|
||||
if (p < 0.2) return p / 0.2
|
||||
if (p < 0.5) return 1
|
||||
if (p < 0.78) return 1 - ((p - 0.5) / 0.28) * 0.55
|
||||
return 0.45 * (1 - (p - 0.78) / 0.22)
|
||||
}
|
||||
|
||||
function heartOutlineScale(t, now) {
|
||||
if (!t.showHeartOutline) return 0
|
||||
const p = textProgress(t, now)
|
||||
if (p < 0.12) return (p / 0.12) * 0.75
|
||||
if (p < 0.45) return 0.75 + ((p - 0.12) / 0.33) * 1.35
|
||||
if (p < 0.75) return 2.1
|
||||
return 2.1 * (1 - (p - 0.75) / 0.25)
|
||||
}
|
||||
|
||||
function heartOutlineOpacity(t, now) {
|
||||
if (!t.showHeartOutline) return 0
|
||||
const p = textProgress(t, now)
|
||||
if (p < 0.12) return (p / 0.12) * 0.75
|
||||
if (p < 0.4) return 0.75
|
||||
if (p < 0.7) return 0.75 * (1 - (p - 0.4) / 0.3)
|
||||
return 0
|
||||
}
|
||||
|
||||
function pushParticle(p) {
|
||||
if (particles.length >= MAX_PARTICLES) particles.shift()
|
||||
particles.push(p)
|
||||
}
|
||||
|
||||
function pushRing(r) {
|
||||
if (rings.length >= MAX_RINGS) rings.shift()
|
||||
rings.push(r)
|
||||
}
|
||||
|
||||
function makeParticle(x, y, now, palette, glowPalette, opts = {}) {
|
||||
const angle = opts.angle != null ? opts.angle : rand(0, Math.PI * 2)
|
||||
const speed = opts.speed != null ? opts.speed : rand(50, 180)
|
||||
pushParticle({
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed - rand(20, 80),
|
||||
birthTime: now,
|
||||
life: opts.life || rand(700, 1600),
|
||||
color: pick(palette),
|
||||
glow: pick(glowPalette),
|
||||
size: opts.size || rand(2.5, 7),
|
||||
kind: opts.kind || 'dot',
|
||||
gravity: opts.gravity != null ? opts.gravity : 70,
|
||||
rot: rand(0, Math.PI * 2),
|
||||
rotSpeed: rand(-2.5, 2.5),
|
||||
drift: rand(-18, 18),
|
||||
})
|
||||
}
|
||||
|
||||
function spawnShockwave(cx, cy, now, color, count = 2) {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
pushRing({
|
||||
x: cx,
|
||||
y: cy,
|
||||
birthTime: now + i * 90,
|
||||
life: 900 + i * 120,
|
||||
maxR: Math.min(W, H) * (0.28 + i * 0.12),
|
||||
color,
|
||||
lineWidth: 3.5 - i * 0.8,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function spawnParticles(giftType, now, wave = 0) {
|
||||
const style = GIFT_STYLE[giftType] || GIFT_STYLE['520']
|
||||
const cx = W / 2
|
||||
const cy = H * 0.46
|
||||
const mode = style.particleMode
|
||||
const palette = style.palette
|
||||
const glowPalette = style.glowPalette
|
||||
const boost = wave > 0 ? 0.75 : 1
|
||||
|
||||
if (mode === 'heartBurst') {
|
||||
const n = Math.floor(42 * boost)
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
makeParticle(cx + rand(-50, 50), cy + rand(-40, 40), now, palette, glowPalette, {
|
||||
speed: rand(80, 220),
|
||||
size: rand(8, 18),
|
||||
kind: 'heart',
|
||||
life: rand(1100, 2000),
|
||||
gravity: 55,
|
||||
})
|
||||
}
|
||||
for (let i = 0; i < 24 * boost; i += 1) {
|
||||
makeParticle(cx, cy, now, palette, glowPalette, {
|
||||
speed: rand(100, 260),
|
||||
size: rand(1.5, 4),
|
||||
kind: 'spark',
|
||||
life: rand(400, 900),
|
||||
})
|
||||
}
|
||||
} else if (mode === 'goldenRain') {
|
||||
for (let i = 0; i < 48 * boost; i += 1) {
|
||||
makeParticle(cx + rand(-60, 60), cy + rand(-50, 50), now, palette, glowPalette, {
|
||||
speed: rand(90, 240),
|
||||
size: rand(7, 16),
|
||||
kind: 'heart',
|
||||
life: rand(1200, 2200),
|
||||
gravity: 45,
|
||||
})
|
||||
}
|
||||
for (let i = 0; i < 30 * boost; i += 1) {
|
||||
makeParticle(cx + rand(-W * 0.2, W * 0.2), cy - H * 0.08, now, palette, glowPalette, {
|
||||
angle: rand(-Math.PI * 0.7, -Math.PI * 0.3),
|
||||
speed: rand(40, 120),
|
||||
size: rand(2, 5),
|
||||
kind: 'spark',
|
||||
gravity: 90,
|
||||
life: rand(800, 1500),
|
||||
})
|
||||
}
|
||||
} else if (mode === 'dualWave') {
|
||||
for (let side = -1; side <= 1; side += 2) {
|
||||
for (let i = 0; i < 22 * boost; i += 1) {
|
||||
makeParticle(cx + side * W * 0.2 + rand(-30, 30), cy + rand(-50, 50), now, palette, glowPalette, {
|
||||
speed: rand(70, 180),
|
||||
size: rand(6, 14),
|
||||
kind: 'heart',
|
||||
life: rand(1000, 1900),
|
||||
})
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 20 * boost; i += 1) {
|
||||
makeParticle(cx, cy, now, palette, glowPalette, { kind: 'spark', speed: rand(60, 160) })
|
||||
}
|
||||
} else if (mode === 'softPetals') {
|
||||
for (let i = 0; i < 36 * boost; i += 1) {
|
||||
makeParticle(cx + rand(-100, 100), cy + rand(-70, 70), now, palette, glowPalette, {
|
||||
speed: rand(25, 90),
|
||||
size: rand(8, 18),
|
||||
kind: 'petal',
|
||||
life: rand(1400, 2400),
|
||||
gravity: 20,
|
||||
})
|
||||
}
|
||||
for (let i = 0; i < 18 * boost; i += 1) {
|
||||
makeParticle(cx, cy, now, palette, glowPalette, {
|
||||
speed: rand(30, 80),
|
||||
size: rand(2, 4),
|
||||
kind: 'spark',
|
||||
gravity: 15,
|
||||
life: rand(1000, 1800),
|
||||
})
|
||||
}
|
||||
} else if (mode === 'ring') {
|
||||
for (let ring = 1; ring <= 4; ring += 1) {
|
||||
const r = 28 + ring * 32
|
||||
const count = 12 + ring * 5
|
||||
for (let i = 0; i < count * boost; i += 1) {
|
||||
const a = (i / count) * Math.PI * 2 + ring * 0.2
|
||||
makeParticle(cx + Math.cos(a) * r, cy + Math.sin(a) * r * 0.72, now, palette, glowPalette, {
|
||||
angle: a + rand(-0.15, 0.15),
|
||||
speed: rand(40, 110),
|
||||
size: rand(3, 7),
|
||||
kind: ring % 2 ? 'dot' : 'spark',
|
||||
life: rand(900, 1600),
|
||||
})
|
||||
}
|
||||
}
|
||||
spawnShockwave(cx, cy, now, style.glowColor, 3)
|
||||
} else if (mode === 'stars') {
|
||||
for (let i = 0; i < 40 * boost; i += 1) {
|
||||
makeParticle(cx + rand(-W * 0.38, W * 0.38), cy + rand(-H * 0.28, H * 0.28), now, palette, glowPalette, {
|
||||
speed: rand(15, 70),
|
||||
size: rand(2, 6),
|
||||
kind: 'star',
|
||||
life: rand(1000, 2200),
|
||||
gravity: 12,
|
||||
})
|
||||
}
|
||||
for (let i = 0; i < 16 * boost; i += 1) {
|
||||
makeParticle(cx, cy, now, palette, glowPalette, {
|
||||
speed: rand(60, 150),
|
||||
size: rand(5, 12),
|
||||
kind: 'heart',
|
||||
life: rand(900, 1600),
|
||||
})
|
||||
}
|
||||
} else if (mode === 'ripple') {
|
||||
for (let waveIdx = 0; waveIdx < 4; waveIdx += 1) {
|
||||
const count = 18
|
||||
for (let i = 0; i < count * boost; i += 1) {
|
||||
const a = (i / count) * Math.PI * 2
|
||||
makeParticle(cx, cy, now, palette, glowPalette, {
|
||||
angle: a,
|
||||
speed: 35 + waveIdx * 38 + rand(0, 25),
|
||||
life: 1000 + waveIdx * 180,
|
||||
size: rand(2.5, 5.5),
|
||||
gravity: 8,
|
||||
kind: waveIdx % 2 ? 'spark' : 'dot',
|
||||
})
|
||||
}
|
||||
}
|
||||
spawnShockwave(cx, cy, now, style.glowColor, 3)
|
||||
} else if (mode === 'twin') {
|
||||
for (let side = -1; side <= 1; side += 2) {
|
||||
for (let i = 0; i < 20 * boost; i += 1) {
|
||||
makeParticle(cx + side * W * 0.14 + rand(-28, 28), cy + rand(-40, 40), now, palette, glowPalette, {
|
||||
speed: rand(70, 180),
|
||||
size: rand(7, 15),
|
||||
kind: 'heart',
|
||||
life: rand(1100, 1900),
|
||||
})
|
||||
}
|
||||
}
|
||||
spawnShockwave(cx, cy, now, style.glowColor, 2)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLoop() {
|
||||
if (running) return
|
||||
running = true
|
||||
rafId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
function stopIfIdle() {
|
||||
if (texts.length || particles.length || rings.length || flashAlpha > 0.01 || playQueue.length || queueTimer) return
|
||||
running = false
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = 0
|
||||
}
|
||||
if (ctx && W && H) ctx.clearRect(0, 0, W, H)
|
||||
}
|
||||
|
||||
function clearSecondaryTimers() {
|
||||
secondaryTimers.forEach(clearTimeout)
|
||||
secondaryTimers = []
|
||||
}
|
||||
|
||||
function clearVisuals() {
|
||||
texts = []
|
||||
particles = []
|
||||
rings = []
|
||||
flashAlpha = 0
|
||||
clearSecondaryTimers()
|
||||
if (ctx && W && H) ctx.clearRect(0, 0, W, H)
|
||||
}
|
||||
|
||||
function normalizePlayItem(item) {
|
||||
if (typeof item === 'string') {
|
||||
return { giftType: item, name: '', count: 1 }
|
||||
}
|
||||
if (!item || typeof item !== 'object') return null
|
||||
const giftType = item.giftType || item.gift_type || item.type
|
||||
if (!giftType || !GIFT_STYLE[giftType]) return null
|
||||
return {
|
||||
giftType,
|
||||
name: String(item.name || '').trim() || '匿名',
|
||||
count: Math.max(1, Number(item.count) || 1),
|
||||
}
|
||||
}
|
||||
|
||||
function playOne(item) {
|
||||
const payload = normalizePlayItem(item)
|
||||
if (!payload) return 0
|
||||
const { giftType, name, count } = payload
|
||||
if (!W || !H) resizeCanvas()
|
||||
clearVisuals()
|
||||
const now = performance.now()
|
||||
const style = GIFT_STYLE[giftType]
|
||||
texts.push(createTextEffect(giftType, now, count))
|
||||
spawnParticles(giftType, now, 0)
|
||||
spawnShockwave(W / 2, H * 0.46, now, style.glowColor, style.showHeartOutline ? 3 : 2)
|
||||
flashAlpha = style.flash
|
||||
flashDecay = style.flash / Math.max(1, style.flashMs / 16)
|
||||
flashColor = style.flashColor
|
||||
if (style.secondaryBurst) {
|
||||
const t = setTimeout(() => {
|
||||
spawnParticles(giftType, performance.now(), 1)
|
||||
ensureLoop()
|
||||
}, 280)
|
||||
secondaryTimers.push(t)
|
||||
}
|
||||
emit('play-start', { giftType, name, count })
|
||||
ensureLoop()
|
||||
return style.duration + 280
|
||||
}
|
||||
|
||||
function scheduleNextFromQueue(delayMs) {
|
||||
clearTimeout(queueTimer)
|
||||
queueTimer = setTimeout(() => {
|
||||
queueTimer = null
|
||||
drainPlayQueue()
|
||||
}, Math.max(80, delayMs))
|
||||
}
|
||||
|
||||
function drainPlayQueue() {
|
||||
if (queueTimer) return
|
||||
if (!playQueue.length) {
|
||||
stopIfIdle()
|
||||
return
|
||||
}
|
||||
const next = playQueue.shift()
|
||||
const wait = playOne(next)
|
||||
if (playQueue.length) scheduleNextFromQueue(wait)
|
||||
}
|
||||
|
||||
/** 入队串行播放,保证同一时间只展示一个礼物特效 */
|
||||
const play = (item) => {
|
||||
const payload = normalizePlayItem(item)
|
||||
if (!payload) return
|
||||
playQueue.push(payload)
|
||||
if (texts.length || particles.length || rings.length || flashAlpha > 0.01) {
|
||||
if (!queueTimer) {
|
||||
const active = texts[0]
|
||||
const remain = active
|
||||
? Math.max(200, active.duration - (performance.now() - active.birthTime) + 200)
|
||||
: 400
|
||||
scheduleNextFromQueue(remain)
|
||||
}
|
||||
return
|
||||
}
|
||||
drainPlayQueue()
|
||||
}
|
||||
|
||||
/**
|
||||
* 按序列错开回放(进场用)——严格一个接一个
|
||||
* @param {Array<string|{giftType:string,name?:string,count?:number}>} items
|
||||
* @param {{ max?: number }} opts
|
||||
*/
|
||||
const playSequence = (items, opts = {}) => {
|
||||
const max = opts.max != null ? opts.max : 10
|
||||
const list = (Array.isArray(items) ? items : [])
|
||||
.map(normalizePlayItem)
|
||||
.filter(Boolean)
|
||||
.slice(0, max)
|
||||
if (!list.length) return
|
||||
clearTimeout(queueTimer)
|
||||
queueTimer = null
|
||||
playQueue = list.slice()
|
||||
drainPlayQueue()
|
||||
}
|
||||
|
||||
function animate(timestamp) {
|
||||
if (!running) return
|
||||
const now = timestamp || performance.now()
|
||||
if (!ctx) {
|
||||
rafId = requestAnimationFrame(animate)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, W, H)
|
||||
|
||||
// rings
|
||||
for (let i = rings.length - 1; i >= 0; i -= 1) {
|
||||
const r = rings[i]
|
||||
const age = now - r.birthTime
|
||||
if (age < 0) continue
|
||||
const p = Math.min(age / r.life, 1)
|
||||
if (p >= 1) {
|
||||
rings.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
const radius = r.maxR * (1 - (1 - p) ** 2.2)
|
||||
const alpha = (1 - p) * 0.55
|
||||
ctx.save()
|
||||
ctx.globalAlpha = alpha
|
||||
ctx.strokeStyle = r.color
|
||||
ctx.lineWidth = r.lineWidth * (1 - p * 0.5)
|
||||
ctx.shadowColor = r.color
|
||||
ctx.shadowBlur = 12
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(r.x, r.y, radius, radius * 0.72, 0, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// particles
|
||||
for (let i = particles.length - 1; i >= 0; i -= 1) {
|
||||
const p = particles[i]
|
||||
const progress = Math.min((now - p.birthTime) / p.life, 1)
|
||||
if (progress >= 1) {
|
||||
particles.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
const age = (now - p.birthTime) / 1000
|
||||
const x = p.x + p.vx * age + Math.sin(age * 3 + p.rot) * (p.drift || 0) * 0.35
|
||||
const y = p.y + p.vy * age + 0.5 * p.gravity * age * age
|
||||
const opacity = progress < 0.15 ? progress / 0.15 : 1 - ((progress - 0.15) / 0.85) ** 1.4
|
||||
const size = p.size * (progress < 0.2 ? 0.6 + progress * 2 : 1 - progress * 0.45)
|
||||
const rot = p.rot + p.rotSpeed * age
|
||||
|
||||
ctx.save()
|
||||
ctx.globalAlpha = Math.max(0, opacity)
|
||||
ctx.fillStyle = p.color
|
||||
ctx.shadowColor = p.glow || p.color
|
||||
ctx.shadowBlur = p.kind === 'spark' || p.kind === 'star' ? 10 : 6
|
||||
|
||||
if (p.kind === 'heart') {
|
||||
ctx.translate(x, y)
|
||||
ctx.rotate(rot * 0.35)
|
||||
drawHeartPath(ctx, 0, 0, size)
|
||||
ctx.fill()
|
||||
} else if (p.kind === 'petal') {
|
||||
ctx.fillStyle = p.color
|
||||
drawPetal(ctx, x, y, size, rot)
|
||||
} else if (p.kind === 'star') {
|
||||
ctx.beginPath()
|
||||
for (let s = 0; s < 8; s += 1) {
|
||||
const rr = s % 2 === 0 ? size : size * 0.38
|
||||
const a = (s / 8) * Math.PI * 2 - Math.PI / 2 + rot
|
||||
const px = x + Math.cos(a) * rr
|
||||
const py = y + Math.sin(a) * rr
|
||||
if (s === 0) ctx.moveTo(px, py)
|
||||
else ctx.lineTo(px, py)
|
||||
}
|
||||
ctx.closePath()
|
||||
ctx.fill()
|
||||
} else if (p.kind === 'spark') {
|
||||
ctx.translate(x, y)
|
||||
ctx.rotate(rot)
|
||||
ctx.fillRect(-size * 0.35, -size * 1.4, size * 0.7, size * 2.8)
|
||||
} else {
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, size, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// milestone texts
|
||||
for (let i = texts.length - 1; i >= 0; i -= 1) {
|
||||
const mt = texts[i]
|
||||
if (textProgress(mt, now) >= 1) {
|
||||
texts.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
const cx = W / 2
|
||||
const cy = H * 0.46
|
||||
const scale = textScale(mt, now)
|
||||
const opacity = textOpacity(mt, now)
|
||||
const glowIntensity = textGlow(mt, now)
|
||||
const fontSize = mt.fontSize
|
||||
if (opacity < 0.01) continue
|
||||
const wobble = Math.sin((now - mt.birthTime) / 180) * mt.wobble
|
||||
|
||||
// soft aura behind text
|
||||
if (mt.aura) {
|
||||
ctx.save()
|
||||
ctx.globalAlpha = opacity * 0.22 * glowIntensity
|
||||
const aura = ctx.createRadialGradient(cx, cy, 0, cx, cy, fontSize * 1.8 * scale)
|
||||
aura.addColorStop(0, mt.glowColor)
|
||||
aura.addColorStop(1, 'rgba(0,0,0,0)')
|
||||
ctx.fillStyle = aura
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, fontSize * 1.8 * scale, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.save()
|
||||
ctx.globalAlpha = opacity
|
||||
ctx.translate(cx, cy)
|
||||
ctx.rotate(wobble)
|
||||
ctx.scale(scale, scale)
|
||||
const glowBlur = fontSize * 0.4 * glowIntensity
|
||||
ctx.shadowColor = mt.glowColor
|
||||
ctx.shadowBlur = glowBlur
|
||||
ctx.strokeStyle = mt.strokeColor
|
||||
ctx.lineWidth = fontSize * 0.065
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.font = `700 ${fontSize}px "Ma Shan Zheng", "Great Vibes", cursive, serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.strokeText(mt.text, 0, 0)
|
||||
const textGrad = ctx.createLinearGradient(0, -fontSize * 0.55, 0, fontSize * 0.55)
|
||||
const g = mt.gradient
|
||||
textGrad.addColorStop(0, g[0])
|
||||
textGrad.addColorStop(0.35, g[1])
|
||||
textGrad.addColorStop(0.7, g[2])
|
||||
textGrad.addColorStop(1, g[3])
|
||||
ctx.fillStyle = textGrad
|
||||
ctx.shadowBlur = glowBlur * 1.5
|
||||
ctx.fillText(mt.text, 0, 0)
|
||||
ctx.shadowBlur = 0
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.4)'
|
||||
ctx.fillText(mt.text, -fontSize * 0.018, -fontSize * 0.028)
|
||||
ctx.restore()
|
||||
|
||||
if (mt.showHeartOutline && heartOutlineOpacity(mt, now) > 0.01) {
|
||||
const outlineScale = heartOutlineScale(mt, now)
|
||||
const outlineOpacity = heartOutlineOpacity(mt, now)
|
||||
const outlineSize = fontSize * 1.7 * outlineScale
|
||||
ctx.save()
|
||||
ctx.globalAlpha = outlineOpacity
|
||||
ctx.translate(cx, cy)
|
||||
ctx.strokeStyle = mt.glowColor
|
||||
ctx.lineWidth = outlineSize * 0.035
|
||||
ctx.shadowColor = mt.glowColor
|
||||
ctx.shadowBlur = outlineSize * 0.28
|
||||
drawHeartPath(ctx, 0, 0, outlineSize)
|
||||
ctx.stroke()
|
||||
ctx.lineWidth = outlineSize * 0.065
|
||||
ctx.shadowBlur = outlineSize * 0.45
|
||||
ctx.globalAlpha = outlineOpacity * 0.35
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
if (mt.orb && opacity > 0.18) {
|
||||
const numOrbs = mt.showHeartOutline ? 16 : 12
|
||||
const orbRadius = fontSize * (1.05 + 0.15 * glowIntensity)
|
||||
const age = (now - mt.birthTime) / 1000
|
||||
for (let j = 0; j < numOrbs; j += 1) {
|
||||
const angle = age * 1.9 + j * ((Math.PI * 2) / numOrbs)
|
||||
const orbX = cx + Math.cos(angle) * orbRadius
|
||||
const orbY = cy + Math.sin(angle) * orbRadius * 0.68
|
||||
const orbSize = fontSize * 0.035 * (1 + Math.sin(age * 3 + j) * 0.45)
|
||||
ctx.save()
|
||||
ctx.globalAlpha = opacity * (0.55 + Math.sin(age * 2.4 + j) * 0.3)
|
||||
ctx.fillStyle = '#fffbe6'
|
||||
ctx.shadowColor = mt.glowColor
|
||||
ctx.shadowBlur = orbSize * 6
|
||||
ctx.beginPath()
|
||||
ctx.arc(orbX, orbY, Math.max(2, orbSize), 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// colored flash on top
|
||||
if (flashAlpha > 0.01) {
|
||||
ctx.save()
|
||||
ctx.globalAlpha = Math.min(flashAlpha, 1)
|
||||
ctx.fillStyle = flashColor
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
ctx.restore()
|
||||
flashAlpha = Math.max(0, flashAlpha - flashDecay)
|
||||
}
|
||||
|
||||
if (texts.length || particles.length || rings.length || flashAlpha > 0.01 || playQueue.length || queueTimer) {
|
||||
rafId = requestAnimationFrame(animate)
|
||||
} else {
|
||||
stopIfIdle()
|
||||
}
|
||||
}
|
||||
|
||||
const onResize = () => resizeCanvas()
|
||||
|
||||
onMounted(() => {
|
||||
resizeCanvas()
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(queueTimer)
|
||||
clearSecondaryTimers()
|
||||
queueTimer = null
|
||||
playQueue = []
|
||||
running = false
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
window.removeEventListener('resize', onResize)
|
||||
texts = []
|
||||
particles = []
|
||||
rings = []
|
||||
})
|
||||
|
||||
defineExpose({ play, playSequence })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gift-effect-canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 46;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
143
vite-tailwindcss/src/components/GiftFeed.vue
Normal file
143
vite-tailwindcss/src/components/GiftFeed.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="gift-feed" aria-live="polite">
|
||||
<TransitionGroup name="gift-feed-item" tag="div" class="gift-feed-list">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="gift-feed-row"
|
||||
>
|
||||
<span class="gift-feed-name">{{ item.name }}</span>
|
||||
<span class="gift-feed-verb">送出</span>
|
||||
<span class="gift-feed-gift calligraphy-strong">{{ item.label }}</span>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { GIFT_LABEL_MAP } from '@/utils/giftTypes'
|
||||
|
||||
const MAX_ITEMS = 5
|
||||
const LIFE_MS = 8000
|
||||
const items = ref([])
|
||||
const timers = new Map()
|
||||
let seq = 0
|
||||
|
||||
const formatLabel = (giftType, count = 1) => {
|
||||
const base = GIFT_LABEL_MAP[giftType] || giftType
|
||||
const n = Math.max(1, Number(count) || 1)
|
||||
return n > 1 ? `${base}×${n}` : base
|
||||
}
|
||||
|
||||
const removeItem = (id) => {
|
||||
const t = timers.get(id)
|
||||
if (t) {
|
||||
clearTimeout(t)
|
||||
timers.delete(id)
|
||||
}
|
||||
items.value = items.value.filter((it) => it.id !== id)
|
||||
}
|
||||
|
||||
/** 追加一条(与当前特效同步);最新在底部,旧的上移;最多 5 条;8 秒后向左淡出 */
|
||||
const push = (name, giftType, count = 1) => {
|
||||
const n = String(name || '').trim() || '匿名'
|
||||
const type = String(giftType || '').trim()
|
||||
if (!type) return
|
||||
seq += 1
|
||||
const id = `${Date.now()}-${seq}`
|
||||
const next = {
|
||||
id,
|
||||
name: n,
|
||||
giftType: type,
|
||||
count: Math.max(1, Number(count) || 1),
|
||||
label: formatLabel(type, count),
|
||||
}
|
||||
while (items.value.length >= MAX_ITEMS) {
|
||||
removeItem(items.value[0].id)
|
||||
}
|
||||
items.value = [...items.value, next]
|
||||
const timer = setTimeout(() => removeItem(id), LIFE_MS)
|
||||
timers.set(id, timer)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
timers.forEach((t) => clearTimeout(t))
|
||||
timers.clear()
|
||||
})
|
||||
|
||||
defineExpose({ push })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gift-feed {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
bottom: calc(88px + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 48;
|
||||
max-width: min(72vw, 260px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gift-feed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gift-feed-row {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 6px;
|
||||
max-width: 100%;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(70, 70, 75, 0.28);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.gift-feed-name {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
max-width: 5.5em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gift-feed-verb {
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.gift-feed-gift {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.06em;
|
||||
color: #ffe8a8;
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.gift-feed-item-enter-active {
|
||||
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||
}
|
||||
.gift-feed-item-leave-active {
|
||||
transition: opacity 0.45s ease, transform 0.45s ease;
|
||||
}
|
||||
.gift-feed-item-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
.gift-feed-item-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-28px);
|
||||
}
|
||||
.gift-feed-item-move {
|
||||
transition: transform 0.35s ease;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user