Canvas刮刮乐实现详解:从基础绘制到性能优化

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>
相关推荐
007php0072 小时前
大厂深度面试相关文章:深入探讨底层原理与高性能优化
java·开发语言·git·python·面试·职场和发展·性能优化
dcloud_jibinbin3 小时前
【uniapp】解决小程序分包下的json文件编译后生成到主包的问题
前端·性能优化·微信小程序·uni-app·vue·json
MarkHD15 小时前
蓝牙钥匙 第67次 蓝牙大规模部署挑战:高密度环境下的性能优化与系统架构设计
性能优化·系统架构
努力的小郑16 小时前
Elasticsearch 避坑指南:我在项目中总结的 14 条实用经验
后端·elasticsearch·性能优化
AI_56781 天前
Webpack性能优化终极指南:4步实现闪电打包
前端·webpack·性能优化
dcloud_jibinbin2 天前
【uniapp】小程序体积优化,分包异步化
前端·vue.js·webpack·性能优化·微信小程序·uni-app
初恋叫萱萱2 天前
从正确到卓越:昇腾CANN算子开发高级性能优化指南
性能优化
进击的圆儿2 天前
HTTP协议深度解析:从基础到性能优化
网络协议·http·性能优化
潘达斯奈基~2 天前
spark性能优化2:Window操作和groupBy操作的区别
大数据·性能优化·spark