1. 礼物特效
This commit is contained in:
@@ -5,14 +5,18 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { GIFT_LABEL_MAP } from '@/utils/giftTypes'
|
||||
import { isIOS } from '@/composables/useAndroidOptimize'
|
||||
|
||||
const emit = defineEmits(['play-start'])
|
||||
const canvasRef = ref(null)
|
||||
|
||||
const MAX_PARTICLES = 280
|
||||
const MAX_RINGS = 12
|
||||
const MAX_PARTICLES = isIOS ? 100 : 280
|
||||
const MAX_RINGS = isIOS ? 6 : 12
|
||||
const MAX_TEXTS = 3
|
||||
const DPR_CAP = 2
|
||||
const DPR_CAP = isIOS ? 1.5 : 2
|
||||
const SHADOW_MUL = isIOS ? 0.2 : 1
|
||||
const SKIP_AURA = isIOS
|
||||
const PARTICLE_SCALE = isIOS ? 0.55 : 1
|
||||
|
||||
const GIFT_STYLE = {
|
||||
'520': {
|
||||
@@ -187,17 +191,24 @@ let running = false
|
||||
let playQueue = []
|
||||
let queueTimer = null
|
||||
let secondaryTimers = []
|
||||
/** 当前正在播放的礼物(用于连续同礼物叠加 ×N) */
|
||||
let activeMeta = null
|
||||
|
||||
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
|
||||
// iOS:优先用 visualViewport,避免地址栏伸缩导致尺寸错乱
|
||||
const vv = typeof window !== 'undefined' ? window.visualViewport : null
|
||||
const cssW = Math.max(1, Math.floor((vv && vv.width) || window.innerWidth || canvas.clientWidth || 1))
|
||||
const cssH = Math.max(1, Math.floor((vv && vv.height) || window.innerHeight || canvas.clientHeight || 1))
|
||||
W = cssW
|
||||
H = cssH
|
||||
canvas.style.width = `${cssW}px`
|
||||
canvas.style.height = `${cssH}px`
|
||||
canvas.width = Math.max(1, Math.floor(W * dpr))
|
||||
canvas.height = Math.max(1, Math.floor(H * dpr))
|
||||
ctx = canvas.getContext('2d')
|
||||
ctx = canvas.getContext('2d', { alpha: true })
|
||||
if (!ctx) return
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.scale(dpr, dpr)
|
||||
@@ -230,16 +241,18 @@ 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
|
||||
const fontSize = Math.max(12, Math.min(W * style.fontSizeRatio || 48, style.fontSizeMax))
|
||||
return {
|
||||
text,
|
||||
text: base,
|
||||
baseText: base,
|
||||
giftType,
|
||||
count: n,
|
||||
countRevealed: false,
|
||||
countRevealAt: 0,
|
||||
rollFrom: 1,
|
||||
birthTime: now,
|
||||
duration: style.duration,
|
||||
fontSize: Math.min(W * style.fontSizeRatio, style.fontSizeMax) * sizeBoost,
|
||||
fontSize,
|
||||
glowColor: style.glowColor,
|
||||
strokeColor: style.strokeColor,
|
||||
peakScale: style.peakScale,
|
||||
@@ -251,6 +264,43 @@ function createTextEffect(giftType, now, count = 1) {
|
||||
}
|
||||
}
|
||||
|
||||
const COUNT_ROLL_MS = 900
|
||||
|
||||
/** ×N 数字滚动:支持中途叠加时从 rollFrom 滚到新目标 */
|
||||
function scrolledCount(mt, now) {
|
||||
const target = Math.max(1, mt.count || 1)
|
||||
if (!mt.countRevealed) return 1
|
||||
const from = Math.max(1, Number(mt.rollFrom) || 1)
|
||||
const elapsed = now - (mt.countRevealAt || now)
|
||||
const t = Math.min(1, Math.max(0, elapsed / COUNT_ROLL_MS))
|
||||
const e = 1 - (1 - t) ** 3
|
||||
return Math.max(from, Math.round(from + (target - from) * e))
|
||||
}
|
||||
|
||||
function drawMilestoneLine(context, text, fontSize, glowBlur, gradient, strokeColor, glowColor, y) {
|
||||
const blur = Math.max(0, glowBlur * SHADOW_MUL)
|
||||
context.shadowColor = glowColor
|
||||
context.shadowBlur = blur
|
||||
context.strokeStyle = strokeColor
|
||||
context.lineWidth = fontSize * 0.065
|
||||
context.lineJoin = 'round'
|
||||
context.font = `700 ${fontSize}px "Ma Shan Zheng", "Great Vibes", cursive, serif`
|
||||
context.textAlign = 'center'
|
||||
context.textBaseline = 'middle'
|
||||
context.strokeText(text, 0, y)
|
||||
const textGrad = context.createLinearGradient(0, y - fontSize * 0.55, 0, y + fontSize * 0.55)
|
||||
textGrad.addColorStop(0, gradient[0])
|
||||
textGrad.addColorStop(0.35, gradient[1])
|
||||
textGrad.addColorStop(0.7, gradient[2])
|
||||
textGrad.addColorStop(1, gradient[3])
|
||||
context.fillStyle = textGrad
|
||||
context.shadowBlur = blur * 1.5
|
||||
context.fillText(text, 0, y)
|
||||
context.shadowBlur = 0
|
||||
context.fillStyle = 'rgba(255,255,255,0.4)'
|
||||
context.fillText(text, -fontSize * 0.018, y - fontSize * 0.028)
|
||||
}
|
||||
|
||||
function textProgress(t, now) {
|
||||
return Math.min((now - t.birthTime) / t.duration, 1)
|
||||
}
|
||||
@@ -350,7 +400,7 @@ function spawnParticles(giftType, now, wave = 0) {
|
||||
const mode = style.particleMode
|
||||
const palette = style.palette
|
||||
const glowPalette = style.glowPalette
|
||||
const boost = wave > 0 ? 0.75 : 1
|
||||
const boost = (wave > 0 ? 0.75 : 1) * PARTICLE_SCALE
|
||||
|
||||
if (mode === 'heartBurst') {
|
||||
const n = Math.floor(42 * boost)
|
||||
@@ -541,24 +591,81 @@ function playOne(item) {
|
||||
clearVisuals()
|
||||
const now = performance.now()
|
||||
const style = GIFT_STYLE[giftType]
|
||||
texts.push(createTextEffect(giftType, now, count))
|
||||
const mt = createTextEffect(giftType, now, count)
|
||||
texts.push(mt)
|
||||
activeMeta = { giftType, name, mt }
|
||||
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)
|
||||
spawnShockwave(W / 2, H * 0.46, now, style.glowColor, style.showHeartOutline && !isIOS ? 3 : 2)
|
||||
flashAlpha = isIOS ? Math.min(style.flash, 0.45) : style.flash
|
||||
flashDecay = flashAlpha / Math.max(1, style.flashMs / 16)
|
||||
flashColor = style.flashColor
|
||||
if (style.secondaryBurst) {
|
||||
if (style.secondaryBurst && !isIOS) {
|
||||
const t = setTimeout(() => {
|
||||
spawnParticles(giftType, performance.now(), 1)
|
||||
ensureLoop()
|
||||
}, 280)
|
||||
secondaryTimers.push(t)
|
||||
}
|
||||
emit('play-start', { giftType, name, count })
|
||||
emit('play-start', { giftType, name, count, phase: 'main' })
|
||||
if (count > 1) {
|
||||
const revealAt = Math.min(style.duration * 0.42, 1100)
|
||||
const t = setTimeout(() => {
|
||||
if (!texts.includes(mt) || mt.countRevealed) return
|
||||
mt.countRevealed = true
|
||||
mt.rollFrom = 1
|
||||
mt.countRevealAt = performance.now()
|
||||
emit('play-start', { giftType, name, count: mt.count, phase: 'mult' })
|
||||
ensureLoop()
|
||||
}, revealAt)
|
||||
secondaryTimers.push(t)
|
||||
}
|
||||
ensureLoop()
|
||||
return style.duration + 280
|
||||
}
|
||||
|
||||
/** 连续送同一礼物:叠加到当前特效 / 队列末项,显示 ×N */
|
||||
function tryBumpSame(payload) {
|
||||
const add = Math.max(1, payload.count || 1)
|
||||
// 叠加队列末尾相同项
|
||||
const last = playQueue[playQueue.length - 1]
|
||||
if (last && last.giftType === payload.giftType && last.name === payload.name) {
|
||||
last.count += add
|
||||
return true
|
||||
}
|
||||
// 叠加正在播放的特效
|
||||
if (
|
||||
activeMeta
|
||||
&& activeMeta.giftType === payload.giftType
|
||||
&& activeMeta.name === payload.name
|
||||
&& texts.includes(activeMeta.mt)
|
||||
) {
|
||||
const mt = activeMeta.mt
|
||||
const now = performance.now()
|
||||
const prevShown = mt.countRevealed ? scrolledCount(mt, now) : 1
|
||||
mt.count += add
|
||||
if (!mt.countRevealed) {
|
||||
mt.countRevealed = true
|
||||
mt.rollFrom = 1
|
||||
mt.countRevealAt = now
|
||||
} else {
|
||||
mt.rollFrom = prevShown
|
||||
mt.countRevealAt = now
|
||||
}
|
||||
const elapsed = now - mt.birthTime
|
||||
mt.duration = Math.max(mt.duration, elapsed + 1600)
|
||||
spawnParticles(payload.giftType, now, 1)
|
||||
emit('play-start', {
|
||||
giftType: payload.giftType,
|
||||
name: payload.name,
|
||||
count: mt.count,
|
||||
phase: 'mult',
|
||||
})
|
||||
ensureLoop()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function scheduleNextFromQueue(delayMs) {
|
||||
clearTimeout(queueTimer)
|
||||
queueTimer = setTimeout(() => {
|
||||
@@ -570,6 +677,7 @@ function scheduleNextFromQueue(delayMs) {
|
||||
function drainPlayQueue() {
|
||||
if (queueTimer) return
|
||||
if (!playQueue.length) {
|
||||
activeMeta = null
|
||||
stopIfIdle()
|
||||
return
|
||||
}
|
||||
@@ -578,10 +686,11 @@ function drainPlayQueue() {
|
||||
if (playQueue.length) scheduleNextFromQueue(wait)
|
||||
}
|
||||
|
||||
/** 入队串行播放,保证同一时间只展示一个礼物特效 */
|
||||
/** 入队串行播放;连续同礼物则合并为 ×N */
|
||||
const play = (item) => {
|
||||
const payload = normalizePlayItem(item)
|
||||
if (!payload) return
|
||||
if (tryBumpSame(payload)) return
|
||||
playQueue.push(payload)
|
||||
if (texts.length || particles.length || rings.length || flashAlpha > 0.01) {
|
||||
if (!queueTimer) {
|
||||
@@ -624,6 +733,20 @@ function animate(timestamp) {
|
||||
|
||||
ctx.clearRect(0, 0, W, H)
|
||||
|
||||
// 特效期间压暗背景(纯色半透明,不加模糊)
|
||||
if (texts.length) {
|
||||
const mt = texts[0]
|
||||
const op = textOpacity(mt, now)
|
||||
const dim = Math.min(0.4, 0.12 + op * 0.28)
|
||||
if (dim > 0.02) {
|
||||
ctx.save()
|
||||
ctx.globalAlpha = dim
|
||||
ctx.fillStyle = '#0c0c10'
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
// rings
|
||||
for (let i = rings.length - 1; i >= 0; i -= 1) {
|
||||
const r = rings[i]
|
||||
@@ -641,7 +764,7 @@ function animate(timestamp) {
|
||||
ctx.strokeStyle = r.color
|
||||
ctx.lineWidth = r.lineWidth * (1 - p * 0.5)
|
||||
ctx.shadowColor = r.color
|
||||
ctx.shadowBlur = 12
|
||||
ctx.shadowBlur = 12 * SHADOW_MUL
|
||||
ctx.beginPath()
|
||||
ctx.ellipse(r.x, r.y, radius, radius * 0.72, 0, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
@@ -667,7 +790,7 @@ function animate(timestamp) {
|
||||
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
|
||||
ctx.shadowBlur = (p.kind === 'spark' || p.kind === 'star' ? 10 : 6) * SHADOW_MUL
|
||||
|
||||
if (p.kind === 'heart') {
|
||||
ctx.translate(x, y)
|
||||
@@ -705,6 +828,7 @@ function animate(timestamp) {
|
||||
for (let i = texts.length - 1; i >= 0; i -= 1) {
|
||||
const mt = texts[i]
|
||||
if (textProgress(mt, now) >= 1) {
|
||||
if (activeMeta && activeMeta.mt === mt) activeMeta = null
|
||||
texts.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
@@ -717,18 +841,21 @@ function animate(timestamp) {
|
||||
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()
|
||||
// soft aura behind text(iOS 跳过径向渐变,避免卡顿/错乱)
|
||||
if (mt.aura && !SKIP_AURA) {
|
||||
const auraR = Math.max(0.1, Number(fontSize) * 1.8 * Math.max(0, scale) || 0)
|
||||
if (auraR > 0.1) {
|
||||
ctx.save()
|
||||
ctx.globalAlpha = opacity * 0.22 * glowIntensity
|
||||
const aura = ctx.createRadialGradient(cx, cy, 0, cx, cy, auraR)
|
||||
aura.addColorStop(0, mt.glowColor)
|
||||
aura.addColorStop(1, 'rgba(0,0,0,0)')
|
||||
ctx.fillStyle = aura
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, auraR, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
ctx.save()
|
||||
@@ -737,27 +864,40 @@ function animate(timestamp) {
|
||||
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)
|
||||
const showMult = mt.countRevealed && mt.count > 1
|
||||
const mainY = showMult ? -fontSize * 0.42 : 0
|
||||
drawMilestoneLine(
|
||||
ctx,
|
||||
mt.baseText || mt.text,
|
||||
fontSize,
|
||||
glowBlur,
|
||||
mt.gradient,
|
||||
mt.strokeColor,
|
||||
mt.glowColor,
|
||||
mainY,
|
||||
)
|
||||
if (showMult) {
|
||||
const n = scrolledCount(mt, now)
|
||||
const multSize = fontSize * 0.72
|
||||
const multY = fontSize * 0.55
|
||||
// 滚动过程轻微上浮
|
||||
const rollP = Math.min(1, (now - (mt.countRevealAt || now)) / COUNT_ROLL_MS)
|
||||
const pop = 0.85 + 0.15 * (1 - (1 - rollP) ** 2)
|
||||
ctx.save()
|
||||
ctx.translate(0, multY)
|
||||
ctx.scale(pop, pop)
|
||||
drawMilestoneLine(
|
||||
ctx,
|
||||
`×${n}`,
|
||||
multSize,
|
||||
glowBlur * 0.85,
|
||||
mt.gradient,
|
||||
mt.strokeColor,
|
||||
mt.glowColor,
|
||||
0,
|
||||
)
|
||||
ctx.restore()
|
||||
}
|
||||
ctx.restore()
|
||||
|
||||
if (mt.showHeartOutline && heartOutlineOpacity(mt, now) > 0.01) {
|
||||
@@ -824,6 +964,8 @@ const onResize = () => resizeCanvas()
|
||||
onMounted(() => {
|
||||
resizeCanvas()
|
||||
window.addEventListener('resize', onResize)
|
||||
window.visualViewport?.addEventListener('resize', onResize)
|
||||
window.visualViewport?.addEventListener('scroll', onResize)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -831,9 +973,12 @@ onUnmounted(() => {
|
||||
clearSecondaryTimers()
|
||||
queueTimer = null
|
||||
playQueue = []
|
||||
activeMeta = null
|
||||
running = false
|
||||
if (rafId) cancelAnimationFrame(rafId)
|
||||
window.removeEventListener('resize', onResize)
|
||||
window.visualViewport?.removeEventListener('resize', onResize)
|
||||
window.visualViewport?.removeEventListener('scroll', onResize)
|
||||
texts = []
|
||||
particles = []
|
||||
rings = []
|
||||
@@ -845,10 +990,17 @@ defineExpose({ play, playSequence })
|
||||
<style scoped>
|
||||
.gift-effect-canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
z-index: 46;
|
||||
pointer-events: none;
|
||||
/* iOS:独立合成层,避免和左侧点赞 canvas / transform 滚动打架 */
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,7 +8,13 @@
|
||||
>
|
||||
<span class="gift-feed-name">{{ item.name }}</span>
|
||||
<span class="gift-feed-verb">送出</span>
|
||||
<span class="gift-feed-gift calligraphy-strong">{{ item.label }}</span>
|
||||
<span class="gift-feed-gift-wrap">
|
||||
<span class="gift-feed-gift calligraphy-strong">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.showCount"
|
||||
class="gift-feed-mult calligraphy-strong"
|
||||
>×{{ item.displayCount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
@@ -20,15 +26,13 @@ import { GIFT_LABEL_MAP } from '@/utils/giftTypes'
|
||||
|
||||
const MAX_ITEMS = 5
|
||||
const LIFE_MS = 8000
|
||||
const ROLL_MS = 900
|
||||
const items = ref([])
|
||||
const timers = new Map()
|
||||
const rollTimers = 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 baseLabel = (giftType) => GIFT_LABEL_MAP[giftType] || giftType
|
||||
|
||||
const removeItem = (id) => {
|
||||
const t = timers.get(id)
|
||||
@@ -36,37 +40,120 @@ const removeItem = (id) => {
|
||||
clearTimeout(t)
|
||||
timers.delete(id)
|
||||
}
|
||||
const r = rollTimers.get(id)
|
||||
if (r) {
|
||||
cancelAnimationFrame(r)
|
||||
rollTimers.delete(id)
|
||||
}
|
||||
items.value = items.value.filter((it) => it.id !== id)
|
||||
}
|
||||
|
||||
/** 追加一条(与当前特效同步);最新在底部,旧的上移;最多 5 条;8 秒后向左淡出 */
|
||||
const resetLife = (id) => {
|
||||
const old = timers.get(id)
|
||||
if (old) clearTimeout(old)
|
||||
timers.set(id, setTimeout(() => removeItem(id), LIFE_MS))
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加一条:主特效阶段只显示礼物名;×N 稍后通过 revealCount 补上
|
||||
*/
|
||||
const push = (name, giftType, count = 1) => {
|
||||
const n = String(name || '').trim() || '匿名'
|
||||
const type = String(giftType || '').trim()
|
||||
if (!type) return
|
||||
if (!type) return ''
|
||||
const c = Math.max(1, Number(count) || 1)
|
||||
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),
|
||||
count: c,
|
||||
label: baseLabel(type),
|
||||
showCount: false,
|
||||
displayCount: 1,
|
||||
}
|
||||
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)
|
||||
resetLife(id)
|
||||
return id
|
||||
}
|
||||
|
||||
const animateRoll = (id, target, fromOverride) => {
|
||||
const it0 = items.value.find((x) => x.id === id)
|
||||
if (!it0) return
|
||||
const from = fromOverride != null ? fromOverride : (it0.displayCount || 1)
|
||||
it0.displayCount = from
|
||||
items.value = [...items.value]
|
||||
const start = performance.now()
|
||||
const tick = (now) => {
|
||||
const it = items.value.find((x) => x.id === id)
|
||||
if (!it) {
|
||||
rollTimers.delete(id)
|
||||
return
|
||||
}
|
||||
const t = Math.min(1, (now - start) / ROLL_MS)
|
||||
const e = 1 - (1 - t) ** 3
|
||||
it.displayCount = Math.max(from, Math.round(from + (target - from) * e))
|
||||
items.value = [...items.value]
|
||||
if (t < 1) {
|
||||
rollTimers.set(id, requestAnimationFrame(tick))
|
||||
} else {
|
||||
it.displayCount = target
|
||||
items.value = [...items.value]
|
||||
rollTimers.delete(id)
|
||||
}
|
||||
}
|
||||
const prev = rollTimers.get(id)
|
||||
if (prev) cancelAnimationFrame(prev)
|
||||
rollTimers.set(id, requestAnimationFrame(tick))
|
||||
}
|
||||
|
||||
/** 同一次特效中稍后出现 ×N,并数字滚动;已显示则可继续叠加 */
|
||||
const revealCount = (giftType, count) => {
|
||||
const c = Math.max(1, Number(count) || 1)
|
||||
if (c <= 1) return
|
||||
const list = items.value
|
||||
for (let i = list.length - 1; i >= 0; i -= 1) {
|
||||
const it = list[i]
|
||||
if (giftType && it.giftType !== giftType) continue
|
||||
const from = it.showCount ? (it.displayCount || 1) : 1
|
||||
it.count = c
|
||||
it.showCount = true
|
||||
resetLife(it.id)
|
||||
items.value = [...list]
|
||||
animateRoll(it.id, c, from)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭特效时:连续同礼物合并到最后一条 */
|
||||
const bumpOrPush = (name, giftType) => {
|
||||
const n = String(name || '').trim() || '匿名'
|
||||
const type = String(giftType || '').trim()
|
||||
if (!type) return
|
||||
const last = items.value[items.value.length - 1]
|
||||
if (last && last.name === n && last.giftType === type) {
|
||||
last.count += 1
|
||||
last.showCount = true
|
||||
resetLife(last.id)
|
||||
items.value = [...items.value]
|
||||
animateRoll(last.id, last.count, last.displayCount || 1)
|
||||
return last.id
|
||||
}
|
||||
return push(n, type, 1)
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
timers.forEach((t) => clearTimeout(t))
|
||||
timers.clear()
|
||||
rollTimers.forEach((r) => cancelAnimationFrame(r))
|
||||
rollTimers.clear()
|
||||
})
|
||||
|
||||
defineExpose({ push })
|
||||
defineExpose({ push, revealCount, bumpOrPush })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -77,6 +164,10 @@ defineExpose({ push })
|
||||
z-index: 48;
|
||||
max-width: min(72vw, 260px);
|
||||
pointer-events: none;
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.gift-feed-list {
|
||||
@@ -88,12 +179,12 @@ defineExpose({ push })
|
||||
|
||||
.gift-feed-row {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 6px;
|
||||
max-width: 100%;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border-radius: 14px;
|
||||
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);
|
||||
@@ -116,6 +207,13 @@ defineExpose({ push })
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.gift-feed-gift-wrap {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.gift-feed-gift {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.06em;
|
||||
@@ -123,6 +221,14 @@ defineExpose({ push })
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.gift-feed-mult {
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.04em;
|
||||
color: #fff0c2;
|
||||
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.25);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.gift-feed-item-enter-active {
|
||||
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { isIOS } from '@/composables/useAndroidOptimize'
|
||||
|
||||
const canvasRef = ref(null)
|
||||
|
||||
/** 同屏上限 */
|
||||
const MAX_HEARTS = 55
|
||||
const MAX_SPARKS = 120
|
||||
const MAX_HEARTS = isIOS ? 36 : 55
|
||||
const MAX_SPARKS = isIOS ? 80 : 120
|
||||
/** 异常保护 */
|
||||
const MAX_TOTAL = 5000
|
||||
/** 每次「点赞效果」间隔 */
|
||||
@@ -25,9 +26,9 @@ const RISE_SPEED_MIN = 70
|
||||
const RISE_SPEED_MAX = 150
|
||||
const DRIFT_AMP_MIN = 8
|
||||
const DRIFT_AMP_MAX = 45
|
||||
const GLOW_BLUR = 10
|
||||
const GLOW_BLUR = isIOS ? 4 : 10
|
||||
const GLOW_ALPHA = 0.55
|
||||
const DPR_CAP = 2
|
||||
const DPR_CAP = isIOS ? 1.5 : 2
|
||||
|
||||
/** 多色爱心:主色 */
|
||||
const HEART_COLORS = [
|
||||
@@ -466,7 +467,12 @@ defineExpose({ burst })
|
||||
bottom: 0;
|
||||
width: min(52vw, 320px);
|
||||
height: 62vh;
|
||||
height: 62dvh;
|
||||
z-index: 45;
|
||||
pointer-events: none;
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user