起源
過年期間,朋友們凑在一起買了一堆刮刮樂——結果張張摃龜,連成本都沒回來。
有人說:「不如自己做一個,反正想中什麼寫什麼,也不用花錢。」
就這樣,這個小專案開始了。目標很簡單:
- 用 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
完整範例
把以上步驟組合成一個完整的 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>
);
}