1. 礼物特效

This commit is contained in:
李琦
2026-08-03 12:56:40 +08:00
parent 50f1e09569
commit 06c57a0754
3 changed files with 341 additions and 90 deletions

View File

@@ -10,6 +10,15 @@ import { isIOS } from '@/composables/useAndroidOptimize'
const emit = defineEmits(['play-start'])
const canvasRef = ref(null)
// ========================================================
// [高级特效开关控制] (可自由组合)
// ========================================================
const ENABLE_BLEND_MODES = true // 特效一:叠加发光 (仙气、刺眼的高亮光感)
const ENABLE_MOTION_BLUR = true // 特效二:物理拉丝拖尾 (运动模糊、极速流星感)
const ENABLE_NEON_GLOW = true // 特效三:[全新] 霓虹流光场 (将原本难看的线条,升级为多层发光的能量轨迹)
const ENABLE_TURBULENCE = true // 特效四:流体力学场 (S型盘旋、引力扰动空间)
// ========================================================
const MAX_PARTICLES = isIOS ? 72 : 180
const MAX_RINGS = isIOS ? 6 : 12
const MAX_TEXTS = 3
@@ -262,24 +271,35 @@ function resizeCanvas() {
if (typeof ctx.direction === 'string') ctx.direction = 'ltr'
}
/**
* [优化] 完美心形比例计算:移除原本生硬的锚点,
* 使用更圆润的黄金分割贝塞尔曲线,还原极其逼真的心形
*/
function drawHeartPath(context, cx, cy, size) {
const s = size
context.beginPath()
context.moveTo(cx, cy + s * 0.55)
context.bezierCurveTo(cx - s * 0.85, cy + s * 0.25, cx - s * 0.95, cy - s * 0.2, cx - s * 0.45, cy - s * 1.05)
context.bezierCurveTo(cx, cy - s * 0.55, cx + s * 0.45, cy - s * 1.05, cx + s * 0.45, cy - s * 1.05)
context.bezierCurveTo(cx + s * 0.95, cy - s * 0.2, cx + s * 0.85, cy + s * 0.25, cx, cy + s * 0.55)
context.moveTo(cx, cy - size * 0.25)
// 右半心
context.bezierCurveTo(cx + size * 0.5, cy - size * 0.65, cx + size * 1.1, cy + size * 0.15, cx, cy + size * 0.85)
// 左半心
context.bezierCurveTo(cx - size * 1.1, cy + size * 0.15, cx - size * 0.5, cy - size * 0.65, cx, cy - size * 0.25)
context.closePath()
}
/**
* [优化] 真实花瓣比例计算:水滴与樱花的结合体,
* 底部尖锐,顶部圆润,伴有自然凹陷。
*/
function drawPetal(context, x, y, size, rot) {
context.save()
context.translate(x, y)
context.rotate(rot)
context.beginPath()
context.moveTo(0, -size)
context.quadraticCurveTo(size * 0.7, -size * 0.2, 0, size)
context.quadraticCurveTo(-size * 0.7, -size * 0.2, 0, -size)
// 花瓣根部
context.moveTo(0, size * 0.5)
// 左侧弧线
context.bezierCurveTo(-size * 0.8, size * 0.2, -size * 0.6, -size * 0.8, 0, -size)
// 右侧弧线
context.bezierCurveTo(size * 0.6, -size * 0.8, size * 0.8, size * 0.2, 0, size * 0.5)
context.closePath()
context.fill()
context.restore()
@@ -328,11 +348,41 @@ 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'
const FONT_STACK_FALLBACK = '"Ma Shan Zheng", "Great Vibes", cursive, serif'
let cachedCalligraphyFontStack = ''
/**
* Canvas 不解析 CSS 变量。
* --font-calligraphy 首位是 Great Vibes拉丁花体无中文毛笔字形
* 礼物文案是中文,需把 Ma Shan Zheng 提到最前,否则易落到 cursive/系统字体,没有毛笔感。
*/
function calligraphyFontStack() {
if (cachedCalligraphyFontStack) return cachedCalligraphyFontStack
let raw = ''
try {
raw = getComputedStyle(document.documentElement)
.getPropertyValue('--font-calligraphy')
.trim()
} catch {
raw = ''
}
const base = raw || FONT_STACK_FALLBACK
// 已含马善政则提到最前;否则插入最前
if (/ma\s*shan\s*zheng/i.test(base)) {
const rest = base
.split(',')
.map((s) => s.trim())
.filter((s) => s && !/ma\s*shan\s*zheng/i.test(s))
cachedCalligraphyFontStack = [`"Ma Shan Zheng"`, ...rest].join(', ')
} else {
cachedCalligraphyFontStack = `"Ma Shan Zheng", ${base}`
}
return cachedCalligraphyFontStack
}
function setMilestoneFont(context, fontSize) {
// Ma Shan Zheng 只有 400用 700 时 iOS 会假粗并打乱字距/排版
context.font = `400 ${fontSize}px ${FONT_STACK}`
// Ma Shan Zheng 只有 400用 700 时 iOS 会假粗并打乱字距
context.font = `400 ${fontSize}px ${calligraphyFontStack()}`
if (typeof context.direction === 'string') context.direction = 'ltr'
if (typeof context.letterSpacing === 'string') context.letterSpacing = '0px'
}
@@ -426,6 +476,7 @@ function drawMilestoneLine(context, text, fontSize, glowBlur, gradient, strokeCo
context.fillStyle = textGrad
if (blur > 0) context.shadowBlur = blur * 1.15
context.fillText(ch, cx, y)
context.shadowBlur = 0
// 细高光
context.globalAlpha *= 0.35
@@ -588,8 +639,8 @@ function spawnParticles(giftType, now, wave = 0) {
for (let side = -1; side <= 1; side += 2) {
for (let i = 0; i < Math.floor(26 * boost); i += 1) {
const a = side < 0
? rand(-Math.PI * 0.35, Math.PI * 0.35)
: rand(Math.PI * 0.65, Math.PI * 1.35)
? rand(-Math.PI * 0.35, Math.PI * 0.35)
: rand(Math.PI * 0.65, Math.PI * 1.35)
makeParticle(cx + side * W * 0.28 + rand(-20, 20), cy + rand(-60, 60), now, palette, glowPalette, {
angle: a,
speed: rand(90, 200),
@@ -777,8 +828,8 @@ function playOne(item) {
activeMeta = { giftType, name, mt }
spawnParticles(giftType, now, 0)
const waves = style.shockwaves != null
? style.shockwaves
: (style.showHeartOutline && !isIOS ? 3 : 2)
? style.shockwaves
: (style.showHeartOutline && !isIOS ? 3 : 2)
spawnShockwave(W / 2, H * 0.46, now, style.glowColor, waves)
flashAlpha = isIOS ? Math.min(style.flash, giftType === 'concentric' ? 0.48 : 0.4) : style.flash
flashDecay = flashAlpha / Math.max(1, style.flashMs / 16)
@@ -825,10 +876,10 @@ function tryBumpSame(payload) {
}
// 叠加正在播放的特效
if (
activeMeta
&& activeMeta.giftType === payload.giftType
&& activeMeta.name === payload.name
&& texts.includes(activeMeta.mt)
activeMeta
&& activeMeta.giftType === payload.giftType
&& activeMeta.name === payload.name
&& texts.includes(activeMeta.mt)
) {
const mt = activeMeta.mt
const now = performance.now()
@@ -887,8 +938,8 @@ const play = (item) => {
if (!queueTimer) {
const active = texts[0]
const remain = active
? Math.max(200, active.duration - (performance.now() - active.birthTime) + 200)
: 400
? Math.max(200, active.duration - (performance.now() - active.birthTime) + 200)
: 400
scheduleNextFromQueue(remain)
}
return
@@ -904,9 +955,9 @@ const play = (item) => {
const playSequence = (items, opts = {}) => {
const max = opts.max != null ? opts.max : 10
const list = (Array.isArray(items) ? items : [])
.map(normalizePlayItem)
.filter(Boolean)
.slice(0, max)
.map(normalizePlayItem)
.filter(Boolean)
.slice(0, max)
if (!list.length) return
clearTimeout(queueTimer)
queueTimer = null
@@ -989,15 +1040,30 @@ function animate(timestamp) {
const age = (now - p.birthTime) / 1000
// 飘逸减速:速度随时间衰减,更高级
const damp = 1 / (1 + age * 0.55)
const x = p.x + p.vx * age * damp + Math.sin(age * 2.2 + p.rot) * (p.drift || 0) * 0.45
const y = p.y + p.vy * age * damp + 0.5 * p.gravity * age * age * 0.75
let vortexX = 0
let vortexY = 0
// 特效四:流体力学场 (Turbulence)
if (ENABLE_TURBULENCE) {
vortexX = Math.sin(age * 3.0 + p.birthTime) * 15 * damp
vortexY = Math.cos(age * 2.5 + p.rot) * 10 * damp
}
const x = p.x + p.vx * age * damp + Math.sin(age * 2.2 + p.rot) * (p.drift || 0) * 0.45 + vortexX
const y = p.y + p.vy * age * damp + 0.5 * p.gravity * age * age * 0.75 + vortexY
const opacity = progress < 0.12
? easeOutCubic(progress / 0.12)
: 1 - ((progress - 0.12) / 0.88) ** 1.15
? easeOutCubic(progress / 0.12)
: 1 - ((progress - 0.12) / 0.88) ** 1.15
const size = p.size * (progress < 0.15 ? 0.7 + progress * 1.6 : 1 - progress * 0.35)
const rot = p.rot + p.rotSpeed * age * 0.65
ctx.save()
// 特效一:叠加发光混合模式 (Blend Modes)
if (ENABLE_BLEND_MODES && (p.kind === 'spark' || p.kind === 'star' || p.kind === 'dot')) {
ctx.globalCompositeOperation = 'lighter'
}
ctx.globalAlpha = Math.max(0, opacity)
ctx.fillStyle = p.color
ctx.shadowColor = p.glow || p.color
@@ -1024,9 +1090,28 @@ function animate(timestamp) {
ctx.closePath()
ctx.fill()
} else if (p.kind === 'spark') {
ctx.translate(x, y)
ctx.rotate(rot)
ctx.fillRect(-size * 0.28, -size * 1.15, size * 0.56, size * 2.3)
// 特效二:物理拉丝拖尾 (Motion Blur)
if (ENABLE_MOTION_BLUR) {
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy) * damp;
const stretch = Math.max(1, speed * 0.15);
const angle = Math.atan2(p.vy, p.vx);
ctx.translate(x, y)
ctx.rotate(angle)
const tailGrad = ctx.createLinearGradient(0, 0, -size * stretch, 0);
tailGrad.addColorStop(0, p.color);
tailGrad.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = tailGrad;
ctx.beginPath();
ctx.ellipse(0, 0, size * stretch, size * 0.4, 0, 0, Math.PI * 2);
ctx.fill();
} else {
ctx.translate(x, y)
ctx.rotate(rot)
ctx.fillRect(-size * 0.28, -size * 1.15, size * 0.56, size * 2.3)
}
} else {
ctx.beginPath()
ctx.arc(x, y, size, 0, Math.PI * 2)
@@ -1052,7 +1137,7 @@ function animate(timestamp) {
if (opacity < 0.01) continue
const wobble = Math.sin((now - mt.birthTime) / 180) * mt.wobble
// soft aura behind textiOS 跳过径向渐变,避免卡顿/错乱)
// soft aura behind text
if (mt.aura && !SKIP_AURA) {
const auraR = Math.max(0.1, Number(fontSize) * 1.8 * Math.max(0, scale) || 0)
if (auraR > 0.1) {
@@ -1079,20 +1164,20 @@ function animate(timestamp) {
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,
{
underStroke: mt.underStroke,
charGap: mt.charGap,
birthTime: mt.birthTime,
now,
},
ctx,
mt.baseText || mt.text,
fontSize,
glowBlur,
mt.gradient,
mt.strokeColor,
mt.glowColor,
mainY,
{
underStroke: mt.underStroke,
charGap: mt.charGap,
birthTime: mt.birthTime,
now,
},
)
if (showMult) {
const n = scrolledCount(mt, now)
@@ -1104,42 +1189,70 @@ function animate(timestamp) {
ctx.translate(0, multY)
ctx.scale(pop, pop)
drawMilestoneLine(
ctx,
`×${n}`,
multSize,
glowBlur * 0.7,
mt.gradient,
mt.strokeColor,
mt.glowColor,
0,
{
underStroke: mt.underStroke,
charGap: 0.04,
birthTime: mt.countRevealAt || now,
now,
},
ctx,
`×${n}`,
multSize,
glowBlur * 0.7,
mt.gradient,
mt.strokeColor,
mt.glowColor,
0,
{
underStroke: mt.underStroke,
charGap: 0.04,
birthTime: mt.countRevealAt || now,
now,
},
)
ctx.restore()
}
ctx.restore()
// 核心心形轮廓重构:将难看的单线条换成多层高质感的霓虹流光轮廓
if (mt.showHeartOutline && heartOutlineOpacity(mt, now) > 0.01) {
const outlineScale = heartOutlineScale(mt, now)
const outlineOpacity = heartOutlineOpacity(mt, now) * 0.7
const outlineOpacity = heartOutlineOpacity(mt, now) * (ENABLE_NEON_GLOW ? 1.0 : 0.7)
const outlineSize = fontSize * 1.55 * outlineScale
ctx.save()
ctx.globalAlpha = outlineOpacity
ctx.translate(cx, cy)
ctx.strokeStyle = mt.glowColor
ctx.lineWidth = Math.max(1, outlineSize * 0.022)
ctx.shadowColor = mt.glowColor
ctx.shadowBlur = (isIOS ? 0 : outlineSize * 0.16) * SHADOW_MUL
drawHeartPath(ctx, 0, 0, outlineSize)
ctx.stroke()
if (ENABLE_NEON_GLOW) {
// [新增] 特效三:惊艳的霓虹流光多层绘制
ctx.globalCompositeOperation = 'lighter'
// 渲染 3 层不同的发光和线宽来模拟极具科技感和仙气的霓虹能量管
const neonLayers = [
{ widthRatio: 0.045, alpha: 0.25, blur: 20 * SHADOW_MUL }, // 底层弥散光晕
{ widthRatio: 0.02, alpha: 0.6, blur: 10 * SHADOW_MUL }, // 中层亮色核心
{ widthRatio: 0.006, alpha: 1.0, blur: 2 * SHADOW_MUL } // 顶层高亮白心
]
neonLayers.forEach((layer, idx) => {
ctx.globalAlpha = outlineOpacity * layer.alpha
// 顶层使用纯白,底层使用礼物的主题发光色
ctx.strokeStyle = idx === 2 ? '#ffffff' : mt.glowColor
ctx.lineWidth = Math.max(1, outlineSize * layer.widthRatio)
ctx.shadowColor = mt.glowColor
ctx.shadowBlur = layer.blur
drawHeartPath(ctx, 0, 0, outlineSize)
ctx.stroke()
})
} else {
// 降级旧方案(普通单线条描边)
ctx.globalAlpha = outlineOpacity
ctx.strokeStyle = mt.glowColor
ctx.lineWidth = Math.max(1, outlineSize * 0.022)
ctx.shadowColor = mt.glowColor
ctx.shadowBlur = (isIOS ? 0 : outlineSize * 0.16) * SHADOW_MUL
drawHeartPath(ctx, 0, 0, outlineSize)
ctx.stroke()
}
ctx.restore()
}
// 永结同心双心相扣,慢呼吸而非炫转
// 永结同心双心相扣)重构:同样支持霓虹发光材质
if (mt.showInterlockHearts && opacity > 0.12) {
const p = textProgress(mt, now)
const grow = easeOutCubic(Math.min(1, p / 0.28))
@@ -1147,32 +1260,54 @@ function animate(timestamp) {
const breath = Math.sin((now - mt.birthTime) / 900) * 0.04
const hs = fontSize * (1.05 + 0.28 * grow) * scale
const ox = fontSize * 0.38 * scale
ctx.save()
ctx.translate(cx, cy)
ctx.globalAlpha = opacity * 0.38 * fade
ctx.strokeStyle = mt.glowColor
ctx.lineWidth = Math.max(1, hs * 0.022)
ctx.shadowColor = mt.glowColor
ctx.shadowBlur = isIOS ? 0 : 6 * SHADOW_MUL
ctx.save()
ctx.translate(-ox, 0)
ctx.rotate(-0.2 + breath)
drawHeartPath(ctx, 0, 0, hs)
ctx.stroke()
ctx.restore()
ctx.save()
ctx.translate(ox, 0)
ctx.rotate(0.2 - breath)
drawHeartPath(ctx, 0, 0, hs)
ctx.stroke()
ctx.restore()
const drawInterlockHeart = (offsetX, rotateAngle) => {
ctx.save()
ctx.translate(offsetX, 0)
ctx.rotate(rotateAngle)
if (ENABLE_NEON_GLOW) {
ctx.globalCompositeOperation = 'lighter'
const neonLayers = [
{ widthRatio: 0.045, alpha: 0.2, blur: 15 * SHADOW_MUL },
{ widthRatio: 0.02, alpha: 0.5, blur: 8 * SHADOW_MUL },
{ widthRatio: 0.006, alpha: 0.9, blur: 2 * SHADOW_MUL }
]
neonLayers.forEach((layer, idx) => {
ctx.globalAlpha = opacity * fade * layer.alpha
ctx.strokeStyle = idx === 2 ? '#ffffff' : mt.glowColor
ctx.lineWidth = Math.max(1, hs * layer.widthRatio)
ctx.shadowColor = mt.glowColor
ctx.shadowBlur = layer.blur
drawHeartPath(ctx, 0, 0, hs)
ctx.stroke()
})
} else {
ctx.globalAlpha = opacity * 0.38 * fade
ctx.strokeStyle = mt.glowColor
ctx.lineWidth = Math.max(1, hs * 0.022)
ctx.shadowColor = mt.glowColor
ctx.shadowBlur = isIOS ? 0 : 6 * SHADOW_MUL
drawHeartPath(ctx, 0, 0, hs)
ctx.stroke()
}
ctx.restore()
}
// 绘制左心和右心
drawInterlockHeart(-ox, -0.2 + breath)
drawInterlockHeart(ox, 0.2 - breath)
ctx.restore()
}
if (mt.orb && opacity > 0.18) {
const numOrbs = mt.showInterlockHearts
? (isIOS ? 8 : 12)
: (mt.showHeartOutline ? 10 : 8)
? (isIOS ? 8 : 12)
: (mt.showHeartOutline ? 10 : 8)
const orbRadius = fontSize * (mt.showInterlockHearts ? 1.15 : 1.0 + 0.12 * glowIntensity)
const age = (now - mt.birthTime) / 1000
for (let j = 0; j < numOrbs; j += 1) {
@@ -1223,6 +1358,15 @@ const onResize = () => resizeCanvas()
onMounted(() => {
resizeCanvas()
// 预加载书法字体,避免首帧特效落到系统兜底字体
if (document.fonts?.load) {
const stack = calligraphyFontStack()
Promise.all([
document.fonts.load(`400 64px ${stack}`),
document.fonts.load('400 64px "Great Vibes"'),
document.fonts.load('400 64px "Ma Shan Zheng"'),
]).catch(() => {})
}
window.addEventListener('resize', onResize)
window.visualViewport?.addEventListener('resize', onResize)
window.visualViewport?.addEventListener('scroll', onResize)
@@ -1282,4 +1426,4 @@ defineExpose({ play, playSequence, clear: clearAll, stop: clearAll })
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
</style>
</style>

View File

@@ -696,6 +696,20 @@ function polaroidTransform(i) {
border-radius: 10px; scrollbar-width: none;
}
.g-carousel-slide { flex: 0 0 100%; min-width: 100%; height: 52vh; scroll-snap-align: center; }
@media (min-width: 768px) {
.gallery {
max-width: 400px;
margin-inline: auto;
}
.overlap-wrap {
max-width: 400px;
height: clamp(340px, 48vh, 460px);
}
.g-carousel-slide {
height: min(52vh, 420px);
}
}
.g-carousel-slide img { width: 100%; height: 100%; object-fit: cover; }
.g-arrow {
position: absolute; top: 50%; transform: translateY(-50%);

View File

@@ -2472,7 +2472,7 @@ watch(
text-shadow:
0 0 6px rgba(253, 251, 247, 0.95),
0 1px 0 rgba(255, 255, 255, 0.8);
transform: translate3d(calc(var(--scroll-p, 0) * (100vw - 1.1em) + 0.55em - 50%), 0, 0);
transform: translate3d(calc(var(--scroll-p, 0) * (100% - 1.1em) + 0.55em - 50%), 0, 0);
will-change: transform;
font-weight: 700;
letter-spacing: 0;
@@ -3582,4 +3582,97 @@ watch(
12% { opacity: 1; transform: translateY(4px) scale(.98); filter: blur(0); }
100% { opacity: 0; transform: translateY(-12px) scale(.94); filter: blur(1.4px); }
}
/* ---------- PC内容列限宽居中缩略图不宜铺满大屏 ---------- */
@media (min-width: 768px) {
.wrapper {
--pc-invite-width: 430px;
background:
radial-gradient(ellipse at 50% 12%, rgba(232, 201, 210, 0.12), transparent 42%),
linear-gradient(180deg, #f3eee6 0%, #ebe4d8 100%);
}
.scroll-transform-host {
max-width: var(--pc-invite-width);
margin-left: auto;
margin-right: auto;
background: #fdfbf7;
box-shadow:
0 0 0 1px rgba(168, 140, 107, 0.12),
0 28px 80px rgba(95, 70, 39, 0.12);
}
.scroll-progress {
left: 50%;
right: auto;
width: var(--pc-invite-width);
max-width: 100%;
transform: translateX(-50%);
}
.action-dock {
left: 50%;
right: auto;
width: var(--pc-invite-width);
max-width: 100%;
transform: translateX(-50%);
box-sizing: border-box;
}
/* 日历 PC略放大卡片与选中爱心移动端样式不变 */
.wedding-calendar-page {
min-height: 100dvh;
padding-top: 48px;
padding-bottom: 48px;
}
.calendar-card {
width: min(100%, 392px);
padding: 36px 28px 34px;
}
.calendar-card::before {
inset: 14px;
}
.calendar-kicker {
font-size: 11px;
letter-spacing: 0.5em;
padding-left: 0.5em;
}
.calendar-title {
margin-top: 14px;
font-size: 34px;
gap: 16px;
}
.calendar-title i {
width: 40px;
}
.calendar-date-line {
margin: 12px auto 26px;
font-size: 12px;
}
.calendar-weekdays,
.calendar-grid {
gap: 10px;
}
.calendar-weekdays {
margin-bottom: 11px;
font-size: 11px;
}
.calendar-day {
font-size: 16px;
}
.calendar-day.is-selected .calendar-day-num {
width: 28px;
height: 28px;
font-size: 17px;
}
.heart-ring {
inset: -12px;
}
.heart-ring svg {
width: 74px;
height: 72px;
}
.calendar-note {
margin-top: 28px;
font-size: 14px;
gap: 12px;
}
}
</style>