1. 礼物特效

This commit is contained in:
李琦
2026-08-03 11:24:55 +08:00
parent 7831e0d11a
commit ab2cafeb1b
12 changed files with 1686 additions and 35 deletions

View 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>

View 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>