1. 礼物特效
This commit is contained in:
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user