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