1. 礼物特效
This commit is contained in:
Binary file not shown.
@@ -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>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/** 礼物类型常量表(与后端 giftTypeWhitelist 一致) */
|
||||
export const GIFT_TYPES = [
|
||||
{ key: '520', label: '520' },
|
||||
{ key: '1314', label: '1314' },
|
||||
{ key: 'forever', label: '长长久久' },
|
||||
{ key: 'bald', label: '白头偕老' },
|
||||
{ key: 'concentric', label: '永结同心' },
|
||||
{ key: 'heaven', label: '天作之合' },
|
||||
{ key: 'moisten', label: '相濡以沫' },
|
||||
{ key: 'match', label: '佳偶天成' },
|
||||
{ key: '520', label: '520', preview: 'linear-gradient(135deg, #ff8da6 0%, #ff2d55 55%, #c41e3a 100%)' },
|
||||
{ key: '1314', label: '1314', preview: 'linear-gradient(135deg, #fff3b0 0%, #ffd700 50%, #e6a000 100%)' },
|
||||
{ key: 'forever', label: '长长久久', preview: 'linear-gradient(135deg, #ffb3c1 0%, #c41e3a 55%, #6b0f1a 100%)' },
|
||||
{ key: 'bald', label: '白头偕老', preview: 'linear-gradient(135deg, #ffffff 0%, #e8e4d9 45%, #b8a878 100%)' },
|
||||
{ key: 'concentric', label: '永结同心', preview: 'linear-gradient(135deg, #f5dcc4 0%, #d4a574 50%, #b76e79 100%)' },
|
||||
{ key: 'heaven', label: '天作之合', preview: 'linear-gradient(135deg, #cce4ff 0%, #5ba3ff 50%, #1a5bb5 100%)' },
|
||||
{ key: 'moisten', label: '相濡以沫', preview: 'linear-gradient(135deg, #c5f5ef 0%, #40b4a6 50%, #165e5a 100%)' },
|
||||
{ key: 'match', label: '佳偶天成', preview: 'linear-gradient(135deg, #ffd4a8 0%, #e85d75 50%, #b5475a 100%)' },
|
||||
]
|
||||
|
||||
export const GIFT_LABEL_MAP = Object.fromEntries(GIFT_TYPES.map((g) => [g.key, g.label]))
|
||||
|
||||
@@ -291,16 +291,16 @@
|
||||
<!-- 礼物面板 -->
|
||||
<div v-if="showGiftPanel" class="fixed inset-0 z-[60] bg-black/25 transition-opacity duration-200" @click="closeGiftPanel"></div>
|
||||
<div
|
||||
class="gift-panel fixed bottom-0 left-0 right-0 mx-auto w-full max-w-[420px] bg-[#fdfbf7] rounded-t-2xl shadow-xl transition-transform duration-300 z-[70] px-4 pt-4 pb-5 flex flex-col"
|
||||
class="gift-panel fixed bottom-0 left-0 right-0 mx-auto w-full max-w-[420px] rounded-t-2xl shadow-xl transition-transform duration-300 z-[70] px-3 pt-3 pb-4 flex flex-col"
|
||||
:class="{ 'pointer-events-none': !showGiftPanel }"
|
||||
:style="{ transform: showGiftPanel ? 'translateY(0)' : 'translateY(110%)' }"
|
||||
>
|
||||
<div class="gift-panel-handle" aria-hidden="true"></div>
|
||||
<div class="flex items-center justify-between mb-2.5 px-0.5">
|
||||
<span class="text-[13px] text-[#a88c6b] tracking-[0.25em]">送一份心意</span>
|
||||
<button type="button" class="p-1.5 text-[#a88c6b]/70 text-sm leading-none" @click="closeGiftPanel">✕</button>
|
||||
<div class="flex items-center justify-between mb-1.5 px-0.5">
|
||||
<span class="text-[12px] text-[#c8c4bc] tracking-[0.25em]">送一份心意</span>
|
||||
<button type="button" class="p-1 text-[#c8c4bc]/80 text-sm leading-none" @click="closeGiftPanel">✕</button>
|
||||
</div>
|
||||
<div class="gift-quota mb-2.5">
|
||||
<div class="gift-quota mb-1.5">
|
||||
<span v-if="giftQuota.remain > 0">还可赠送 <b>{{ giftQuota.remain }}</b> 次</span>
|
||||
<span v-else>赠送次数已用完,再点赞 <b>{{ giftQuota.likes_to_next }}</b> 次可多送 1 次</span>
|
||||
<span class="gift-quota-sub">已送 {{ giftQuota.gift_used }} / {{ giftQuota.gift_allow }} · 点赞 {{ giftQuota.my_likes }}(每 {{ giftQuota.likes_per_gift }} 赞 +1 次)</span>
|
||||
@@ -308,7 +308,7 @@
|
||||
<input
|
||||
v-model="giftForm.name"
|
||||
placeholder="您的姓名"
|
||||
class="w-full bg-white border-[0.5px] border-[#a88c6b]/25 rounded-lg px-3 py-2 text-[12px] mb-2.5 focus:outline-none focus:border-[#a88c6b]"
|
||||
class="gift-name-input w-full rounded-lg px-3 py-2 text-[12px] mb-1.5 focus:outline-none"
|
||||
/>
|
||||
<div class="gift-grid">
|
||||
<button
|
||||
@@ -319,8 +319,11 @@
|
||||
:disabled="giftBusy || giftQuota.remain <= 0"
|
||||
@click="handleSendGift(g.key)"
|
||||
>
|
||||
<span class="gift-item-label calligraphy-strong">{{ g.label }}</span>
|
||||
<span class="gift-item-count">{{ giftCounts[g.key] || 0 }}</span>
|
||||
<span
|
||||
class="gift-item-preview calligraphy-strong"
|
||||
:style="{ backgroundImage: g.preview }"
|
||||
>{{ g.label }}</span>
|
||||
<span class="gift-item-caption">{{ g.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1538,7 +1541,11 @@ const openGiftPanel = async () => {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const onGiftPlayStart = ({ giftType, name, count }) => {
|
||||
const onGiftPlayStart = ({ giftType, name, count, phase }) => {
|
||||
if (phase === 'mult') {
|
||||
giftFeedRef.value?.revealCount(giftType, count)
|
||||
return
|
||||
}
|
||||
giftFeedRef.value?.push(name, giftType, count)
|
||||
}
|
||||
|
||||
@@ -1552,7 +1559,14 @@ const replayGiftFeedOnly = (list) => {
|
||||
const it = items[i]
|
||||
i += 1
|
||||
giftFeedRef.value?.push(it.name, it.giftType, it.count)
|
||||
if (i < items.length) setTimeout(tick, 900)
|
||||
if (it.count > 1) {
|
||||
setTimeout(() => {
|
||||
giftFeedRef.value?.revealCount(it.giftType, it.count)
|
||||
if (i < items.length) setTimeout(tick, 500)
|
||||
}, 900)
|
||||
} else if (i < items.length) {
|
||||
setTimeout(tick, 900)
|
||||
}
|
||||
}
|
||||
tick()
|
||||
}
|
||||
@@ -1583,7 +1597,7 @@ const handleSendGift = async (giftType) => {
|
||||
if (showGiftFx.value) {
|
||||
giftEffectRef.value?.play({ giftType, name, count: 1 })
|
||||
} else {
|
||||
giftFeedRef.value?.push(name, giftType, 1)
|
||||
giftFeedRef.value?.bumpOrPush(name, giftType)
|
||||
}
|
||||
toast.success('心意已送达')
|
||||
} catch (e) {
|
||||
@@ -1901,73 +1915,100 @@ watch(
|
||||
.gift-fab.busy { opacity: 0.7; pointer-events: none; }
|
||||
.gift-icon { font-size: 16px; color: #a88c6b; line-height: 1; }
|
||||
.gift-panel {
|
||||
max-height: min(52dvh, 380px);
|
||||
max-height: min(56dvh, 420px);
|
||||
overflow-y: auto;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(55, 55, 60, 0.94);
|
||||
color: #e8e6e2;
|
||||
}
|
||||
.gift-panel-handle {
|
||||
width: 36px;
|
||||
height: 4px;
|
||||
width: 32px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
background: rgba(168, 140, 107, 0.28);
|
||||
margin: 0 auto 10px;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
.gift-quota {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(168, 140, 107, 0.08);
|
||||
color: #7a6550;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
gap: 1px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(232, 230, 226, 0.78);
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.gift-quota b { color: #a88c6b; font-weight: 600; }
|
||||
.gift-quota b { color: #f0e6c8; font-weight: 600; }
|
||||
.gift-quota-sub {
|
||||
font-size: 10px;
|
||||
color: #8A8680;
|
||||
font-size: 9px;
|
||||
color: rgba(232, 230, 226, 0.48);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.gift-name-input {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.14);
|
||||
color: #f5f3ef;
|
||||
}
|
||||
.gift-name-input::placeholder {
|
||||
color: rgba(232, 230, 226, 0.4);
|
||||
}
|
||||
.gift-name-input:focus {
|
||||
border-color: rgba(240, 230, 200, 0.45);
|
||||
}
|
||||
.gift-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
}
|
||||
.gift-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-height: 52px;
|
||||
padding: 6px 4px;
|
||||
justify-content: flex-start;
|
||||
gap: 1px;
|
||||
min-height: 72px;
|
||||
padding: 6px 2px 4px;
|
||||
border-radius: 10px;
|
||||
border: 0.5px solid rgba(168, 140, 107, 0.22);
|
||||
background: #fff;
|
||||
color: #5c4a35;
|
||||
border: 0.5px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #e8e6e2;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
.gift-item:active { transform: scale(0.96); }
|
||||
.gift-item:disabled { opacity: 0.6; pointer-events: none; }
|
||||
.gift-item-label {
|
||||
font-size: 14px;
|
||||
line-height: 1.15;
|
||||
color: #a88c6b;
|
||||
.gift-item:disabled { opacity: 0.45; pointer-events: none; }
|
||||
.gift-item-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
line-height: 1.1;
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.04em;
|
||||
text-align: center;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
.gift-item-count {
|
||||
font-size: 10px;
|
||||
color: #8A8680;
|
||||
letter-spacing: 0.04em;
|
||||
.gift-item-caption {
|
||||
font-size: 8px;
|
||||
line-height: 1.15;
|
||||
color: rgba(232, 230, 226, 0.5);
|
||||
letter-spacing: 0.03em;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 360px) {
|
||||
.gift-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.gift-item { min-height: 48px; }
|
||||
.gift-item-label { font-size: 15px; }
|
||||
.gift-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; }
|
||||
.gift-item { min-height: 80px; }
|
||||
.gift-item-preview { font-size: 28px; min-height: 46px; }
|
||||
.gift-item-caption { font-size: 9px; }
|
||||
}
|
||||
.action-comment-row {
|
||||
position: relative;
|
||||
|
||||
Reference in New Issue
Block a user