topfans/frontend/static/html/tear-card.html
2026-04-27 18:20:38 +08:00

629 lines
25 KiB
HTML
Raw 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>伪3D撕纸 · 初始顶层覆盖 · 拖拽后露出底层</title> -->
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a2e;
color: #eee;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
user-select: none;
-webkit-user-select: none;
-webkit-tap-highlight-color: transparent;
}
h1 {
font-size: 20px;
font-weight: 400;
color: #e94560;
margin-bottom: 8px;
letter-spacing: 0.5px;
}
.subtitle {
color: #aaa;
font-size: 13px;
margin-bottom: 30px;
text-align: center;
line-height: 1.7;
}
.tear-container {
position: relative;
width: 90vw;
height: 70vh;
background: transparent;
border-radius: 12px;
overflow: visible;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
touch-action: none;
cursor: default;
}
.layer {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
border-radius: 12px;
font-weight: bold;
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
z-index: 1;
}
.layer-1 {
background: transparent;
color: rgba(0, 0, 0, 0.0);
font-size: 42px;
font-weight: 700;
pointer-events: auto;
}
.layer-1 img {
max-width: 80%;
max-height: 80%;
object-fit: contain;
display: none;
}
#tearCanvas {
position: absolute;
z-index: 10;
pointer-events: auto;
cursor: grab;
border-radius: 12px;
}
#tearCanvas:active {
cursor: grabbing;
}
.instructions {
margin-top: 20px;
color: #777;
font-size: 12px;
text-align: center;
line-height: 1.6;
max-width: 400px;
}
.reset-btn {
margin-top: 50px;
padding: 9px 24px;
background: transparent;
border: 1px solid #444;
color: #aaa;
border-radius: 20px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
letter-spacing: 0.5px;
}
.reset-btn:hover {
border-color: #e94560;
color: #e94560;
box-shadow: 0 0 12px rgba(233, 68, 96, 0.2);
}
@media (max-width: 440px) {
.tear-container {
width: 90vw;
height: 70vh;
}
h1 {
font-size: 18px;
}
}
</style>
</head>
<body>
<!-- <h1>伪3D撕纸 · 初始完全覆盖</h1> -->
<!-- <p class="subtitle">从顶部边缘向下拖拽 → 逐步撕开顶层,露出底层</p> -->
<div class="tear-container" id="container">
<div class="layer layer-1">
<img data-src="" alt="底层">
</div>
<canvas id="tearCanvas"></canvas>
</div>
<!-- <p class="instructions">
⬇️ 从顶部边缘拖拽 → 顶层撕裂并弯曲,底层显露<br>
🖱️ 只有在锯齿线附近(±20px)移动,才能改变弯曲中心<br>
🌊 拖拽接近底部 → 顶层逐渐淡出消失,完全露出底层<br>
双击画布或点击按钮重置
</p> -->
<button class="reset-btn" id="resetBtn">重 置</button>
<script>
(function () {
const container = document.getElementById('container');
const canvas = document.getElementById('tearCanvas');
const ctx = canvas.getContext('2d');
const resetBtn = document.getElementById('resetBtn');
const PAD = 48;
const DRAG_THRESHOLD = 3;
const EDGE_THRESHOLD = 26;
const TEAR_TOLERANCE = 20;
const JAGGED_SEGMENTS = 18;
const JAGGED_AMPLITUDE = 5.0;
const JAGGED_FREQ = 3.5;
const MAX_BEND = 54;
const MIN_BEND = 5;
const SHADOW_OFFSET_FACTOR = 0.7;
const THICKNESS_COLOR = '#6B4226';
const FADE_START_DISTANCE = 48;
// 从URL参数或data-src获取图片地址
function getBottomImageSrc() {
const params = new URLSearchParams(window.location.search);
const urlParam = params.get('imageUrl');
const dataSrc = document.querySelector('.layer-1 img').dataset.src;
return urlParam || dataSrc || '';
}
const BOTTOM_IMAGE_SRC = getBottomImageSrc();
let bottomImageLoaded = false;
let bottomImgEl = null;
let isDragging = false;
let hasTorn = false;
let containerW = 0, containerH = 0;
let canvasW = 0, canvasH = 0;
let offsetX = PAD, offsetY = PAD;
let edgePosition = 0; // 撕裂线Y坐标从0开始向下
let freeEdgeOffset = 0; // 纸片翘起偏移量
let bendCenterX = 0; // 弯曲中心X
let dragStartX = 0, dragStartY = 0;
function getCoords(e) {
const rect = container.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top, w: rect.width, h: rect.height };
}
function getTouchCoords(e) {
const touch = e.touches[0] || e.changedTouches[0];
const rect = container.getBoundingClientRect();
return { x: touch.clientX - rect.left, y: touch.clientY - rect.top, w: rect.width, h: rect.height };
}
function dist(x1, y1, x2, y2) { return Math.hypot(x1 - x2, y1 - y2); }
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
function getJaggedPoints(x1, y1, x2, y2, segments, amp, freq, seed) {
const points = [{ x: x1, y: y1 }];
const dx = x2 - x1, dy = y2 - y1;
const len = Math.hypot(dx, dy);
if (len < 1.5) { points.push({ x: x2, y: y2 }); return points; }
const ux = dx / len, uy = dy / len;
const px = -uy, py = ux;
for (let i = 1; i < segments; i++) {
const t = i / segments;
const bx = x1 + dx * t, by = y1 + dy * t;
const offset = amp * (Math.sin(t * freq * Math.PI + seed) * 0.7 +
Math.sin(t * freq * 2.3 * Math.PI + seed * 1.7) * 0.3);
points.push({ x: bx + px * offset, y: by + py * offset });
}
points.push({ x: x2, y: y2 });
return points;
}
function cx(x) { return x + offsetX; }
function cy(y) { return y + offsetY; }
// 计算顶层剩余部分的透明度(用于底部淡出效果)
function getTopLayerAlpha() {
if (!hasTorn) return 1;
const distanceToBottom = containerH - edgePosition;
if (distanceToBottom <= 0) return 0;
if (distanceToBottom >= FADE_START_DISTANCE) return 1;
return distanceToBottom / FADE_START_DISTANCE;
}
function updateEdgeTearAndCenter(mx, my) {
if (!hasTorn) return;
let newPos = clamp(my, 0, containerH);
if (newPos > edgePosition) edgePosition = newPos;
const desiredOffset = -my;
const absOffset = Math.abs(desiredOffset);
const clampedOffset = Math.min(absOffset, MAX_BEND);
const signedOffset = desiredOffset >= 0 ? clampedOffset : -clampedOffset;
if (Math.abs(signedOffset) > Math.abs(freeEdgeOffset)) {
freeEdgeOffset = signedOffset;
}
if (containerW > 0) {
bendCenterX = clamp(mx, 8, containerW - 8);
}
}
// 预加载底层图片
function preloadBottomImage(callback) {
if (!BOTTOM_IMAGE_SRC) {
callback();
return;
}
bottomImgEl = new Image();
bottomImgEl.src = BOTTOM_IMAGE_SRC;
bottomImgEl.onload = () => {
bottomImageLoaded = true;
callback();
};
bottomImgEl.onerror = () => {
bottomImageLoaded = false;
callback();
};
}
// 绘制明亮的底层(青瓷色,与顶层形成对比)
function drawBaseLayer() {
const w = containerW, h = containerH;
const grad = ctx.createLinearGradient(cx(0), cy(0), cx(w * 0.3), cy(h * 0.7));
grad.addColorStop(0, '#E9F5EF');
grad.addColorStop(0.5, '#CDE5D9');
grad.addColorStop(1, '#B0D2C2');
ctx.fillStyle = grad;
ctx.fillRect(cx(0), cy(0), w, h);
// 网格纹理
ctx.save();
ctx.globalAlpha = 0.2;
ctx.lineWidth = 0.8;
ctx.strokeStyle = '#5D8B7A';
for (let i = 0; i < w; i += 25) {
ctx.beginPath();
ctx.moveTo(cx(i), cy(0));
ctx.lineTo(cx(i), cy(h));
ctx.stroke();
}
for (let i = 0; i < h; i += 25) {
ctx.beginPath();
ctx.moveTo(cx(0), cy(i));
ctx.lineTo(cx(w), cy(i));
ctx.stroke();
}
ctx.restore();
// 底层图片
if (bottomImageLoaded && bottomImgEl) {
const scale = Math.min(w / bottomImgEl.width, h / bottomImgEl.height) * 0.8;
const drawW = bottomImgEl.width * scale;
const drawH = bottomImgEl.height * scale;
ctx.drawImage(bottomImgEl, cx(w / 2 - drawW / 2), cy(h / 2 - drawH / 2), drawW, drawH);
}
}
// 绘制未被撕裂的顶层部分(即撕裂线以下区域)
function drawUnrippedTop(alpha) {
if (!hasTorn) return;
if (alpha <= 0) return;
const w = containerW, h = containerH;
const tearY = edgePosition;
if (tearY >= h) return;
ctx.save();
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.rect(cx(0), cy(tearY), w, h - tearY);
ctx.clip();
// 顶层完整覆盖区域的材质(深色仿牛皮纸)
const grad = ctx.createLinearGradient(cx(0), cy(tearY), cx(w * 0.5), cy(h));
grad.addColorStop(0, '#B87C4E');
grad.addColorStop(0.6, '#9C633A');
grad.addColorStop(1, '#7A4A28');
ctx.fillStyle = grad;
ctx.fillRect(cx(0), cy(tearY), w, h - tearY);
// 添加细微纹理
ctx.globalCompositeOperation = 'overlay';
ctx.globalAlpha = 0.12;
for (let i = 0; i < 200; i++) {
ctx.fillStyle = '#FFF0D0';
ctx.beginPath();
ctx.arc(cx(Math.random() * w), cy(tearY + Math.random() * (h - tearY)), Math.random() * 2 + 0.5, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalCompositeOperation = 'source-over';
ctx.restore();
}
// 绘制翘起的纸片(撕裂线以上部分,带弯曲和厚度)
function drawTornPiece(alpha) {
if (!hasTorn) return;
if (alpha <= 0) return;
const w = containerW, h = containerH;
const absOff = Math.abs(freeEdgeOffset);
if (absOff < 1) return;
const bendDir = freeEdgeOffset > 0 ? 1 : -1;
const bendAmount = clamp(absOff, MIN_BEND, MAX_BEND);
const jaggedStart = { x: 0, y: edgePosition };
const jaggedEnd = { x: w, y: edgePosition };
const freeEdgeStart = { x: 0, y: edgePosition + freeEdgeOffset };
const freeEdgeEnd = { x: w, y: edgePosition + freeEdgeOffset };
let dynamicCenterX = bendCenterX;
if (isNaN(dynamicCenterX)) dynamicCenterX = w / 2;
dynamicCenterX = clamp(dynamicCenterX, 8, w - 8);
const arcMidX = dynamicCenterX;
const arcMidY = edgePosition + freeEdgeOffset + freeEdgeOffset * 0.5;
const seed = jaggedStart.x * 0.37 + jaggedEnd.y * 0.53;
const jagged = getJaggedPoints(jaggedStart.x, jaggedStart.y, jaggedEnd.x, jaggedEnd.y, JAGGED_SEGMENTS,
JAGGED_AMPLITUDE, JAGGED_FREQ, seed);
function buildPath(offX, offY) {
const pts = [];
for (const p of jagged) pts.push({ x: p.x + offX, y: p.y + offY });
const cpOff = bendAmount * 0.15;
pts.push({ x: freeEdgeEnd.x + offX + (bendDir * cpOff), y: freeEdgeEnd.y + offY });
pts.push({ x: arcMidX + offX, y: arcMidY + offY });
pts.push({ x: freeEdgeStart.x + offX + (bendDir * cpOff), y: freeEdgeStart.y + offY });
return pts;
}
const mainPath = buildPath(0, 0);
const shadowDir = -bendDir;
const shadowDist = bendAmount * SHADOW_OFFSET_FACTOR;
const shadowPath = buildPath(shadowDir * shadowDist, 0);
ctx.save();
ctx.globalAlpha = alpha;
// 阴影
ctx.beginPath();
ctx.moveTo(cx(shadowPath[0].x), cy(shadowPath[0].y));
for (let i = 1; i < shadowPath.length; i++) ctx.lineTo(cx(shadowPath[i].x), cy(shadowPath[i].y));
ctx.closePath();
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = 15;
ctx.shadowOffsetX = 3;
ctx.shadowOffsetY = 5;
ctx.fill();
// 厚度
const thick = bendAmount * 0.48;
function drawEdgeThick(p1, p2, sign) {
const dx = p2.x - p1.x, dy = p2.y - p1.y;
const len = Math.hypot(dx, dy);
if (len < 1) return;
const nx = -dy / len, ny = dx / len;
const offNx = nx * thick * sign, offNy = ny * thick * sign;
ctx.beginPath();
ctx.moveTo(cx(p1.x), cy(p1.y));
ctx.lineTo(cx(p1.x + offNx), cy(p1.y + offNy));
ctx.lineTo(cx(p2.x + offNx), cy(p2.y + offNy));
ctx.lineTo(cx(p2.x), cy(p2.y));
ctx.closePath();
const grad = ctx.createLinearGradient(cx(p1.x), cy(p1.y), cx(p2.x), cy(p2.y));
grad.addColorStop(0, '#9B6A3C');
grad.addColorStop(1, '#553A22');
ctx.fillStyle = grad;
ctx.fill();
}
drawEdgeThick(jagged[jagged.length - 1], mainPath[jagged.length], -1);
drawEdgeThick(mainPath[mainPath.length - 1], jagged[0], 1);
// 纸片正面(深色,与底层形成反差)
ctx.beginPath();
ctx.moveTo(cx(mainPath[0].x), cy(mainPath[0].y));
for (let i = 1; i < mainPath.length; i++) ctx.lineTo(cx(mainPath[i].x), cy(mainPath[i].y));
ctx.closePath();
const paperGrad = ctx.createLinearGradient(cx(freeEdgeStart.x), cy(freeEdgeStart.y), cx(jaggedStart.x), cy(jaggedStart.y));
paperGrad.addColorStop(0, '#D09E6E');
paperGrad.addColorStop(0.5, '#B47C48');
paperGrad.addColorStop(1, '#8B5A2E');
ctx.fillStyle = paperGrad;
ctx.fill();
ctx.strokeStyle = 'rgba(40,20,5,0.3)';
ctx.lineWidth = 1.2;
ctx.stroke();
// 弯曲高光
ctx.beginPath();
for (let i = jagged.length; i < mainPath.length; i++) ctx.lineTo(cx(mainPath[i].x), cy(mainPath[i].y));
ctx.strokeStyle = 'rgba(255,225,150,0.6)';
ctx.lineWidth = 2;
ctx.shadowBlur = 5;
ctx.stroke();
// 锯齿边缘金色高光
ctx.beginPath();
ctx.moveTo(cx(jagged[0].x), cy(jagged[0].y));
for (let i = 1; i < jagged.length; i++) ctx.lineTo(cx(jagged[i].x), cy(jagged[i].y));
ctx.strokeStyle = 'rgba(245,195,80,0.9)';
ctx.lineWidth = 2.0;
ctx.stroke();
ctx.restore();
}
// 绘制锯齿边缘线(撕裂线位置,仅在撕裂存在时显示)
function drawTearLine(alpha) {
if (!hasTorn) return;
if (alpha <= 0) return;
const w = containerW;
const seed = 0.618;
const jagged = getJaggedPoints(0, edgePosition, w, edgePosition, JAGGED_SEGMENTS, JAGGED_AMPLITUDE, JAGGED_FREQ, seed);
ctx.save();
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.moveTo(cx(jagged[0].x), cy(jagged[0].y));
for (let i = 1; i < jagged.length; i++) ctx.lineTo(cx(jagged[i].x), cy(jagged[i].y));
ctx.strokeStyle = 'rgba(210,150,70,0.8)';
ctx.lineWidth = 2.2;
ctx.shadowBlur = 6;
ctx.stroke();
ctx.restore();
}
// 绘制整个场景:底层 + 未被撕掉的顶层部分 + 翘起纸片 + 撕裂线
function drawScene() {
ctx.clearRect(0, 0, canvasW, canvasH);
drawBaseLayer(); // 先绘制底层(一直存在)
if (!hasTorn) {
// 未撕裂时,绘制完整顶层覆盖
ctx.save();
const w = containerW, h = containerH;
const grad = ctx.createLinearGradient(cx(0), cy(0), cx(w * 0.5), cy(h));
grad.addColorStop(0, '#B87C4E');
grad.addColorStop(0.6, '#9C633A');
grad.addColorStop(1, '#7A4A28');
ctx.fillStyle = grad;
ctx.fillRect(cx(0), cy(0), w, h);
// 添加纹理
ctx.globalCompositeOperation = 'overlay';
ctx.globalAlpha = 0.1;
for (let i = 0; i < 300; i++) {
ctx.fillStyle = '#FFF0D0';
ctx.beginPath();
ctx.arc(cx(Math.random() * w), cy(Math.random() * h), Math.random() * 2 + 0.5, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
return;
}
// 已撕裂状态:先绘制未被撕裂的顶层部分(撕裂线以下区域)
const alpha = getTopLayerAlpha();
drawUnrippedTop(alpha);
// 绘制翘起的纸片(撕裂线以上)
drawTornPiece(alpha);
// 绘制锯齿撕裂线
drawTearLine(alpha);
}
function resizeCanvas() {
const rect = container.getBoundingClientRect();
const newW = Math.round(rect.width);
const newH = Math.round(rect.height);
if (containerW === newW && containerH === newH && canvasW > 0) return;
if (hasTorn && containerW > 0 && containerH > 0) {
const sy = newH / containerH;
edgePosition = Math.min(edgePosition * sy, newH);
freeEdgeOffset *= sy;
}
containerW = newW;
containerH = newH;
canvasW = newW + 2 * PAD;
canvasH = newH + 2 * PAD;
offsetX = PAD;
offsetY = PAD;
canvas.width = canvasW;
canvas.height = canvasH;
canvas.style.width = canvasW + 'px';
canvas.style.height = canvasH + 'px';
canvas.style.left = -PAD + 'px';
canvas.style.top = -PAD + 'px';
if (containerW > 0) bendCenterX = clamp(bendCenterX, 5, containerW - 5);
else bendCenterX = containerW / 2;
drawScene();
}
// 交互逻辑
function onPointerDown(x, y) {
dragStartX = x;
dragStartY = y;
isDragging = true;
}
function onPointerMove(x, y) {
if (!isDragging) return;
if (!hasTorn) {
if (dist(x, y, dragStartX, dragStartY) < DRAG_THRESHOLD) return;
if (dragStartY > EDGE_THRESHOLD) {
isDragging = false;
return;
}
hasTorn = true;
edgePosition = clamp(dragStartY, 0, containerH);
let initOffset = -edgePosition;
initOffset = clamp(initOffset, -MAX_BEND, MAX_BEND);
freeEdgeOffset = initOffset;
bendCenterX = clamp(dragStartX, 8, containerW - 8);
drawScene();
updateEdgeTearAndCenter(x, y);
drawScene();
return;
}
const distToLine = Math.abs(y - edgePosition);
if (distToLine <= TEAR_TOLERANCE) {
updateEdgeTearAndCenter(x, y);
drawScene();
}
// 线外不更新任何状态
}
function onPointerUp() {
isDragging = false;
}
function resetTear() {
hasTorn = false;
isDragging = false;
edgePosition = 0;
freeEdgeOffset = 0;
if (containerW > 0) bendCenterX = containerW / 2;
drawScene();
}
// 事件绑定 - 修复:同时在 container 和 canvas 上绑定双击事件
container.addEventListener('mousedown', (e) => { e.preventDefault(); const c = getCoords(e); onPointerDown(c.x, c.y); });
window.addEventListener('mousemove', (e) => { if (!isDragging) return; e.preventDefault(); const c = getCoords(e); onPointerMove(c.x, c.y); });
window.addEventListener('mouseup', onPointerUp);
container.addEventListener('touchstart', (e) => { e.preventDefault(); const c = getTouchCoords(e); onPointerDown(c.x, c.y); }, { passive: false });
window.addEventListener('touchmove', (e) => { if (!isDragging) return; e.preventDefault(); const c = getTouchCoords(e); onPointerMove(c.x, c.y); }, { passive: false });
window.addEventListener('touchend', onPointerUp);
window.addEventListener('touchcancel', onPointerUp);
// 修复:在 container 和 canvas 上都添加双击事件
// container.addEventListener('dblclick', (e) => { e.preventDefault(); resetTear(); });
// canvas.addEventListener('dblclick', (e) => { e.preventDefault(); resetTear(); });
resetBtn.addEventListener('click', resetTear);
window.addEventListener('resize', () => { clearTimeout(window._rst); window._rst = setTimeout(resizeCanvas, 100); });
window.addEventListener('orientationchange', () => setTimeout(resizeCanvas, 200));
// 设置底层图片
const imgEl = document.querySelector('.layer-1 img');
imgEl.src = BOTTOM_IMAGE_SRC;
imgEl.dataset.src = BOTTOM_IMAGE_SRC;
// 预加载完成后初始化
preloadBottomImage(() => {
resizeCanvas();
resetTear();
});
resetTear();
})();
</script>
</body>
</html>