喂!我是 Wei

Front-End Engineer

Be a Problem Solver.

⌘K

導覽

所有文章緣起互動小功能

文章分類

目錄
起源核心概念:destination-out三步驟實現步驟一:畫出覆蓋圖層步驟二:刮除像素步驟三:計算刮除比例座標轉換觸控支援基礎版 Demo完整範例

相關文章

從 jQuery 到 TypeScript:打造轉盤抽獎元件

2026年3月9日

用 Vercel AI SDK v6 在 Next.js 加上 AI 聊天助手(免費用 Groq)

2026年3月20日

用 requestAnimationFrame 實作可中途停止的轉盤

2026年3月10日

最新文章
全部 →
前端 CI/CD 與正式環境除錯:從 Pull Request 到事故排查
2026-06-24
即時資料怎麼選?Polling、SSE、WebSocket 比較
2026-06-23
前端系統設計:如何拆元件、資料流與大型專案架構?
2026-06-22
無障礙不是加 ARIA:語意化 HTML、鍵盤操作與焦點管理
2026-06-21
CSS 與 RWD 面試整理:Flexbox、Grid、定位與層疊脈絡
2026-06-19
← 返回文章列表

用 Canvas 做刮刮樂:destination-out 的原理與實現

2026年3月11日·約 6 分鐘閱讀·
前端CanvasReactTypeScript

起源

過年期間,朋友們凑在一起買了一堆刮刮樂——結果張張摃龜,連成本都沒回來。

有人說:「不如自己做一個,反正想中什麼寫什麼,也不用花錢。」

就這樣,這個小專案開始了。目標很簡單:

  • 用 Canvas 模擬真實的刮塗層手感
  • 支援手機觸控
  • 刮夠一定比例後自動揭曉,不用把整張刮完

核心概念:destination-out

Canvas 有一個合成屬性 globalCompositeOperation,預設是 source-over(新畫的東西覆蓋在舊的上面)。

把它設成 destination-out 之後,畫的形狀不會「畫上去」,而是把原本的像素挖掉——這就是刮刮樂的核心魔法。

ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(x, y, 30, 0, Math.PI * 2);
ctx.fill();
// → 以 (x, y) 為圓心,半徑 30 的圓形範圍被「挖除」

三步驟實現

步驟一:畫出覆蓋圖層

Canvas 疊在獎項內容上方,初始化時填滿灰色作為「待刮」的遮罩:

function drawLayer(canvas: HTMLCanvasElement) {
  const ctx = canvas.getContext("2d")!;
  ctx.globalCompositeOperation = "source-over"; // 確保是覆蓋模式
  ctx.fillStyle = "#c0c0c0";
  ctx.fillRect(0, 0, canvas.width, canvas.height);
 
  ctx.fillStyle = "rgba(70,70,70,0.85)";
  ctx.font = "bold 20px sans-serif";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillText("✨ 刮刮看", canvas.width / 2, canvas.height / 2);
}

步驟二:刮除像素

每次滑鼠移動,把游標所在位置的圓形範圍挖掉:

function scratch(ctx: CanvasRenderingContext2D, x: number, y: number) {
  ctx.globalCompositeOperation = "destination-out";
  ctx.beginPath();
  ctx.arc(x, y, 28, 0, Math.PI * 2);
  ctx.fill();
  ctx.globalCompositeOperation = "source-over"; // 記得還原!
}

陷阱:操作完之後要把 globalCompositeOperation 還原為 source-over,否則後續所有繪製都會誤用 destination-out。

步驟三:計算刮除比例

用 getImageData 取出畫布所有像素,數透明的像素(alpha < 128)的比例:

function getScratchRatio(canvas: HTMLCanvasElement): number {
  const ctx = canvas.getContext("2d")!;
  const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
 
  let transparent = 0;
  // 每隔 4 個像素取樣一次(data 是 RGBA 陣列,每 4 bytes = 1 像素)
  // i += 16 代表跳過 3 個像素,只檢查第 4 個的 alpha (index + 3)
  for (let i = 3; i < data.length; i += 16) {
    if (data[i] < 128) transparent++;
  }
 
  return transparent / (data.length / 16); // 0 ~ 1
}

i += 16 表示每次跳過 4 個像素取樣一個(每個像素 4 bytes × 4 = 16),效能約是全量取樣的 1/4,誤差極小。


座標轉換

Canvas 元素的 CSS 尺寸(getBoundingClientRect)和畫布的內部尺寸(canvas.width)可能不同,需要手動換算:

function getXY(e: { clientX: number; clientY: number }, canvas: HTMLCanvasElement) {
  const rect = canvas.getBoundingClientRect();
  return {
    x: (e.clientX - rect.left) * (canvas.width  / rect.width),
    y: (e.clientY - rect.top)  * (canvas.height / rect.height),
  };
}

觸控支援

Touch 事件的座標在 e.touches[0],格式與 MouseEvent 相同(都有 clientX/clientY),只需多呼叫 e.preventDefault() 防止頁面滾動:

const onTouchMove = (e: React.TouchEvent<HTMLCanvasElement>) => {
  e.preventDefault(); // 阻止捲動
  if (!isDrawing.current) return;
  const { x, y } = getXY(e.touches[0], e.currentTarget); // Touch 也有 clientX/clientY
  scratch(x, y);
  sample();
};

基礎版 Demo

灰色遮罩 + 硬邊圓筆刷,這是最精簡的實現:

🎉

恭喜中獎!

$500

刮除進度0%

完整範例

把以上步驟組合成一個完整的 React 元件:

"use client";
 
import { useCallback, useEffect, useRef, useState } from "react";
 
const RADIUS = 28;           // 筆刷半徑(px)
const REVEAL_THRESHOLD = 0.50; // 刮除 50% 自動揭曉
 
function drawLayer(canvas: HTMLCanvasElement) {
  const ctx = canvas.getContext("2d")!;
  ctx.globalCompositeOperation = "source-over";
  ctx.fillStyle = "#c0c0c0";
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = "rgba(70,70,70,0.85)";
  ctx.font = "bold 20px sans-serif";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillText("✨ 刮刮看", canvas.width / 2, canvas.height / 2);
}
 
export default function ScratchCard() {
  const canvasRef  = useRef<HTMLCanvasElement>(null);
  const isDrawing  = useRef(false);
  const [pct,      setPct]      = useState(0);
  const [revealed, setRevealed] = useState(false);
  const [resetKey, setResetKey] = useState(0);
 
  useEffect(() => {
    const canvas = canvasRef.current;
    if (canvas) drawLayer(canvas);
  }, [resetKey]);
 
  const getXY = (e: { clientX: number; clientY: number }, canvas: HTMLCanvasElement) => {
    const r = canvas.getBoundingClientRect();
    return {
      x: (e.clientX - r.left) * (canvas.width  / r.width),
      y: (e.clientY - r.top)  * (canvas.height / r.height),
    };
  };
 
  const scratch = useCallback((x: number, y: number) => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d")!;
    ctx.globalCompositeOperation = "destination-out";
    ctx.beginPath();
    ctx.arc(x, y, RADIUS, 0, Math.PI * 2);
    ctx.fill();
    ctx.globalCompositeOperation = "source-over"; // 記得還原
  }, []);
 
  const sample = useCallback(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d")!;
    const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
    let transparent = 0;
    for (let i = 3; i < data.length; i += 16) {
      if (data[i] < 128) transparent++;
    }
    const ratio = transparent / (data.length / 16);
    setPct(Math.min(100, Math.round(ratio * 100)));
    if (ratio >= REVEAL_THRESHOLD) setRevealed(true);
  }, []);
 
  // ── 滑鼠 ──
  const onMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
    if (revealed) return;
    isDrawing.current = true;
    const { x, y } = getXY(e.nativeEvent, e.currentTarget);
    scratch(x, y);
  };
  const onMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
    if (!isDrawing.current || revealed) return;
    const { x, y } = getXY(e.nativeEvent, e.currentTarget);
    scratch(x, y);
    sample();
  };
  const onMouseUp = () => { isDrawing.current = false; sample(); };
 
  // ── 觸控 ──
  const onTouchStart = (e: React.TouchEvent<HTMLCanvasElement>) => {
    e.preventDefault();
    if (revealed) return;
    isDrawing.current = true;
    const { x, y } = getXY(e.touches[0], e.currentTarget);
    scratch(x, y);
  };
  const onTouchMove = (e: React.TouchEvent<HTMLCanvasElement>) => {
    e.preventDefault();
    if (!isDrawing.current || revealed) return;
    const { x, y } = getXY(e.touches[0], e.currentTarget);
    scratch(x, y);
    sample();
  };
  const onTouchEnd = () => { isDrawing.current = false; sample(); };
 
  const reset = () => {
    setRevealed(false);
    setPct(0);
    setResetKey((k) => k + 1);
  };
 
  return (
    <div className="flex flex-col items-center gap-4 py-6 select-none">
      {/* 卡片 */}
      <div className="relative w-80 h-48 rounded-xl overflow-hidden shadow-xl">
        {/* 獎項層(底層) */}
        <div className="absolute inset-0 flex flex-col items-center justify-center bg-amber-100">
          <p className="text-4xl">🎉</p>
          <p className="text-2xl font-black text-amber-600">$500</p>
        </div>
 
        {/* Canvas 刮除層(頂層) */}
        <canvas
          ref={canvasRef}
          width={320}
          height={192}
          className={[
            "absolute inset-0 w-full h-full touch-none transition-opacity duration-500",
            revealed ? "opacity-0 pointer-events-none" : "cursor-crosshair",
          ].join(" ")}
          onMouseDown={onMouseDown}
          onMouseMove={onMouseMove}
          onMouseUp={onMouseUp}
          onMouseLeave={onMouseUp}
          onTouchStart={onTouchStart}
          onTouchMove={onTouchMove}
          onTouchEnd={onTouchEnd}
        />
      </div>
 
      {/* 進度條 */}
      <div className="w-80 space-y-1">
        <div className="flex justify-between text-xs text-gray-500">
          <span>刮除進度</span>
          <span>{pct}%</span>
        </div>
        <div className="h-2 bg-gray-200 rounded-full overflow-hidden">
          <div
            className="h-full bg-amber-400 rounded-full transition-all duration-200"
            style={{ width: `${pct}%` }}
          />
        </div>
        <p className="text-[11px] text-gray-400">刮除 50% 即自動揭曉結果</p>
      </div>
 
      <button onClick={reset} className="px-4 py-1.5 rounded-lg text-sm bg-gray-100 hover:bg-gray-200">
        重置
      </button>
    </div>
  );
}
分享:XLinkedIn
← 上一篇用 requestAnimationFrame 實作可中途停止的轉盤
下一篇 →個人 Blog 需要 GitHub Actions CI 嗎?我試了之後的結論