1. 礼物特效

This commit is contained in:
李琦
2026-08-03 12:20:55 +08:00
parent 63fea7e903
commit 170102b0d8
12 changed files with 813 additions and 216 deletions

View File

@@ -1,5 +1,6 @@
<template>
<div v-if="enabled && (items.length || rows.length)" class="danmaku-layer" aria-hidden="true">
<!-- 始终挂载 v-show 隐藏避免关闭时 v-if 拆树与清空列表打架 -->
<div v-show="enabled" class="danmaku-layer" aria-hidden="true">
<div
v-for="row in rows"
:key="row.id"
@@ -142,7 +143,6 @@ const buildRow = (item, { force = false } = {}) => {
const key = itemKey(item)
if (!force && isCooling(key)) return null
// 速度区间收窄,降低同屏追尾概率;轨道占用保证同轨不叠
const duration = 12 + Math.random() * 4
const delay = force ? 0 : Math.random() * 0.25
const slot = reserveTrack(duration, delay, { force })
@@ -154,7 +154,6 @@ const buildRow = (item, { force = false } = {}) => {
const tint = resolveDanmakuTint(item.color)
const id = ++uid
const totalDelay = delay + slot.extraDelay
// 5 轨均匀分布在上半屏,轨间距约 14%
const topPct = 7 + slot.track * 14
const isGradient = typeof tint.background === 'string' && tint.background.includes('gradient')
@@ -191,11 +190,7 @@ const mergeItems = (incoming) => {
}
const fetchList = async () => {
if (!props.enabled) {
items.value = []
shownIds.clear()
return
}
if (!props.enabled) return
try {
const done = isBatchDone()
const maxId = maxItemId()
@@ -229,9 +224,7 @@ const spawnOne = () => {
const pushItem = (item) => {
if (!item || !props.enabled) return
const id = item.id
if (id != null && items.value.some((x) => x.id === id)) {
// already in list — still force-spawn for sender
} else {
if (id == null || !items.value.some((x) => x.id === id)) {
items.value = [...items.value, item]
}
if (!props.active) return
@@ -240,6 +233,7 @@ const pushItem = (item) => {
}
const onEnded = (id) => {
if (!rows.value.length) return
rows.value = rows.value.filter((r) => r.id !== id)
}
@@ -270,52 +264,70 @@ const stopPoll = () => {
pollTimer = null
}
watch(
() => [props.enabled, props.active, items.value.length],
() => {
if (props.enabled && props.active && items.value.length) startSpawn()
else {
stopSpawn()
if (!props.enabled) {
rows.value = []
items.value = []
lastShownAt.clear()
shownIds.clear()
resetTracks()
}
}
}
)
const clearFlying = () => {
rows.value = []
resetTracks()
}
const clearAll = () => {
stopPoll()
stopSpawn()
clearFlying()
items.value = []
lastShownAt.clear()
shownIds.clear()
cursor = 0
}
const syncSpawn = () => {
if (props.enabled && props.active && items.value.length) startSpawn()
else stopSpawn()
}
// 只监听开关本身,禁止在依赖 items.length 的 watcher 里清空 items会递归更新
watch(
() => props.enabled,
async (on) => {
if (on) {
await fetchList()
startPoll()
if (props.active && items.value.length) startSpawn()
syncSpawn()
} else {
stopPoll()
stopSpawn()
rows.value = []
items.value = []
lastShownAt.clear()
shownIds.clear()
resetTracks()
clearAll()
}
}
},
)
watch(
() => props.active,
() => {
if (!props.enabled) return
if (props.active) syncSpawn()
else {
stopSpawn()
clearFlying()
}
},
)
// items 变化时只启停 spawn绝不在这里清空 items
watch(
() => items.value.length,
() => {
if (!props.enabled) return
syncSpawn()
},
)
onMounted(async () => {
if (!props.enabled) return
await fetchList()
startPoll()
if (props.active && items.value.length) startSpawn()
syncSpawn()
})
onUnmounted(() => {
stopPoll()
stopSpawn()
clearAll()
})
defineExpose({ refresh: fetchList, pushItem })

View File

@@ -206,12 +206,15 @@ function resizeCanvas() {
H = cssH
canvas.style.width = `${cssW}px`
canvas.style.height = `${cssH}px`
canvas.style.writingMode = 'horizontal-tb'
canvas.style.direction = 'ltr'
canvas.width = Math.max(1, Math.floor(W * dpr))
canvas.height = Math.max(1, Math.floor(H * dpr))
ctx = canvas.getContext('2d', { alpha: true })
if (!ctx) return
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.scale(dpr, dpr)
if (typeof ctx.direction === 'string') ctx.direction = 'ltr'
}
function drawHeartPath(context, cx, cy, size) {
@@ -277,28 +280,80 @@ function scrolledCount(mt, now) {
return Math.max(from, Math.round(from + (target - from) * e))
}
const FONT_STACK = '"Ma Shan Zheng", "PingFang SC", "Hiragino Sans GB", sans-serif'
function setMilestoneFont(context, fontSize) {
// Ma Shan Zheng 只有 400用 700 时 iOS 会假粗并打乱字距/排版
context.font = `400 ${fontSize}px ${FONT_STACK}`
if (typeof context.direction === 'string') context.direction = 'ltr'
if (typeof context.letterSpacing === 'string') context.letterSpacing = '0px'
}
/** 保证艺术字单行:按字宽横排,过宽则缩小字号 */
function fitMilestoneFontSize(context, text, desired, maxWidth) {
let size = Math.max(12, desired)
setMilestoneFont(context, size)
let w = context.measureText(text).width
while (size > 14 && w > maxWidth) {
size -= 2
setMilestoneFont(context, size)
w = context.measureText(text).width
}
return size
}
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`
const raw = String(text || '').replace(/[\r\n\u2028\u2029]/g, '')
if (!raw) return
// iOS canvas shadowBlur 在中文笔画下会拖出脏光晕,艺术字直接关掉阴影
const blur = isIOS ? 0 : Math.max(0, glowBlur * SHADOW_MUL)
const maxW = Math.max(40, W * 0.88)
const size = fitMilestoneFontSize(context, raw, fontSize, maxW)
setMilestoneFont(context, size)
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)
context.lineJoin = 'round'
context.miterLimit = 2
context.lineWidth = size * (isIOS ? 0.08 : 0.065)
context.shadowColor = 'transparent'
context.shadowBlur = 0
context.shadowOffsetX = 0
context.shadowOffsetY = 0
context.strokeStyle = strokeColor
const chars = Array.from(raw)
// 逐字测宽后强制横排,避免 iOS 把四字折成 2×2
const widths = chars.map((ch) => context.measureText(ch).width)
const total = widths.reduce((a, b) => a + b, 0)
const textGrad = context.createLinearGradient(0, y - size * 0.55, 0, y + size * 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)
let x = -total / 2
for (let i = 0; i < chars.length; i += 1) {
const ch = chars[i]
const cw = widths[i]
const cx = x + cw / 2
if (blur > 0) {
context.shadowColor = glowColor
context.shadowBlur = blur
}
context.strokeText(ch, cx, y)
context.fillStyle = textGrad
if (blur > 0) context.shadowBlur = blur * 1.5
context.fillText(ch, cx, y)
context.shadowColor = 'transparent'
context.shadowBlur = 0
context.fillStyle = 'rgba(255,255,255,0.4)'
context.fillText(ch, cx - size * 0.018, y - size * 0.028)
x += cw
}
context.shadowColor = 'transparent'
context.shadowBlur = 0
context.fillStyle = 'rgba(255,255,255,0.4)'
context.fillText(text, -fontSize * 0.018, y - fontSize * 0.028)
context.shadowOffsetX = 0
context.shadowOffsetY = 0
}
function textProgress(t, now) {
@@ -984,7 +1039,21 @@ onUnmounted(() => {
rings = []
})
defineExpose({ play, playSequence })
function clearAll() {
clearTimeout(queueTimer)
queueTimer = null
playQueue = []
activeMeta = null
clearVisuals()
running = false
if (rafId) {
cancelAnimationFrame(rafId)
rafId = 0
}
if (ctx && W && H) ctx.clearRect(0, 0, W, H)
}
defineExpose({ play, playSequence, clear: clearAll, stop: clearAll })
</script>
<style scoped>
@@ -997,6 +1066,11 @@ defineExpose({ play, playSequence })
height: 100dvh;
z-index: 46;
pointer-events: none;
/* 防止 iOS 继承竖排书写模式导致 canvas 文字折成两行/方阵 */
writing-mode: horizontal-tb;
text-orientation: mixed;
direction: ltr;
white-space: nowrap;
/* iOS独立合成层避免和左侧点赞 canvas / transform 滚动打架 */
transform: translateZ(0);
-webkit-transform: translateZ(0);

View File

@@ -1,6 +1,11 @@
<template>
<div class="gift-feed" aria-live="polite">
<TransitionGroup name="gift-feed-item" tag="div" class="gift-feed-list">
<TransitionGroup
name="gift-feed-item"
tag="div"
class="gift-feed-list"
:css="useTransition"
>
<div
v-for="item in items"
:key="item.id"
@@ -21,7 +26,7 @@
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
import { ref, nextTick, onUnmounted } from 'vue'
import { GIFT_LABEL_MAP } from '@/utils/giftTypes'
const MAX_ITEMS = 5
@@ -30,6 +35,7 @@ const ROLL_MS = 900
const items = ref([])
const timers = new Map()
const rollTimers = new Map()
const useTransition = ref(true)
let seq = 0
const baseLabel = (giftType) => GIFT_LABEL_MAP[giftType] || giftType
@@ -146,14 +152,28 @@ const bumpOrPush = (name, giftType) => {
return push(n, type, 1)
}
const clear = () => {
timers.forEach((t) => clearTimeout(t))
timers.clear()
rollTimers.forEach((r) => cancelAnimationFrame(r))
rollTimers.clear()
// 一次性清空时关掉 leave 过渡,避免 TransitionGroup 与 vnode patch 冲突
useTransition.value = false
items.value = []
nextTick(() => {
useTransition.value = true
})
}
onUnmounted(() => {
timers.forEach((t) => clearTimeout(t))
timers.clear()
rollTimers.forEach((r) => cancelAnimationFrame(r))
rollTimers.clear()
items.value = []
})
defineExpose({ push, revealCount, bumpOrPush })
defineExpose({ push, revealCount, bumpOrPush, clear })
</script>
<style scoped>

View File

@@ -316,6 +316,8 @@ function fireOneLikeEffect() {
return true
}
let burstGapMs = LIKE_GAP_MS
const scheduleNextLike = () => {
clearLikeTimer()
if (firedLikes >= targetLikes) return
@@ -328,19 +330,37 @@ const scheduleNextLike = () => {
}
firedLikes += 1
if (firedLikes < targetLikes) scheduleNextLike()
}, LIKE_GAP_MS)
}, burstGapMs)
}
/**
* 按真实点赞次数逐次触发飘心(不是一次性铺满)
* @param {number} count
* @param {{ durationMs?: number, maxVisual?: number }} [opts]
* durationMs: 把视觉节奏压缩到约几秒内播完(多次点赞用)
* maxVisual: 最多触发几次飘心节奏
*/
const burst = (count = 3) => {
const burst = (count = 3, opts = {}) => {
clearLikeTimer()
const n = Math.max(0, Math.min(MAX_TOTAL, Math.floor(Number(count) || 0)))
if (!n) return
const raw = Math.max(0, Math.min(MAX_TOTAL, Math.floor(Number(count) || 0)))
if (!raw) return
if (!W || !H) resizeCanvas()
targetLikes = n
const maxVisual = Math.max(1, Math.min(
Number(opts.maxVisual) || raw,
raw,
24,
))
const durationMs = Number(opts.durationMs)
const useShort = Number.isFinite(durationMs) && durationMs > 0 && raw > 1
const visual = useShort ? Math.min(maxVisual, Math.max(5, Math.ceil(Math.min(raw, 12)))) : Math.min(raw, maxVisual)
targetLikes = visual
firedLikes = 0
burstGapMs = useShort
? Math.max(90, Math.floor(durationMs / Math.max(1, visual)))
: LIKE_GAP_MS
if (fireOneLikeEffect()) firedLikes = 1
if (firedLikes < targetLikes) scheduleNextLike()
}