1. 礼物特效

This commit is contained in:
李琦
2026-08-03 11:48:31 +08:00
parent ab2cafeb1b
commit 63fea7e903
6 changed files with 435 additions and 130 deletions

Binary file not shown.

View File

@@ -5,14 +5,18 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { GIFT_LABEL_MAP } from '@/utils/giftTypes' import { GIFT_LABEL_MAP } from '@/utils/giftTypes'
import { isIOS } from '@/composables/useAndroidOptimize'
const emit = defineEmits(['play-start']) const emit = defineEmits(['play-start'])
const canvasRef = ref(null) const canvasRef = ref(null)
const MAX_PARTICLES = 280 const MAX_PARTICLES = isIOS ? 100 : 280
const MAX_RINGS = 12 const MAX_RINGS = isIOS ? 6 : 12
const MAX_TEXTS = 3 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 = { const GIFT_STYLE = {
'520': { '520': {
@@ -187,17 +191,24 @@ let running = false
let playQueue = [] let playQueue = []
let queueTimer = null let queueTimer = null
let secondaryTimers = [] let secondaryTimers = []
/** 当前正在播放的礼物(用于连续同礼物叠加 ×N */
let activeMeta = null
function resizeCanvas() { function resizeCanvas() {
const canvas = canvasRef.value const canvas = canvasRef.value
if (!canvas) return if (!canvas) return
dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP) dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP)
const rect = canvas.getBoundingClientRect() // iOS优先用 visualViewport避免地址栏伸缩导致尺寸错乱
W = rect.width const vv = typeof window !== 'undefined' ? window.visualViewport : null
H = rect.height 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.width = Math.max(1, Math.floor(W * dpr))
canvas.height = Math.max(1, Math.floor(H * dpr)) canvas.height = Math.max(1, Math.floor(H * dpr))
ctx = canvas.getContext('2d') ctx = canvas.getContext('2d', { alpha: true })
if (!ctx) return if (!ctx) return
ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.scale(dpr, dpr) ctx.scale(dpr, dpr)
@@ -230,16 +241,18 @@ function createTextEffect(giftType, now, count = 1) {
const style = GIFT_STYLE[giftType] || GIFT_STYLE['520'] const style = GIFT_STYLE[giftType] || GIFT_STYLE['520']
const base = GIFT_LABEL_MAP[giftType] || giftType const base = GIFT_LABEL_MAP[giftType] || giftType
const n = Math.max(1, Number(count) || 1) const n = Math.max(1, Number(count) || 1)
const text = n > 1 ? `${base}×${n}` : base const fontSize = Math.max(12, Math.min(W * style.fontSizeRatio || 48, style.fontSizeMax))
// 带倍数时略缩小字号,避免出屏
const sizeBoost = n > 1 ? 0.88 : 1
return { return {
text, text: base,
baseText: base,
giftType, giftType,
count: n, count: n,
countRevealed: false,
countRevealAt: 0,
rollFrom: 1,
birthTime: now, birthTime: now,
duration: style.duration, duration: style.duration,
fontSize: Math.min(W * style.fontSizeRatio, style.fontSizeMax) * sizeBoost, fontSize,
glowColor: style.glowColor, glowColor: style.glowColor,
strokeColor: style.strokeColor, strokeColor: style.strokeColor,
peakScale: style.peakScale, 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) { function textProgress(t, now) {
return Math.min((now - t.birthTime) / t.duration, 1) return Math.min((now - t.birthTime) / t.duration, 1)
} }
@@ -350,7 +400,7 @@ function spawnParticles(giftType, now, wave = 0) {
const mode = style.particleMode const mode = style.particleMode
const palette = style.palette const palette = style.palette
const glowPalette = style.glowPalette const glowPalette = style.glowPalette
const boost = wave > 0 ? 0.75 : 1 const boost = (wave > 0 ? 0.75 : 1) * PARTICLE_SCALE
if (mode === 'heartBurst') { if (mode === 'heartBurst') {
const n = Math.floor(42 * boost) const n = Math.floor(42 * boost)
@@ -541,24 +591,81 @@ function playOne(item) {
clearVisuals() clearVisuals()
const now = performance.now() const now = performance.now()
const style = GIFT_STYLE[giftType] 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) spawnParticles(giftType, now, 0)
spawnShockwave(W / 2, H * 0.46, now, style.glowColor, style.showHeartOutline ? 3 : 2) spawnShockwave(W / 2, H * 0.46, now, style.glowColor, style.showHeartOutline && !isIOS ? 3 : 2)
flashAlpha = style.flash flashAlpha = isIOS ? Math.min(style.flash, 0.45) : style.flash
flashDecay = style.flash / Math.max(1, style.flashMs / 16) flashDecay = flashAlpha / Math.max(1, style.flashMs / 16)
flashColor = style.flashColor flashColor = style.flashColor
if (style.secondaryBurst) { if (style.secondaryBurst && !isIOS) {
const t = setTimeout(() => { const t = setTimeout(() => {
spawnParticles(giftType, performance.now(), 1) spawnParticles(giftType, performance.now(), 1)
ensureLoop() ensureLoop()
}, 280) }, 280)
secondaryTimers.push(t) 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() ensureLoop()
return style.duration + 280 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) { function scheduleNextFromQueue(delayMs) {
clearTimeout(queueTimer) clearTimeout(queueTimer)
queueTimer = setTimeout(() => { queueTimer = setTimeout(() => {
@@ -570,6 +677,7 @@ function scheduleNextFromQueue(delayMs) {
function drainPlayQueue() { function drainPlayQueue() {
if (queueTimer) return if (queueTimer) return
if (!playQueue.length) { if (!playQueue.length) {
activeMeta = null
stopIfIdle() stopIfIdle()
return return
} }
@@ -578,10 +686,11 @@ function drainPlayQueue() {
if (playQueue.length) scheduleNextFromQueue(wait) if (playQueue.length) scheduleNextFromQueue(wait)
} }
/** 入队串行播放,保证同一时间只展示一个礼物特效 */ /** 入队串行播放;连续同礼物则合并为 ×N */
const play = (item) => { const play = (item) => {
const payload = normalizePlayItem(item) const payload = normalizePlayItem(item)
if (!payload) return if (!payload) return
if (tryBumpSame(payload)) return
playQueue.push(payload) playQueue.push(payload)
if (texts.length || particles.length || rings.length || flashAlpha > 0.01) { if (texts.length || particles.length || rings.length || flashAlpha > 0.01) {
if (!queueTimer) { if (!queueTimer) {
@@ -624,6 +733,20 @@ function animate(timestamp) {
ctx.clearRect(0, 0, W, H) 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 // rings
for (let i = rings.length - 1; i >= 0; i -= 1) { for (let i = rings.length - 1; i >= 0; i -= 1) {
const r = rings[i] const r = rings[i]
@@ -641,7 +764,7 @@ function animate(timestamp) {
ctx.strokeStyle = r.color ctx.strokeStyle = r.color
ctx.lineWidth = r.lineWidth * (1 - p * 0.5) ctx.lineWidth = r.lineWidth * (1 - p * 0.5)
ctx.shadowColor = r.color ctx.shadowColor = r.color
ctx.shadowBlur = 12 ctx.shadowBlur = 12 * SHADOW_MUL
ctx.beginPath() ctx.beginPath()
ctx.ellipse(r.x, r.y, radius, radius * 0.72, 0, 0, Math.PI * 2) ctx.ellipse(r.x, r.y, radius, radius * 0.72, 0, 0, Math.PI * 2)
ctx.stroke() ctx.stroke()
@@ -667,7 +790,7 @@ function animate(timestamp) {
ctx.globalAlpha = Math.max(0, opacity) ctx.globalAlpha = Math.max(0, opacity)
ctx.fillStyle = p.color ctx.fillStyle = p.color
ctx.shadowColor = p.glow || 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') { if (p.kind === 'heart') {
ctx.translate(x, y) ctx.translate(x, y)
@@ -705,6 +828,7 @@ function animate(timestamp) {
for (let i = texts.length - 1; i >= 0; i -= 1) { for (let i = texts.length - 1; i >= 0; i -= 1) {
const mt = texts[i] const mt = texts[i]
if (textProgress(mt, now) >= 1) { if (textProgress(mt, now) >= 1) {
if (activeMeta && activeMeta.mt === mt) activeMeta = null
texts.splice(i, 1) texts.splice(i, 1)
continue continue
} }
@@ -717,18 +841,21 @@ function animate(timestamp) {
if (opacity < 0.01) continue if (opacity < 0.01) continue
const wobble = Math.sin((now - mt.birthTime) / 180) * mt.wobble const wobble = Math.sin((now - mt.birthTime) / 180) * mt.wobble
// soft aura behind text // soft aura behind textiOS 跳过径向渐变,避免卡顿/错乱)
if (mt.aura) { if (mt.aura && !SKIP_AURA) {
ctx.save() const auraR = Math.max(0.1, Number(fontSize) * 1.8 * Math.max(0, scale) || 0)
ctx.globalAlpha = opacity * 0.22 * glowIntensity if (auraR > 0.1) {
const aura = ctx.createRadialGradient(cx, cy, 0, cx, cy, fontSize * 1.8 * scale) ctx.save()
aura.addColorStop(0, mt.glowColor) ctx.globalAlpha = opacity * 0.22 * glowIntensity
aura.addColorStop(1, 'rgba(0,0,0,0)') const aura = ctx.createRadialGradient(cx, cy, 0, cx, cy, auraR)
ctx.fillStyle = aura aura.addColorStop(0, mt.glowColor)
ctx.beginPath() aura.addColorStop(1, 'rgba(0,0,0,0)')
ctx.arc(cx, cy, fontSize * 1.8 * scale, 0, Math.PI * 2) ctx.fillStyle = aura
ctx.fill() ctx.beginPath()
ctx.restore() ctx.arc(cx, cy, auraR, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
} }
ctx.save() ctx.save()
@@ -737,27 +864,40 @@ function animate(timestamp) {
ctx.rotate(wobble) ctx.rotate(wobble)
ctx.scale(scale, scale) ctx.scale(scale, scale)
const glowBlur = fontSize * 0.4 * glowIntensity const glowBlur = fontSize * 0.4 * glowIntensity
ctx.shadowColor = mt.glowColor const showMult = mt.countRevealed && mt.count > 1
ctx.shadowBlur = glowBlur const mainY = showMult ? -fontSize * 0.42 : 0
ctx.strokeStyle = mt.strokeColor drawMilestoneLine(
ctx.lineWidth = fontSize * 0.065 ctx,
ctx.lineJoin = 'round' mt.baseText || mt.text,
ctx.font = `700 ${fontSize}px "Ma Shan Zheng", "Great Vibes", cursive, serif` fontSize,
ctx.textAlign = 'center' glowBlur,
ctx.textBaseline = 'middle' mt.gradient,
ctx.strokeText(mt.text, 0, 0) mt.strokeColor,
const textGrad = ctx.createLinearGradient(0, -fontSize * 0.55, 0, fontSize * 0.55) mt.glowColor,
const g = mt.gradient mainY,
textGrad.addColorStop(0, g[0]) )
textGrad.addColorStop(0.35, g[1]) if (showMult) {
textGrad.addColorStop(0.7, g[2]) const n = scrolledCount(mt, now)
textGrad.addColorStop(1, g[3]) const multSize = fontSize * 0.72
ctx.fillStyle = textGrad const multY = fontSize * 0.55
ctx.shadowBlur = glowBlur * 1.5 // 滚动过程轻微上浮
ctx.fillText(mt.text, 0, 0) const rollP = Math.min(1, (now - (mt.countRevealAt || now)) / COUNT_ROLL_MS)
ctx.shadowBlur = 0 const pop = 0.85 + 0.15 * (1 - (1 - rollP) ** 2)
ctx.fillStyle = 'rgba(255,255,255,0.4)' ctx.save()
ctx.fillText(mt.text, -fontSize * 0.018, -fontSize * 0.028) 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() ctx.restore()
if (mt.showHeartOutline && heartOutlineOpacity(mt, now) > 0.01) { if (mt.showHeartOutline && heartOutlineOpacity(mt, now) > 0.01) {
@@ -824,6 +964,8 @@ const onResize = () => resizeCanvas()
onMounted(() => { onMounted(() => {
resizeCanvas() resizeCanvas()
window.addEventListener('resize', onResize) window.addEventListener('resize', onResize)
window.visualViewport?.addEventListener('resize', onResize)
window.visualViewport?.addEventListener('scroll', onResize)
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -831,9 +973,12 @@ onUnmounted(() => {
clearSecondaryTimers() clearSecondaryTimers()
queueTimer = null queueTimer = null
playQueue = [] playQueue = []
activeMeta = null
running = false running = false
if (rafId) cancelAnimationFrame(rafId) if (rafId) cancelAnimationFrame(rafId)
window.removeEventListener('resize', onResize) window.removeEventListener('resize', onResize)
window.visualViewport?.removeEventListener('resize', onResize)
window.visualViewport?.removeEventListener('scroll', onResize)
texts = [] texts = []
particles = [] particles = []
rings = [] rings = []
@@ -845,10 +990,17 @@ defineExpose({ play, playSequence })
<style scoped> <style scoped>
.gift-effect-canvas { .gift-effect-canvas {
position: fixed; position: fixed;
inset: 0; left: 0;
top: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
height: 100dvh;
z-index: 46; z-index: 46;
pointer-events: none; pointer-events: none;
/* iOS独立合成层避免和左侧点赞 canvas / transform 滚动打架 */
transform: translateZ(0);
-webkit-transform: translateZ(0);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
} }
</style> </style>

View File

@@ -8,7 +8,13 @@
> >
<span class="gift-feed-name">{{ item.name }}</span> <span class="gift-feed-name">{{ item.name }}</span>
<span class="gift-feed-verb">送出</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> </div>
</TransitionGroup> </TransitionGroup>
</div> </div>
@@ -20,15 +26,13 @@ import { GIFT_LABEL_MAP } from '@/utils/giftTypes'
const MAX_ITEMS = 5 const MAX_ITEMS = 5
const LIFE_MS = 8000 const LIFE_MS = 8000
const ROLL_MS = 900
const items = ref([]) const items = ref([])
const timers = new Map() const timers = new Map()
const rollTimers = new Map()
let seq = 0 let seq = 0
const formatLabel = (giftType, count = 1) => { const baseLabel = (giftType) => GIFT_LABEL_MAP[giftType] || giftType
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 removeItem = (id) => {
const t = timers.get(id) const t = timers.get(id)
@@ -36,37 +40,120 @@ const removeItem = (id) => {
clearTimeout(t) clearTimeout(t)
timers.delete(id) timers.delete(id)
} }
const r = rollTimers.get(id)
if (r) {
cancelAnimationFrame(r)
rollTimers.delete(id)
}
items.value = items.value.filter((it) => it.id !== 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 push = (name, giftType, count = 1) => {
const n = String(name || '').trim() || '匿名' const n = String(name || '').trim() || '匿名'
const type = String(giftType || '').trim() const type = String(giftType || '').trim()
if (!type) return if (!type) return ''
const c = Math.max(1, Number(count) || 1)
seq += 1 seq += 1
const id = `${Date.now()}-${seq}` const id = `${Date.now()}-${seq}`
const next = { const next = {
id, id,
name: n, name: n,
giftType: type, giftType: type,
count: Math.max(1, Number(count) || 1), count: c,
label: formatLabel(type, count), label: baseLabel(type),
showCount: false,
displayCount: 1,
} }
while (items.value.length >= MAX_ITEMS) { while (items.value.length >= MAX_ITEMS) {
removeItem(items.value[0].id) removeItem(items.value[0].id)
} }
items.value = [...items.value, next] items.value = [...items.value, next]
const timer = setTimeout(() => removeItem(id), LIFE_MS) resetLife(id)
timers.set(id, timer) 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(() => { onUnmounted(() => {
timers.forEach((t) => clearTimeout(t)) timers.forEach((t) => clearTimeout(t))
timers.clear() timers.clear()
rollTimers.forEach((r) => cancelAnimationFrame(r))
rollTimers.clear()
}) })
defineExpose({ push }) defineExpose({ push, revealCount, bumpOrPush })
</script> </script>
<style scoped> <style scoped>
@@ -77,6 +164,10 @@ defineExpose({ push })
z-index: 48; z-index: 48;
max-width: min(72vw, 260px); max-width: min(72vw, 260px);
pointer-events: none; pointer-events: none;
transform: translateZ(0);
-webkit-transform: translateZ(0);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
} }
.gift-feed-list { .gift-feed-list {
@@ -88,12 +179,12 @@ defineExpose({ push })
.gift-feed-row { .gift-feed-row {
display: inline-flex; display: inline-flex;
align-items: baseline; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px 6px; gap: 4px 6px;
max-width: 100%; max-width: 100%;
padding: 6px 10px; padding: 6px 10px;
border-radius: 999px; border-radius: 14px;
background: rgba(70, 70, 75, 0.28); background: rgba(70, 70, 75, 0.28);
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
@@ -116,6 +207,13 @@ defineExpose({ push })
letter-spacing: 0.08em; letter-spacing: 0.08em;
} }
.gift-feed-gift-wrap {
display: inline-flex;
flex-direction: column;
align-items: center;
line-height: 1.1;
}
.gift-feed-gift { .gift-feed-gift {
font-size: 14px; font-size: 14px;
letter-spacing: 0.06em; letter-spacing: 0.06em;
@@ -123,6 +221,14 @@ defineExpose({ push })
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.25); 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 { .gift-feed-item-enter-active {
transition: opacity 0.35s ease, transform 0.35s ease; transition: opacity 0.35s ease, transform 0.35s ease;
} }

View File

@@ -4,12 +4,13 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { isIOS } from '@/composables/useAndroidOptimize'
const canvasRef = ref(null) const canvasRef = ref(null)
/** 同屏上限 */ /** 同屏上限 */
const MAX_HEARTS = 55 const MAX_HEARTS = isIOS ? 36 : 55
const MAX_SPARKS = 120 const MAX_SPARKS = isIOS ? 80 : 120
/** 异常保护 */ /** 异常保护 */
const MAX_TOTAL = 5000 const MAX_TOTAL = 5000
/** 每次「点赞效果」间隔 */ /** 每次「点赞效果」间隔 */
@@ -25,9 +26,9 @@ const RISE_SPEED_MIN = 70
const RISE_SPEED_MAX = 150 const RISE_SPEED_MAX = 150
const DRIFT_AMP_MIN = 8 const DRIFT_AMP_MIN = 8
const DRIFT_AMP_MAX = 45 const DRIFT_AMP_MAX = 45
const GLOW_BLUR = 10 const GLOW_BLUR = isIOS ? 4 : 10
const GLOW_ALPHA = 0.55 const GLOW_ALPHA = 0.55
const DPR_CAP = 2 const DPR_CAP = isIOS ? 1.5 : 2
/** 多色爱心:主色 */ /** 多色爱心:主色 */
const HEART_COLORS = [ const HEART_COLORS = [
@@ -466,7 +467,12 @@ defineExpose({ burst })
bottom: 0; bottom: 0;
width: min(52vw, 320px); width: min(52vw, 320px);
height: 62vh; height: 62vh;
height: 62dvh;
z-index: 45; z-index: 45;
pointer-events: none; pointer-events: none;
transform: translateZ(0);
-webkit-transform: translateZ(0);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
} }
</style> </style>

View File

@@ -1,13 +1,13 @@
/** 礼物类型常量表(与后端 giftTypeWhitelist 一致) */ /** 礼物类型常量表(与后端 giftTypeWhitelist 一致) */
export const GIFT_TYPES = [ export const GIFT_TYPES = [
{ key: '520', label: '520' }, { key: '520', label: '520', preview: 'linear-gradient(135deg, #ff8da6 0%, #ff2d55 55%, #c41e3a 100%)' },
{ key: '1314', label: '1314' }, { key: '1314', label: '1314', preview: 'linear-gradient(135deg, #fff3b0 0%, #ffd700 50%, #e6a000 100%)' },
{ key: 'forever', label: '长长久久' }, { key: 'forever', label: '长长久久', preview: 'linear-gradient(135deg, #ffb3c1 0%, #c41e3a 55%, #6b0f1a 100%)' },
{ key: 'bald', label: '白头偕老' }, { key: 'bald', label: '白头偕老', preview: 'linear-gradient(135deg, #ffffff 0%, #e8e4d9 45%, #b8a878 100%)' },
{ key: 'concentric', label: '永结同心' }, { key: 'concentric', label: '永结同心', preview: 'linear-gradient(135deg, #f5dcc4 0%, #d4a574 50%, #b76e79 100%)' },
{ key: 'heaven', label: '天作之合' }, { key: 'heaven', label: '天作之合', preview: 'linear-gradient(135deg, #cce4ff 0%, #5ba3ff 50%, #1a5bb5 100%)' },
{ key: 'moisten', label: '相濡以沫' }, { key: 'moisten', label: '相濡以沫', preview: 'linear-gradient(135deg, #c5f5ef 0%, #40b4a6 50%, #165e5a 100%)' },
{ key: 'match', label: '佳偶天成' }, { 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])) export const GIFT_LABEL_MAP = Object.fromEntries(GIFT_TYPES.map((g) => [g.key, g.label]))

View File

@@ -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 v-if="showGiftPanel" class="fixed inset-0 z-[60] bg-black/25 transition-opacity duration-200" @click="closeGiftPanel"></div>
<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 }" :class="{ 'pointer-events-none': !showGiftPanel }"
:style="{ transform: showGiftPanel ? 'translateY(0)' : 'translateY(110%)' }" :style="{ transform: showGiftPanel ? 'translateY(0)' : 'translateY(110%)' }"
> >
<div class="gift-panel-handle" aria-hidden="true"></div> <div class="gift-panel-handle" aria-hidden="true"></div>
<div class="flex items-center justify-between mb-2.5 px-0.5"> <div class="flex items-center justify-between mb-1.5 px-0.5">
<span class="text-[13px] text-[#a88c6b] tracking-[0.25em]">送一份心意</span> <span class="text-[12px] text-[#c8c4bc] tracking-[0.25em]">送一份心意</span>
<button type="button" class="p-1.5 text-[#a88c6b]/70 text-sm leading-none" @click="closeGiftPanel"></button> <button type="button" class="p-1 text-[#c8c4bc]/80 text-sm leading-none" @click="closeGiftPanel"></button>
</div> </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-if="giftQuota.remain > 0">还可赠送 <b>{{ giftQuota.remain }}</b> </span>
<span v-else>赠送次数已用完再点赞 <b>{{ giftQuota.likes_to_next }}</b> 次可多送 1 </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> <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 <input
v-model="giftForm.name" v-model="giftForm.name"
placeholder="您的姓名" 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"> <div class="gift-grid">
<button <button
@@ -319,8 +319,11 @@
:disabled="giftBusy || giftQuota.remain <= 0" :disabled="giftBusy || giftQuota.remain <= 0"
@click="handleSendGift(g.key)" @click="handleSendGift(g.key)"
> >
<span class="gift-item-label calligraphy-strong">{{ g.label }}</span> <span
<span class="gift-item-count">{{ giftCounts[g.key] || 0 }}</span> class="gift-item-preview calligraphy-strong"
:style="{ backgroundImage: g.preview }"
>{{ g.label }}</span>
<span class="gift-item-caption">{{ g.label }}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -1538,7 +1541,11 @@ const openGiftPanel = async () => {
} catch { /* ignore */ } } 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) giftFeedRef.value?.push(name, giftType, count)
} }
@@ -1552,7 +1559,14 @@ const replayGiftFeedOnly = (list) => {
const it = items[i] const it = items[i]
i += 1 i += 1
giftFeedRef.value?.push(it.name, it.giftType, it.count) 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() tick()
} }
@@ -1583,7 +1597,7 @@ const handleSendGift = async (giftType) => {
if (showGiftFx.value) { if (showGiftFx.value) {
giftEffectRef.value?.play({ giftType, name, count: 1 }) giftEffectRef.value?.play({ giftType, name, count: 1 })
} else { } else {
giftFeedRef.value?.push(name, giftType, 1) giftFeedRef.value?.bumpOrPush(name, giftType)
} }
toast.success('心意已送达') toast.success('心意已送达')
} catch (e) { } catch (e) {
@@ -1901,73 +1915,100 @@ watch(
.gift-fab.busy { opacity: 0.7; pointer-events: none; } .gift-fab.busy { opacity: 0.7; pointer-events: none; }
.gift-icon { font-size: 16px; color: #a88c6b; line-height: 1; } .gift-icon { font-size: 16px; color: #a88c6b; line-height: 1; }
.gift-panel { .gift-panel {
max-height: min(52dvh, 380px); max-height: min(56dvh, 420px);
overflow-y: auto; 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 { .gift-panel-handle {
width: 36px; width: 32px;
height: 4px; height: 3px;
border-radius: 999px; border-radius: 999px;
background: rgba(168, 140, 107, 0.28); background: rgba(255, 255, 255, 0.22);
margin: 0 auto 10px; margin: 0 auto 8px;
} }
.gift-quota { .gift-quota {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 1px;
padding: 8px 10px; padding: 6px 8px;
border-radius: 10px; border-radius: 8px;
background: rgba(168, 140, 107, 0.08); background: rgba(255, 255, 255, 0.06);
color: #7a6550; color: rgba(232, 230, 226, 0.78);
font-size: 11px; font-size: 10px;
line-height: 1.45; line-height: 1.4;
letter-spacing: 0.04em; letter-spacing: 0.04em;
} }
.gift-quota b { color: #a88c6b; font-weight: 600; } .gift-quota b { color: #f0e6c8; font-weight: 600; }
.gift-quota-sub { .gift-quota-sub {
font-size: 10px; font-size: 9px;
color: #8A8680; color: rgba(232, 230, 226, 0.48);
letter-spacing: 0.02em; 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 { .gift-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px; gap: 4px;
} }
.gift-item { .gift-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
gap: 2px; gap: 1px;
min-height: 52px; min-height: 72px;
padding: 6px 4px; padding: 6px 2px 4px;
border-radius: 10px; border-radius: 10px;
border: 0.5px solid rgba(168, 140, 107, 0.22); border: 0.5px solid rgba(255, 255, 255, 0.1);
background: #fff; background: rgba(255, 255, 255, 0.06);
color: #5c4a35; color: #e8e6e2;
cursor: pointer; cursor: pointer;
transition: transform 0.15s ease, border-color 0.2s ease, background 0.2s ease; transition: transform 0.15s ease, border-color 0.2s ease, background 0.2s ease;
} }
.gift-item:active { transform: scale(0.96); } .gift-item:active { transform: scale(0.96); }
.gift-item:disabled { opacity: 0.6; pointer-events: none; } .gift-item:disabled { opacity: 0.45; pointer-events: none; }
.gift-item-label { .gift-item-preview {
font-size: 14px; display: block;
line-height: 1.15; width: 100%;
color: #a88c6b; min-height: 40px;
line-height: 1.1;
font-size: 24px;
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-align: center; 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 { .gift-item-caption {
font-size: 10px; font-size: 8px;
color: #8A8680; line-height: 1.15;
letter-spacing: 0.04em; 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) { @media (max-width: 360px) {
.gift-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .gift-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 5px; }
.gift-item { min-height: 48px; } .gift-item { min-height: 80px; }
.gift-item-label { font-size: 15px; } .gift-item-preview { font-size: 28px; min-height: 46px; }
.gift-item-caption { font-size: 9px; }
} }
.action-comment-row { .action-comment-row {
position: relative; position: relative;