1. 优化视觉效果、后端规范化

This commit is contained in:
李琦
2026-08-02 19:04:04 +08:00
parent 8f3918198a
commit 2a4d2e0bea
18 changed files with 3710 additions and 1078 deletions

View File

@@ -4,7 +4,11 @@
v-for="row in rows"
:key="row.id"
class="danmaku-item"
:class="{ 'danmaku-item--grad': row.textLight }"
:class="{
'danmaku-item--grad': row.isGradient,
'danmaku-item--grad-light': row.isGradient && row.textLight,
'danmaku-item--grad-dark': row.isGradient && !row.textLight,
}"
:style="row.style"
@animationend="onEnded(row.id)"
>
@@ -14,7 +18,10 @@
<span
v-if="showTime && row.timeLabel"
class="danmaku-time"
:class="{ 'danmaku-time--on-grad': row.textLight }"
:class="{
'danmaku-time--on-light': row.isGradient && row.textLight,
'danmaku-time--on-dark': row.isGradient && !row.textLight,
}"
>{{ row.timeLabel }}</span>
</div>
</div>
@@ -149,6 +156,7 @@ const buildRow = (item, { force = false } = {}) => {
const totalDelay = delay + slot.extraDelay
// 5 轨均匀分布在上半屏,轨间距约 14%
const topPct = 7 + slot.track * 14
const isGradient = typeof tint.background === 'string' && tint.background.includes('gradient')
const timeLabel = force
? '刚刚'
@@ -160,6 +168,7 @@ const buildRow = (item, { force = false } = {}) => {
content: item.content,
color: resolveDanmakuColor(item.color),
textLight: !!tint.textLight,
isGradient,
timeLabel,
style: {
top: `${topPct}%`,
@@ -338,10 +347,15 @@ defineExpose({ refresh: fetchList, pushItem })
max-width: none;
}
.danmaku-item--grad {
backdrop-filter: saturate(1.25) blur(8px);
text-shadow: 0 1px 2px rgba(40, 20, 30, 0.28);
backdrop-filter: saturate(1.2) blur(8px);
font-weight: 500;
}
.danmaku-item--grad-light {
text-shadow: 0 1px 2px rgba(20, 10, 20, 0.45), 0 0 1px rgba(0, 0, 0, 0.2);
}
.danmaku-item--grad-dark {
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.35);
}
.danmaku-name { font-weight: 600; }
.danmaku-sep { opacity: 0.9; }
.danmaku-text { opacity: 0.95; }
@@ -351,8 +365,11 @@ defineExpose({ refresh: fetchList, pushItem })
font-size: 10px;
letter-spacing: 0.04em;
}
.danmaku-time--on-grad {
color: rgba(255, 255, 255, 0.82);
.danmaku-time--on-light {
color: rgba(255, 255, 255, 0.88);
}
.danmaku-time--on-dark {
color: rgba(74, 52, 32, 0.72);
}
@keyframes danmaku-fly {
from { transform: translateX(0); }

View File

@@ -1,88 +1,472 @@
<template>
<div class="like-burst" aria-hidden="true">
<span
v-for="h in hearts"
:key="h.id"
class="like-burst-heart"
:style="h.style"
@animationend="removeHeart(h.id)"
></span>
</div>
<canvas ref="canvasRef" class="like-burst-canvas" aria-hidden="true" />
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
const hearts = ref([])
let uid = 0
const canvasRef = ref(null)
/** 单次最多同时排队的爱心数,避免超高点赞拖垮渲染 */
const MAX_BURST = 80
/** 同屏上限 */
const MAX_HEARTS = 55
const MAX_SPARKS = 120
/** 异常保护 */
const MAX_TOTAL = 5000
/** 每次「点赞效果」间隔 */
const LIKE_GAP_MS = 280
/** 与手动点赞一致:每次效果飘出几颗 */
const HEARTS_PER_LIKE = 1
const burst = (count = 3) => {
const n = Math.max(0, Math.min(MAX_BURST, Math.floor(Number(count) || 0)))
if (!n) return
for (let i = 0; i < n; i += 1) {
const id = ++uid
const left = 10 + Math.random() * 200
const drift = (Math.random() - 0.5) * 90
const duration = 2.8 + Math.random() * 1.0
const delay = i * 0.1
const scale = 0.8 + Math.random() * 0.55
hearts.value.push({
id,
style: {
left: `${left}px`,
'--drift': `${drift}px`,
'--scale': scale,
animationDuration: `${duration}s`,
animationDelay: `${delay}s`,
},
})
const HEART_MIN_SIZE = 14
const HEART_MAX_SIZE = 34
const HEART_MIN_LIFE = 1400
const HEART_MAX_LIFE = 2600
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_ALPHA = 0.55
const DPR_CAP = 2
/** 多色爱心:主色 */
const HEART_COLORS = [
'#ff2442', '#ff2d55', '#ff4770', '#ff6b8a',
'#f472b6', '#ec4899', '#e879f9', '#c084fc',
'#a78bfa', '#818cf8', '#60a5fa', '#38bdf8',
'#2dd4bf', '#34d399', '#fbbf24', '#f59e0b',
'#fb923c', '#a88c6b', '#d4a574', '#e8b4b8',
'#f43f5e', '#fb7185', '#fda4af',
]
/** 高光色(更亮) */
const GLOW_COLORS = [
'#ff8da6', '#ffb0c4', '#f9a8d4', '#e9d5ff',
'#c4b5fd', '#93c5fd', '#6ee7b7', '#fde68a',
'#fdba74', '#f5d0b0', '#ffffff',
]
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)]
const rand = (min, max) => min + Math.random() * (max - min)
let ctx = null
let W = 0
let H = 0
let dpr = 1
let hearts = []
let sparks = []
let rafId = 0
let running = false
let targetLikes = 0
let firedLikes = 0
let likeTimer = null
const clearLikeTimer = () => {
clearTimeout(likeTimer)
likeTimer = 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
canvas.width = Math.max(1, Math.floor(W * dpr))
canvas.height = Math.max(1, Math.floor(H * dpr))
ctx = canvas.getContext('2d')
if (!ctx) return
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.scale(dpr, dpr)
}
/**
* 标准爱心路径(以 cx,cy 为视觉中心)
* 双瓣二次曲线 + 底部尖角
*/
function drawHeartPath(context, cx, cy, size) {
// size ≈ 半宽;整体高约 1.5*size
const w = size * 2.1
const h = size * 1.95
const top = cy - h * 0.42
const midY = top + h * 0.28
const tipY = top + h
context.beginPath()
// 从顶部凹口中间出发
context.moveTo(cx, midY)
// 左上瓣
context.bezierCurveTo(
cx, top,
cx - w * 0.5, top,
cx - w * 0.5, midY,
)
// 左下到尖端
context.bezierCurveTo(
cx - w * 0.5, top + h * 0.62,
cx - w * 0.08, tipY - h * 0.12,
cx, tipY,
)
// 右下到右瓣
context.bezierCurveTo(
cx + w * 0.08, tipY - h * 0.12,
cx + w * 0.5, top + h * 0.62,
cx + w * 0.5, midY,
)
// 右上瓣回到凹口
context.bezierCurveTo(
cx + w * 0.5, top,
cx, top,
cx, midY,
)
context.closePath()
}
function drawGlowHeart(context, cx, cy, size, color, glowColor, opacity, glowAlpha) {
if (opacity <= 0.01 || size <= 1) return
const globalAlpha = Math.min(opacity, 1)
context.save()
context.globalAlpha = globalAlpha
if (glowAlpha > 0.01) {
context.save()
context.shadowColor = glowColor
context.shadowBlur = GLOW_BLUR * (size / 18)
context.fillStyle = glowColor
context.globalAlpha = glowAlpha * globalAlpha
drawHeartPath(context, cx, cy, size)
context.fill()
context.restore()
}
// 主体:从上到下的轻微立体渐变
const gradient = context.createLinearGradient(cx, cy - size * 0.6, cx, cy + size * 0.7)
gradient.addColorStop(0, glowColor)
gradient.addColorStop(0.4, color)
gradient.addColorStop(1, shadeColor(color, -0.28))
context.fillStyle = gradient
context.shadowColor = 'transparent'
context.shadowBlur = 0
drawHeartPath(context, cx, cy, size)
context.fill()
// 左上高光
const highlightX = cx - size * 0.22
const highlightY = cy - size * 0.28
const highlightR = size * 0.18
context.save()
context.globalAlpha = 0.45 * globalAlpha
const hlGrad = context.createRadialGradient(
highlightX, highlightY, 0,
highlightX, highlightY, highlightR,
)
hlGrad.addColorStop(0, 'rgba(255,255,255,0.95)')
hlGrad.addColorStop(0.55, 'rgba(255,255,255,0.25)')
hlGrad.addColorStop(1, 'rgba(255,255,255,0)')
context.fillStyle = hlGrad
context.beginPath()
context.arc(highlightX, highlightY, highlightR, 0, Math.PI * 2)
context.fill()
context.restore()
context.restore()
}
/** 简单加深/变亮 hex */
function shadeColor(hex, amount) {
const h = hex.replace('#', '')
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h
const n = parseInt(full, 16)
let r = (n >> 16) & 255
let g = (n >> 8) & 255
let b = n & 255
const clamp = (v) => Math.max(0, Math.min(255, Math.round(v)))
r = clamp(r + r * amount)
g = clamp(g + g * amount)
b = clamp(b + b * amount)
return `rgb(${r},${g},${b})`
}
function createHeart(x, y, now) {
return {
startX: x,
startY: y,
birthTime: now,
maxAge: rand(HEART_MIN_LIFE, HEART_MAX_LIFE),
baseSize: rand(HEART_MIN_SIZE, HEART_MAX_SIZE),
color: pick(HEART_COLORS),
glowColor: pick(GLOW_COLORS),
peakScale: rand(0.75, 1.35),
riseSpeed: rand(RISE_SPEED_MIN, RISE_SPEED_MAX),
driftAmp: rand(DRIFT_AMP_MIN, DRIFT_AMP_MAX),
driftFreq: rand(1.2, 2.8),
driftPhase: rand(0, Math.PI * 2),
rotation: rand(-0.4, 0.4),
rotationSpeed: rand(-0.35, 0.35),
extraDrift: Math.random() < 0.25 ? rand(-25, 25) : 0,
spawnedSparks: false,
}
}
const removeHeart = (id) => {
hearts.value = hearts.value.filter((h) => h.id !== id)
function heartProgress(h, now) {
return Math.min((now - h.birthTime) / h.maxAge, 1)
}
function heartScale(h, now) {
const progress = heartProgress(h, now)
if (progress < 0.18) {
const t = progress / 0.18
const elastic = 1 - (1 - t) ** 3.5
const overshoot = Math.sin(t * Math.PI) * 0.08 * (1 - t)
return h.peakScale * (elastic + overshoot)
}
if (progress < 0.65) {
const t = (progress - 0.18) / 0.47
return h.peakScale * (1 - t * 0.25)
}
const t = (progress - 0.65) / 0.35
return h.peakScale * 0.75 * (1 - t * t)
}
function heartOpacity(h, now) {
const progress = heartProgress(h, now)
if (progress < 0.45) return 1
if (progress < 0.78) return 1 - ((progress - 0.45) / 0.33) * 0.35
const t = (progress - 0.78) / 0.22
return 0.65 * (1 - t * t)
}
function heartGlowAlpha(h, now) {
const progress = heartProgress(h, now)
if (progress < 0.5) return GLOW_ALPHA
if (progress < 0.8) return GLOW_ALPHA * (1 - (progress - 0.5) / 0.3)
return 0
}
function heartY(h, now) {
const ageSec = (now - h.birthTime) / 1000
const progress = heartProgress(h, now)
const speedMultiplier = 1 + (progress > 0.7 ? ((progress - 0.7) / 0.3) * 0.4 : 0)
return h.startY - h.riseSpeed * ageSec * speedMultiplier
}
function heartX(h, now) {
const ageSec = (now - h.birthTime) / 1000
const sinDrift = Math.sin(ageSec * h.driftFreq + h.driftPhase) * h.driftAmp
const linearDrift = h.extraDrift * Math.min(ageSec / 1.5, 1)
return h.startX + sinDrift + linearDrift
}
function createSpark(x, y, color, now) {
const angle = rand(0, Math.PI * 2)
const speed = rand(25, 90)
return {
x,
y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed - rand(15, 50),
birthTime: now,
life: rand(250, 550),
color,
size: rand(1.5, 4.5),
}
}
function spawnSparksForHeart(heart, now) {
if (heart.spawnedSparks) return
heart.spawnedSparks = true
const cx = heartX(heart, now)
const cy = heartY(heart, now)
const sparkCount = Math.floor(rand(3, 7))
for (let i = 0; i < sparkCount; i += 1) {
if (sparks.length >= MAX_SPARKS) sparks.shift()
sparks.push(createSpark(cx, cy, heart.glowColor, now))
}
}
function spawnHeartAt(x, y, now) {
if (hearts.length >= MAX_HEARTS) {
const oldest = hearts.shift()
if (oldest && !oldest.spawnedSparks) spawnSparksForHeart(oldest, now)
}
hearts.push(createHeart(x, y, now))
ensureLoop()
}
/** 一次点赞效果:底部偏左区域冒出多颗彩色爱心 */
function fireOneLikeEffect() {
if (hearts.length >= MAX_HEARTS) return false
const now = performance.now()
const baseX = W * (0.18 + Math.random() * 0.35)
const baseY = H * (0.78 + Math.random() * 0.12)
const room = MAX_HEARTS - hearts.length
const n = Math.min(HEARTS_PER_LIKE, room)
for (let i = 0; i < n; i += 1) {
spawnHeartAt(
baseX + rand(-28, 28),
baseY + rand(-18, 18),
now + i * 20,
)
}
return true
}
const scheduleNextLike = () => {
clearLikeTimer()
if (firedLikes >= targetLikes) return
likeTimer = setTimeout(() => {
likeTimer = null
if (firedLikes >= targetLikes) return
if (!fireOneLikeEffect()) {
scheduleNextLike()
return
}
firedLikes += 1
if (firedLikes < targetLikes) scheduleNextLike()
}, LIKE_GAP_MS)
}
/**
* 按真实点赞次数逐次触发飘心(不是一次性铺满)
*/
const burst = (count = 3) => {
clearLikeTimer()
const n = Math.max(0, Math.min(MAX_TOTAL, Math.floor(Number(count) || 0)))
if (!n) return
if (!W || !H) resizeCanvas()
targetLikes = n
firedLikes = 0
if (fireOneLikeEffect()) firedLikes = 1
if (firedLikes < targetLikes) scheduleNextLike()
}
function ensureLoop() {
if (running) return
running = true
rafId = requestAnimationFrame(animate)
}
function stopLoopIfIdle() {
if (hearts.length || sparks.length || likeTimer || firedLikes < targetLikes) return
running = false
if (rafId) {
cancelAnimationFrame(rafId)
rafId = 0
}
if (ctx && W && H) ctx.clearRect(0, 0, W, H)
}
function animate(timestamp) {
if (!running) return
const now = timestamp || performance.now()
if (!ctx) {
rafId = requestAnimationFrame(animate)
return
}
ctx.clearRect(0, 0, W, H)
for (let i = sparks.length - 1; i >= 0; i -= 1) {
const spark = sparks[i]
const progress = Math.min((now - spark.birthTime) / spark.life, 1)
if (progress >= 1) {
sparks.splice(i, 1)
continue
}
const age = (now - spark.birthTime) / 1000
const sx = spark.x + spark.vx * age
const sy = spark.y + spark.vy * age + 0.5 * 80 * age * age
const opacity = 1 - progress * progress
const size = spark.size * (1 - progress * 0.6)
ctx.save()
ctx.globalAlpha = opacity
ctx.fillStyle = spark.color
ctx.shadowColor = spark.color
ctx.shadowBlur = 4
ctx.beginPath()
ctx.arc(sx, sy, size, 0, Math.PI * 2)
ctx.fill()
ctx.restore()
}
for (let i = hearts.length - 1; i >= 0; i -= 1) {
const heart = hearts[i]
const progress = heartProgress(heart, now)
if (!heart.spawnedSparks && progress > 0.82) {
spawnSparksForHeart(heart, now)
}
if (progress >= 1) {
if (!heart.spawnedSparks) spawnSparksForHeart(heart, now)
hearts.splice(i, 1)
if (firedLikes < targetLikes && !likeTimer) scheduleNextLike()
continue
}
const cx = heartX(heart, now)
const cy = heartY(heart, now)
const scale = heartScale(heart, now)
const size = heart.baseSize * scale
const opacity = heartOpacity(heart, now)
const glowAlpha = heartGlowAlpha(heart, now)
const rotation = heart.rotation + heart.rotationSpeed * ((now - heart.birthTime) / 1000)
if (cy < -size * 2 || cy > H + size * 2 || cx < -size * 2 || cx > W + size * 2) {
if (!heart.spawnedSparks) spawnSparksForHeart(heart, now)
hearts.splice(i, 1)
if (firedLikes < targetLikes && !likeTimer) scheduleNextLike()
continue
}
ctx.save()
ctx.translate(cx, cy)
ctx.rotate(rotation)
drawGlowHeart(ctx, 0, 0, size, heart.color, heart.glowColor, opacity, glowAlpha)
ctx.restore()
}
while (sparks.length > MAX_SPARKS) sparks.shift()
if (hearts.length || sparks.length || likeTimer || firedLikes < targetLikes) {
rafId = requestAnimationFrame(animate)
} else {
stopLoopIfIdle()
}
}
const onResize = () => resizeCanvas()
onMounted(() => {
resizeCanvas()
window.addEventListener('resize', onResize)
})
onUnmounted(() => {
clearLikeTimer()
running = false
if (rafId) cancelAnimationFrame(rafId)
window.removeEventListener('resize', onResize)
hearts = []
sparks = []
})
defineExpose({ burst })
</script>
<style scoped>
.like-burst {
.like-burst-canvas {
position: fixed;
left: 0;
bottom: 0;
width: min(46vw, 280px);
height: 58vh;
width: min(52vw, 320px);
height: 62vh;
z-index: 45;
pointer-events: none;
overflow: hidden;
}
.like-burst-heart {
position: absolute;
bottom: 28px;
color: #f472b6;
font-size: 22px;
line-height: 1;
text-shadow: 0 2px 8px rgba(244, 114, 182, 0.45);
animation-name: like-rise;
animation-timing-function: cubic-bezier(0.22, 0.61, 0.36, 1);
animation-fill-mode: forwards;
opacity: 0;
}
@keyframes like-rise {
0% {
opacity: 0;
transform: translate3d(0, 12px, 0) scale(calc(var(--scale, 1) * 0.6));
}
12% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate3d(var(--drift, 0px), -55vh, 0) scale(var(--scale, 1));
}
}
</style>