Files
hunli/hunliji-api/点赞特效.html
2026-08-02 19:04:04 +08:00

837 lines
32 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>抖音直播点赞爱心效果</title>
<style>
:root {
--bg: #0a0a0f;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 100vw;
height: 100vh;
height: 100dvh;
overflow: hidden;
background: var(--bg);
font-family: 'PingFang SC', 'Helvetica Neue', 'Microsoft YaHei', sans-serif;
touch-action: manipulation;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
-webkit-tap-highlight-color: transparent;
cursor: pointer;
position: relative;
}
canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
/* 底部提示 */
.hint-bar {
position: absolute;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
align-items: center;
gap: 10px;
pointer-events: none;
transition: opacity 0.6s;
}
.hint-bar .icon-heart {
font-size: 24px;
animation: hintPulse 1.2s ease-in-out infinite;
}
.hint-bar .text {
color: rgba(255, 255, 255, 0.7);
font-size: 15px;
letter-spacing: 0.5px;
}
@keyframes hintPulse {
0%,
100% {
transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(1.35);
opacity: 1;
}
}
/* 点赞计数器 */
.like-counter {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) translateY(20px);
z-index: 5;
pointer-events: none;
text-align: center;
transition: transform 0.15s cubic-bezier(0.18, 0.89, 0.32, 1.28);
}
.like-counter.pop {
transform: translate(-50%, -50%) translateY(20px) scale(1.25);
}
.like-counter .count {
font-size: 56px;
font-weight: 700;
color: #fff;
text-shadow: 0 0 40px rgba(255, 45, 85, 0.7), 0 0 80px rgba(255, 45, 85, 0.4), 0 4px 12px rgba(0, 0, 0, 0.5);
line-height: 1;
}
.like-counter .label {
font-size: 13px;
color: rgba(255, 255, 255, 0.55);
letter-spacing: 2px;
margin-top: 4px;
}
/* 响应式 */
@media (max-width: 640px) {
.like-counter .count {
font-size: 42px;
}
.hint-bar {
bottom: 28px;
}
.hint-bar .text {
font-size: 13px;
}
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<!-- 点赞计数 -->
<div class="like-counter" id="likeCounter">
<div class="count" id="likeCount">0</div>
<div class="label">点 赞</div>
</div>
<!-- 底部提示 -->
<div class="hint-bar" id="hintBar">
<span class="icon-heart">❤️</span>
<span class="text">点击屏幕送出爱心</span>
</div>
<script>
(function() {
// ==================== DOM元素 ====================
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const likeCountEl = document.getElementById('likeCount');
const likeCounter = document.getElementById('likeCounter');
const hintBar = document.getElementById('hintBar');
// ==================== 配置 ====================
const CONFIG = {
MAX_HEARTS: 55, // 最大同时存在的爱心数
MAX_SPARKS: 120, // 最大同时存在的火花粒子数
HEART_MIN_SIZE: 14, // 爱心最小尺寸(px)
HEART_MAX_SIZE: 34, // 爱心最大尺寸(px)
HEART_MIN_LIFE: 1400, // 爱心最短寿命(ms)
HEART_MAX_LIFE: 2600, // 爱心最长寿命(ms)
RISE_SPEED_MIN: 70, // 最小上升速度(px/s)
RISE_SPEED_MAX: 150, // 最大上升速度(px/s)
DRIFT_AMP_MIN: 8, // 最小水平漂移振幅(px)
DRIFT_AMP_MAX: 45, // 最大水平漂移振幅(px)
LONG_PRESS_DELAY: 130, // 长按持续生成间隔(ms)
GLOW_BLUR: 10, // 发光模糊半径
GLOW_ALPHA: 0.55, // 发光透明度
DPR_CAP: 2, // 设备像素比上限
};
// ==================== 颜色调色板 ====================
const HEART_COLORS = [
'#ff2442', '#ff2d55', '#ff3b6e', '#ff4770',
'#ff5e82', '#ff6b8a', '#ff3c5c', '#ff1a3d',
'#e8223e', '#ff5079', '#ff3860', '#ff4d6d',
'#ff3355', '#ff4466', '#ff597f',
];
// 高光颜色(比主色更亮更粉)
const GLOW_COLORS = [
'#ff6b8a', '#ff7b99', '#ff8da6', '#ff9ab3',
'#ff7090', '#ff85a0', '#ff7795',
];
/**
* 随机选取数组元素
*/
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
/**
* 随机范围
*/
function rand(min, max) {
return min + Math.random() * (max - min);
}
// ==================== Canvas尺寸管理 ====================
let W, H, dpr;
function resizeCanvas() {
dpr = Math.min(window.devicePixelRatio || 1, CONFIG.DPR_CAP);
const rect = canvas.getBoundingClientRect();
W = rect.width;
H = rect.height;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
}
window.addEventListener('resize', resizeCanvas);
window.addEventListener('orientationchange', () => {
setTimeout(resizeCanvas, 100);
});
resizeCanvas();
// ==================== 爱心绘制函数 ====================
/**
* 在指定位置绘制爱心路径
* 使用贝塞尔曲线构建标准心形
* @param {CanvasRenderingContext2D} context
* @param {number} cx - 中心x
* @param {number} cy - 中心y
* @param {number} size - 爱心大小(约为宽度的一半)
*/
function drawHeartPath(context, cx, cy, size) {
const s = size;
// 爱心底部尖端
const bottomY = cy + s * 0.55;
// 爱心顶部凹陷
const topDipY = cy - s * 0.65;
// 左凸起控制点
const leftOuterX = cx - s * 0.95;
const leftOuterY = cy - s * 0.2;
const leftInnerX = cx - s * 0.45;
const leftInnerY = cy - s * 1.05;
// 右凸起控制点
const rightInnerX = cx + s * 0.45;
const rightInnerY = cy - s * 1.05;
const rightOuterX = cx + s * 0.95;
const rightOuterY = cy - s * 0.2;
context.beginPath();
// 从底部尖端开始
context.moveTo(cx, bottomY);
// 左半心形
context.bezierCurveTo(
cx - s * 0.85, cy + s * 0.25, // 左下控制点
leftOuterX, leftOuterY, // 左外控制点
leftInnerX, leftInnerY // 左内终点(顶部凹陷左侧)
);
// 右半心形(从顶部凹陷右侧回到尖端)
context.bezierCurveTo(
rightInnerX, rightInnerY, // 右内控制点
rightOuterX, rightOuterY, // 右外控制点
cx + s * 0.85, cy + s * 0.25 // 右下控制点
);
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 = CONFIG.GLOW_BLUR * (size / 20);
context.fillStyle = glowColor;
context.globalAlpha = glowAlpha * globalAlpha;
drawHeartPath(context, cx, cy, size);
context.fill();
context.restore();
}
// 主体填充 - 使用径向渐变模拟立体感
const gradient = context.createRadialGradient(
cx - size * 0.15, cy - size * 0.35, size * 0.08,
cx, cy, size * 0.9
);
gradient.addColorStop(0, glowColor);
gradient.addColorStop(0.45, color);
gradient.addColorStop(1, '#9a0018');
context.fillStyle = gradient;
context.shadowColor = 'transparent';
context.shadowBlur = 0;
drawHeartPath(context, cx, cy, size);
context.fill();
// 高光点(顶部小亮斑)
const highlightX = cx - size * 0.12;
const highlightY = cy - size * 0.42;
const highlightR = size * 0.14;
context.save();
context.globalAlpha = 0.5 * globalAlpha;
const hlGrad = context.createRadialGradient(
highlightX, highlightY, highlightR * 0.1,
highlightX, highlightY, highlightR
);
hlGrad.addColorStop(0, '#ffffff');
hlGrad.addColorStop(0.5, 'rgba(255,255,255,0.4)');
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();
}
// ==================== 粒子类 ====================
/**
* 爱心粒子
*/
class HeartParticle {
constructor(x, y, now) {
this.startX = x;
this.startY = y;
this.birthTime = now;
this.maxAge = rand(CONFIG.HEART_MIN_LIFE, CONFIG.HEART_MAX_LIFE);
this.baseSize = rand(CONFIG.HEART_MIN_SIZE, CONFIG.HEART_MAX_SIZE);
this.color = pick(HEART_COLORS);
this.glowColor = pick(GLOW_COLORS);
this.peakScale = rand(0.75, 1.35);
this.riseSpeed = rand(CONFIG.RISE_SPEED_MIN, CONFIG.RISE_SPEED_MAX);
this.driftAmp = rand(CONFIG.DRIFT_AMP_MIN, CONFIG.DRIFT_AMP_MAX);
this.driftFreq = rand(1.2, 2.8);
this.driftPhase = rand(0, Math.PI * 2);
this.rotation = rand(-0.4, 0.4);
this.rotationSpeed = rand(-0.35, 0.35);
// 微调:部分爱心有更大的初始偏移
this.extraDrift = (Math.random() < 0.25) ? rand(-25, 25) : 0;
this.alive = true;
this.spawnedSparks = false;
}
getAge(now) {
return now - this.birthTime;
}
getProgress(now) {
return Math.min(this.getAge(now) / this.maxAge, 1);
}
/**
* 获取当前缩放比例(弹入+渐出)
*/
getScale(now) {
const progress = this.getProgress(now);
// 弹入阶段前18%生命周期
if (progress < 0.18) {
const t = progress / 0.18;
// 弹性缓出:快速弹到峰值
const elastic = 1 - Math.pow(1 - t, 3.5);
// 加入微小过冲
const overshoot = Math.sin(t * Math.PI) * 0.08 * (1 - t);
return this.peakScale * (elastic + overshoot);
}
// 稳定/缓慢缩小阶段18%-65%
else if (progress < 0.65) {
const t = (progress - 0.18) / 0.47;
return this.peakScale * (1 - t * 0.25);
}
// 加速缩小阶段65%-100%
else {
const t = (progress - 0.65) / 0.35;
const eased = t * t;
return this.peakScale * 0.75 * (1 - eased);
}
}
/**
* 获取当前透明度
*/
getOpacity(now) {
const progress = this.getProgress(now);
if (progress < 0.45) return 1;
if (progress < 0.78) {
const t = (progress - 0.45) / 0.33;
return 1 - t * 0.35;
}
const t = (progress - 0.78) / 0.22;
const eased = t * t;
return 0.65 * (1 - eased);
}
/**
* 获取发光透明度
*/
getGlowAlpha(now) {
const progress = this.getProgress(now);
if (progress < 0.5) return CONFIG.GLOW_ALPHA;
if (progress < 0.8) {
const t = (progress - 0.5) / 0.3;
return CONFIG.GLOW_ALPHA * (1 - t);
}
return 0;
}
getY(now) {
const age = this.getAge(now);
const ageSec = age / 1000;
// 上升运动,后期略微加速
const speedMultiplier = 1 + (this.getProgress(now) > 0.7 ? (this.getProgress(now) - 0.7) / 0.3 *
0.4 : 0);
return this.startY - this.riseSpeed * ageSec * speedMultiplier;
}
getX(now) {
const age = this.getAge(now);
const ageSec = age / 1000;
const sinDrift = Math.sin(ageSec * this.driftFreq + this.driftPhase) * this.driftAmp;
const linearDrift = this.extraDrift * Math.min(ageSec / 1.5, 1);
return this.startX + sinDrift + linearDrift;
}
getRotation(now) {
const age = this.getAge(now);
return this.rotation + this.rotationSpeed * (age / 1000);
}
isDead(now) {
return this.getProgress(now) >= 1;
}
/**
* 判断是否应该产生火花(在生命末期)
*/
shouldSpawnSparks(now) {
return !this.spawnedSparks && this.getProgress(now) > 0.82;
}
}
/**
* 火花粒子(爱心消失时的小光点)
*/
class SparkParticle {
constructor(x, y, color, now) {
this.x = x;
this.y = y;
const angle = rand(0, Math.PI * 2);
const speed = rand(25, 90);
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed - rand(15, 50);
this.birthTime = now;
this.life = rand(250, 550);
this.color = color;
this.size = rand(1.5, 4.5);
this.alive = true;
}
getAge(now) {
return now - this.birthTime;
}
getProgress(now) {
return Math.min(this.getAge(now) / this.life, 1);
}
getOpacity(now) {
const p = this.getProgress(now);
return 1 - p * p;
}
getCurrentX(now) {
const age = this.getAge(now) / 1000;
return this.x + this.vx * age;
}
getCurrentY(now) {
const age = this.getAge(now) / 1000;
return this.y + this.vy * age + 0.5 * 80 * age * age; // 加入重力
}
isDead(now) {
return this.getProgress(now) >= 1;
}
}
// ==================== 全局状态 ====================
let hearts = [];
let sparks = [];
let totalLikes = 0;
let animationId = null;
let lastFrameTime = performance.now();
let longPressTimer = null;
let isPressing = false;
let lastClickTime = 0;
let rapidClickCount = 0;
const RAPID_CLICK_WINDOW = 400; // 快速点击窗口(ms)
const RAPID_CLICK_THRESHOLD = 3; // 触发爆发效果的点击次数
// ==================== 创建爱心 ====================
function spawnHeart(x, y, now) {
// 限制最大数量
if (hearts.length >= CONFIG.MAX_HEARTS) {
// 移除最老的爱心
const oldest = hearts.shift();
if (oldest && !oldest.spawnedSparks) {
spawnSparksForHeart(oldest, now);
}
}
const heart = new HeartParticle(x, y, now || performance.now());
hearts.push(heart);
return heart;
}
/**
* 为爱心生成火花粒子
*/
function spawnSparksForHeart(heart, now) {
if (heart.spawnedSparks) return;
heart.spawnedSparks = true;
const cx = heart.getX(now);
const cy = heart.getY(now);
const sparkCount = Math.floor(rand(3, 7));
for (let i = 0; i < sparkCount; i++) {
if (sparks.length >= CONFIG.MAX_SPARKS) {
sparks.shift();
}
const spark = new SparkParticle(cx, cy, heart.glowColor, now);
sparks.push(spark);
}
}
/**
* 爆发式生成多个爱心(快速点击时触发)
*/
function burstHearts(x, y, now) {
const count = Math.floor(rand(2, 5));
for (let i = 0; i < count; i++) {
const offsetX = rand(-30, 30);
const offsetY = rand(-25, 25);
const heart = spawnHeart(x + offsetX, y + offsetY, now);
// 爆发爱心有更大的初始速度和偏移
heart.riseSpeed *= rand(1.1, 1.5);
heart.driftAmp *= rand(1.2, 1.8);
heart.peakScale *= rand(0.9, 1.3);
}
}
// ==================== 更新点赞计数 ====================
function incrementLikes(count = 1) {
totalLikes += count;
likeCountEl.textContent = formatNumber(totalLikes);
// 弹跳动画
likeCounter.classList.add('pop');
setTimeout(() => likeCounter.classList.remove('pop'), 150);
}
function formatNumber(num) {
if (num >= 10000) {
const wan = num / 10000;
return wan >= 10 ? Math.floor(wan) + '万' : wan.toFixed(1) + '万';
}
return num.toLocaleString('zh-CN');
}
// ==================== 输入处理 ====================
function getEventPos(e) {
if (e.touches && e.touches.length > 0) {
return { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
if (e.changedTouches && e.changedTouches.length > 0) {
return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY };
}
return { x: e.clientX, y: e.clientY };
}
function handlePressStart(e) {
e.preventDefault();
const pos = getEventPos(e);
const now = performance.now();
isPressing = true;
// 检测快速点击
if (now - lastClickTime < RAPID_CLICK_WINDOW) {
rapidClickCount++;
} else {
rapidClickCount = 1;
}
lastClickTime = now;
// 触发快速点击爆发
if (rapidClickCount >= RAPID_CLICK_THRESHOLD) {
burstHearts(pos.x, pos.y, now);
incrementLikes(Math.floor(rand(2, 5)));
rapidClickCount = 0;
} else {
spawnHeart(pos.x, pos.y, now);
incrementLikes(1);
}
// 隐藏提示
if (hintBar.style.opacity !== '0') {
hintBar.style.transition = 'opacity 0.4s';
hintBar.style.opacity = '0';
}
// 长按定时器
if (longPressTimer) clearInterval(longPressTimer);
longPressTimer = setInterval(() => {
if (isPressing && hearts.length < CONFIG.MAX_HEARTS) {
// 长按时的位置有微小随机偏移
const offsetX = rand(-20, 20);
const offsetY = rand(-15, 15);
spawnHeart(pos.x + offsetX, pos.y + offsetY, performance.now());
incrementLikes(1);
}
}, CONFIG.LONG_PRESS_DELAY);
}
function handlePressMove(e) {
if (!isPressing) return;
e.preventDefault();
// 移动时也偶尔产生爱心(模拟滑动点赞)
const pos = getEventPos(e);
if (Math.random() < 0.35 && hearts.length < CONFIG.MAX_HEARTS) {
spawnHeart(pos.x, pos.y, performance.now());
incrementLikes(1);
}
}
function handlePressEnd(e) {
e.preventDefault();
isPressing = false;
rapidClickCount = 0;
if (longPressTimer) {
clearInterval(longPressTimer);
longPressTimer = null;
}
}
// 桌面端事件
canvas.addEventListener('mousedown', handlePressStart);
canvas.addEventListener('mousemove', handlePressMove);
canvas.addEventListener('mouseup', handlePressEnd);
canvas.addEventListener('mouseleave', handlePressEnd);
// 移动端事件
canvas.addEventListener('touchstart', handlePressStart, { passive: false });
canvas.addEventListener('touchmove', handlePressMove, { passive: false });
canvas.addEventListener('touchend', handlePressEnd);
canvas.addEventListener('touchcancel', handlePressEnd);
// 防止双击缩放
canvas.addEventListener('dblclick', (e) => e.preventDefault());
// ==================== 动画循环 ====================
function animate(timestamp) {
const now = timestamp || performance.now();
const deltaTime = Math.min(now - lastFrameTime, 50); // 限制最大deltaTime避免跳帧
lastFrameTime = now;
// 清除画布
ctx.clearRect(0, 0, W, H);
// 绘制半透明背景拖尾(可选:让画面有微弱的残影效果)
// 这里直接清除,保持干净
// 更新并绘制火花粒子(在爱心下方)
for (let i = sparks.length - 1; i >= 0; i--) {
const spark = sparks[i];
if (spark.isDead(now)) {
sparks.splice(i, 1);
continue;
}
const sx = spark.getCurrentX(now);
const sy = spark.getCurrentY(now);
const opacity = spark.getOpacity(now);
const size = spark.size * (1 - spark.getProgress(now) * 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--) {
const heart = hearts[i];
// 检查是否应该产生火花
if (heart.shouldSpawnSparks(now)) {
spawnSparksForHeart(heart, now);
}
// 移除死爱心
if (heart.isDead(now)) {
if (!heart.spawnedSparks) {
spawnSparksForHeart(heart, now);
}
hearts.splice(i, 1);
continue;
}
const cx = heart.getX(now);
const cy = heart.getY(now);
const scale = heart.getScale(now);
const size = heart.baseSize * scale;
const opacity = heart.getOpacity(now);
const glowAlpha = heart.getGlowAlpha(now);
const rotation = heart.getRotation(now);
// 检查是否在屏幕内
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);
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 > CONFIG.MAX_SPARKS) {
sparks.shift();
}
// 如果没有任何粒子且没有按压,逐渐显示提示
if (hearts.length === 0 && sparks.length === 0 && !isPressing) {
if (hintBar.style.opacity === '0' && totalLikes > 0) {
// 延迟显示提示
setTimeout(() => {
if (hearts.length === 0 && sparks.length === 0 && !isPressing) {
hintBar.style.opacity = '1';
}
}, 1500);
}
}
animationId = requestAnimationFrame(animate);
}
// ==================== 启动 ====================
function start() {
resizeCanvas();
lastFrameTime = performance.now();
if (animationId) cancelAnimationFrame(animationId);
animationId = requestAnimationFrame(animate);
}
// 初始显示提示
hintBar.style.opacity = '1';
// 自动生成几个爱心作为开场演示
function autoDemo() {
if (hearts.length > 0 || isPressing) return;
const now = performance.now();
const cx = W / 2 + rand(-40, 40);
const cy = H * 0.7 + rand(-30, 30);
spawnHeart(cx, cy, now);
incrementLikes(1);
hintBar.style.opacity = '0';
}
// 页面加载后延迟进行自动演示
setTimeout(() => {
autoDemo();
// 再延迟一下做第二次演示
setTimeout(() => {
if (hearts.length <= 1 && !isPressing) {
const now = performance.now();
const cx = W / 2 + rand(-50, 50);
const cy = H * 0.65;
spawnHeart(cx, cy, now);
spawnHeart(cx + rand(-25, 25), cy + rand(-20, 20), now);
incrementLikes(2);
}
}, 600);
}, 800);
start();
// ==================== 暴露API ====================
console.log('💖 抖音直播点赞爱心效果已就绪');
console.log(' - 点击屏幕产生爱心');
console.log(' - 长按持续产生爱心');
console.log(' - 快速连点触发爱心爆发');
console.log(' - 当前点赞数:' + totalLikes);
console.log(' 📐 Canvas尺寸' + Math.round(W) + '×' + Math.round(H) + ' @' + dpr + 'x');
// 暴露方法到全局
window.heartEffect = {
spawn: (x, y) => {
const now = performance.now();
spawnHeart(x ?? W / 2, y ?? H * 0.6, now);
incrementLikes(1);
},
burst: (x, y) => {
const now = performance.now();
burstHearts(x ?? W / 2, y ?? H * 0.6, now);
incrementLikes(Math.floor(rand(3, 6)));
},
getTotalLikes: () => totalLikes,
resetLikes: () => {
totalLikes = 0;
likeCountEl.textContent = '0';
},
clear: () => {
hearts = [];
sparks = [];
},
getStats: () => ({
hearts: hearts.length,
sparks: sparks.length,
totalLikes,
canvasWidth: Math.round(W),
canvasHeight: Math.round(H),
dpr,
}),
};
})();
</script>
</body>
</html>