Canvas刮刮乐是一种常见的网页交互效果,用户通过鼠标或触摸"刮开"覆盖层来查看隐藏内容。本文将深入解析一个完整的Canvas刮刮乐实现,涵盖核心技术和优化策略。

核心实现代码
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Canvas刮刮乐</title>
<style>
/* 样式代码 */
</style>
</head>
<body>
<div class="game-container">
<div class="scratch-container" id="scratch-card">
<div class="prize" aria-hidden="false">
<div class="prize-inner">
<h2>恭喜您!</h2>
<p>获得:50元优惠券</p>
</div>
</div>
<canvas id="scratch-canvas"></canvas>
</div>
<div class="controls">
<button class="reset-btn" id="reset-btn">重新刮奖</button>
<button class="reveal-btn" id="reveal-btn" title="直接揭晓(调试用)">直接揭晓</button>
</div>
<div class="hint">刮开超过 60% 将自动揭晓</div>
</div>
<script>
// JavaScript代码
</script>
</body>
</html>
关键技术点解析
1. 高分辨率适配
js
let dpr = Math.max(1, window.devicePixelRatio || 1);
function setupCanvas() {
const { w, h } = getCssSize();
// 设置物理像素大小,确保高分屏清晰
canvas.width = Math.max(1, Math.floor(w * dpr));
canvas.height = Math.max(1, Math.floor(h * dpr));
// 重置变换后应用 DPR 缩放
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
}
技术要点:
- 使用devicePixelRatio检测设备像素比
- Canvas物理尺寸 = CSS尺寸 × 设备像素比
- 通过ctx.scale()确保绘制坐标与CSS坐标一致
2. 刮擦效果实现
js
function drawCover(w, h) {
// 初始绘制覆盖层
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = '#95a5a6';
ctx.fillRect(0, 0, w, h);
// 关键:设置擦除模式
ctx.globalCompositeOperation = 'destination-out';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = brushSize * 2;
}
function scratchPoint(pt) {
if (!last) {
ctx.beginPath();
ctx.arc(pt.x, pt.y, brushSize, 0, Math.PI * 2);
ctx.fill();
last = pt;
return;
}
// 绘制线段连接点
ctx.beginPath();
ctx.moveTo(last.x, last.y);
ctx.lineTo(pt.x, pt.y);
ctx.stroke();
// 在终点补圆提升覆盖率
ctx.beginPath();
ctx.arc(pt.x, pt.y, brushSize, 0, Math.PI * 2);
ctx.fill();
last = pt;
}
技术要点:
- globalCompositeOperation: 'destination-out'是关键,使新绘制内容变为透明
- 结合线段和圆形绘制,实现平滑连续的刮擦轨迹
- 使用lineCap: 'round'和lineJoin: 'round'优化视觉效果
3. 统一指针事件处理
js
function onPointerDown(e) {
if (revealed) return;
e.preventDefault();
canvas.setPointerCapture?.(e.pointerId);
isDown = true;
last = null;
scratchPoint(cssToCanvasPoint(e));
}
// 事件绑定
canvas.addEventListener('pointerdown', onPointerDown, { passive: false });
canvas.addEventListener('pointermove', onPointerMove, { passive: false });
window.addEventListener('pointerup', onPointerUp, { passive: true });
技术要点:
- 使用Pointer Events统一处理鼠标、触摸和触笔输入
- passive: false允许调用preventDefault()防止滚动
- setPointerCapture确保指针移动事件持续捕获
4. 刮开比例检测
js
function getClearedRatio() {
const { width: W, height: H } = canvas;
const step = Math.max(1, Math.floor(6 * dpr)); // 性能优化:采样步长
const img = ctx.getImageData(0, 0, W, H).data;
let cleared = 0;
let total = 0;
for (let y = 0; y < H; y += step) {
const row = y * W * 4;
for (let x = 0; x < W; x += step) {
const idx = row + x * 4;
const alpha = img[idx + 3]; // 读取alpha通道
total++;
if (alpha < 16) { // 接近透明就算清除
cleared++;
}
}
}
return total === 0 ? 0 : cleared / total;
}
技术要点:
- 使用getImageData()获取像素数据
- 采样优化:通过步长减少计算量
- 检测alpha通道值判断是否透明
- 当刮开比例达到阈值(60%)时自动揭晓
5. 响应式与性能优化
js
// 窗口尺寸变化时重设Canvas
window.addEventListener('resize', () => {
init();
});
const init = () => {
dpr = Math.max(1, window.devicePixelRatio || 1);
setupCanvas();
};
技术要点:
- 监听resize事件确保Canvas尺寸正确
- 使用willReadFrequently: true提示浏览器优化读取操作
- CSS设置touch-action: none防止触控滚动干扰
附上详细代码
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Canvas刮刮乐</title>
<style>
:root {
--card-w: 300px;
--card-h: 200px;
--cover-color: #95a5a6;
--cover-text: rgba(255,255,255,0.95);
--accent: #3498db;
--accent-dark: #2980b9;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #f0f0f0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
.game-container {
display: grid;
gap: 16px;
justify-items: center;
}
.scratch-container {
position: relative;
width: var(--card-w);
height: var(--card-h);
border-radius: 10px;
overflow: hidden;
background: #fff;
box-shadow: 0 8px 20px rgba(0,0,0,0.15);
touch-action: none; /* 防止触控滚动影响刮擦 */
}
.prize {
position: absolute;
inset: 0;
display: grid;
place-items: center;
text-align: center;
padding: 12px;
}
.prize-inner {
display: grid;
gap: 6px;
}
.prize h2 {
margin: 0;
color: #e74c3c;
font-size: 22px;
letter-spacing: 0.5px;
}
.prize p {
margin: 0;
color: #333;
font-size: 16px;
}
canvas#scratch-canvas {
position: absolute;
inset: 0;
width: 100%; /* CSS 尺寸 */
height: 100%; /* CSS 尺寸 */
cursor: crosshair;
transition: opacity 280ms ease;
/* 指针事件在完全揭晓后会关闭 */
}
.controls {
display: flex;
gap: 10px;
}
.reset-btn, .reveal-btn {
padding: 8px 16px;
background-color: var(--accent);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.reset-btn:hover, .reveal-btn:hover {
background-color: var(--accent-dark);
}
.hint {
font-size: 12px;
color: #666;
text-align: center;
}
</style>
</head>
<body>
<div class="game-container">
<div class="scratch-container" id="scratch-card">
<div class="prize" aria-hidden="false">
<div class="prize-inner">
<h2>恭喜您!</h2>
<p>获得:50元优惠券</p>
</div>
</div>
<canvas id="scratch-canvas"></canvas>
</div>
<div class="controls">
<button class="reset-btn" id="reset-btn">重新刮奖</button>
<button class="reveal-btn" id="reveal-btn" title="直接揭晓(调试用)">直接揭晓</button>
</div>
<div class="hint">刮开超过 60% 将自动揭晓</div>
</div>
<script>
(function () {
const canvas = document.getElementById('scratch-canvas');
const resetBtn = document.getElementById('reset-btn');
const revealBtn = document.getElementById('reveal-btn');
const card = document.getElementById('scratch-card');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
let dpr = Math.max(1, window.devicePixelRatio || 1);
let isDown = false;
let last = null;
let revealed = false;
const brushSize = 18; // 画笔半径(视觉像素)
const autoRevealPercent = 0.6; // 60%
function getCssSize() {
const rect = card.getBoundingClientRect();
return { w: Math.round(rect.width), h: Math.round(rect.height) };
}
function setupCanvas() {
const { w, h } = getCssSize();
// 设置物理像素大小,确保高分屏清晰
canvas.width = Math.max(1, Math.floor(w * dpr));
canvas.height = Math.max(1, Math.floor(h * dpr));
// 重置变换后应用 DPR 缩放,使绘制时使用 CSS 坐标
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(dpr, dpr);
// 初始化覆盖层
drawCover(w, h);
revealed = false;
canvas.style.opacity = '1';
canvas.style.pointerEvents = 'auto';
}
function drawCover(w, h) {
// 回到正常绘制模式
ctx.globalCompositeOperation = 'source-over';
// 覆盖层背景
ctx.fillStyle = getComputedStyle(document.documentElement)
.getPropertyValue('--cover-color').trim() || '#95a5a6';
ctx.fillRect(0, 0, w, h);
// 添加提示文字
const text = '刮开此处查看奖品';
ctx.fillStyle = getComputedStyle(document.documentElement)
.getPropertyValue('--cover-text').trim() || '#ffffff';
ctx.font = '16px -apple-system, BlinkMacSystemFont, "Segoe UI", Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, w / 2, h / 2);
// 设置擦除模式与画笔样式(后续绘制用于"擦除")
ctx.globalCompositeOperation = 'destination-out';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = brushSize * 2;
}
function cssToCanvasPoint(e) {
const rect = canvas.getBoundingClientRect();
const x = (e.clientX - rect.left);
const y = (e.clientY - rect.top);
// 因为已经 ctx.scale(dpr, dpr),这里返回 CSS 坐标即可
return { x, y };
}
function scratchPoint(pt) {
// 使用线段连接使轨迹连续光滑
if (!last) {
ctx.beginPath();
ctx.arc(pt.x, pt.y, brushSize, 0, Math.PI * 2);
ctx.fill();
last = pt;
return;
}
ctx.beginPath();
ctx.moveTo(last.x, last.y);
ctx.lineTo(pt.x, pt.y);
ctx.stroke();
// 同时在终点补一个圆,提升覆盖率
ctx.beginPath();
ctx.arc(pt.x, pt.y, brushSize, 0, Math.PI * 2);
ctx.fill();
last = pt;
}
function onPointerDown(e) {
if (revealed) return;
e.preventDefault();
canvas.setPointerCapture?.(e.pointerId);
isDown = true;
last = null;
scratchPoint(cssToCanvasPoint(e));
}
function onPointerMove(e) {
if (!isDown || revealed) return;
e.preventDefault();
scratchPoint(cssToCanvasPoint(e));
}
function onPointerUp(e) {
if (!isDown) return;
isDown = false;
last = null;
// 抬起时计算清除比例
const p = getClearedRatio();
if (p >= autoRevealPercent) {
reveal();
}
}
function reveal() {
if (revealed) return;
revealed = true;
// 动画淡出 + 禁用指针事件
canvas.style.opacity = '0';
canvas.style.pointerEvents = 'none';
}
function reset() {
setupCanvas();
}
function getClearedRatio() {
// 在物理像素坐标系中读取
const { width: W, height: H } = canvas;
// 采样步长以提升性能(步长越大越快但越不精确)
const step = Math.max(1, Math.floor(6 * dpr));
const img = ctx.getImageData(0, 0, W, H).data;
let cleared = 0;
let total = 0;
// alpha 通道索引 = idx + 3
for (let y = 0; y < H; y += step) {
const row = y * W * 4;
for (let x = 0; x < W; x += step) {
const idx = row + x * 4;
const alpha = img[idx + 3]; // 0-255
total++;
if (alpha < 16) { // 接近透明就算清除
cleared++;
}
}
}
return total === 0 ? 0 : cleared / total;
}
// 事件绑定(使用 Pointer 统一鼠标/触控/触笔)
canvas.addEventListener('pointerdown', onPointerDown, { passive: false });
canvas.addEventListener('pointermove', onPointerMove, { passive: false });
window.addEventListener('pointerup', onPointerUp, { passive: true });
window.addEventListener('pointercancel', onPointerUp, { passive: true });
// 控制按钮
resetBtn.addEventListener('click', reset);
revealBtn.addEventListener('click', reveal);
// 初始化与在窗口缩放/DPR变化时自适应
const init = () => {
dpr = Math.max(1, window.devicePixelRatio || 1);
setupCanvas();
};
window.addEventListener('resize', () => {
// 仅当尺寸确实变化才重绘,避免不必要重置
init();
});
init();
})();
</script>
</body>
</html>