Files
hunli/hunliji-api/点赞特效.html

837 lines
32 KiB
HTML
Raw Normal View History

<!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>