837 lines
32 KiB
HTML
837 lines
32 KiB
HTML
|
|
<!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>
|