這是幾年前接的一個外包專案,功能是一個轉盤抽獎遊戲,讓使用者點擊後輪盤開始旋轉,最終停在某個獎項上。
業主當時提了幾個明確需求:
旋轉至少 10 圈:轉太少圈視覺上沒有「在抽獎」的感覺最少旋轉 8 秒後才停止:拉長期待感,不能一下就結束玩家可以在 8 秒內自行按停:讓玩家有參與感,「我自己停的」
當時以 jQuery + 原生 CSS animation 實作。現在把它重新用 TypeScript + React + Canvas API 改寫一遍,把它包成可以直接放進文章的元件。
以下就是當年外包的原始版本,使用靜態輪盤圖片 + CSS transform 旋轉,點擊中間按鈕就可以試玩:

原始 jQuery 版本
核心邏輯
原版用 jQuery 做三件事:
- 監聽按鈕點擊
- 計算目標角度,設定 CSS transform
- 用
setTimeout等動畫結束後重設狀態
let currentAngle = 0;
let totalAngle = 0;
let isSpinning = false;
const prizes = [
{ name: "頭獎", range: [[350, 368]] },
{ name: "二獎", range: [[140, 158]] },
{ name: "三獎", range: [[230, 248], [80, 98]] },
// ...
];
function getTargetAngle(_prize) {
let prize = prizes.find((p) => p.name === _prize);
let [min, max] = prize.range[Math.floor(Math.random() * prize.range.length)];
let rawTarget = Math.floor(Math.random() * (max - min + 1)) + min;
let targetAngle = rawTarget - currentAngle;
if (targetAngle < 0) targetAngle += 360;
return targetAngle;
}
$(".btn_start").click(() => {
if (isSpinning) return;
isSpinning = true;
const _prize = fetchRandomPrizeApi();
let targetAngle = getTargetAngle(_prize);
let extraRotations = 10 * 360;
totalAngle += extraRotations + targetAngle;
$(".lunpan").css({
transition: "transform 8s cubic-bezier(.21,.02,.14,.98)",
transform: `rotate(${totalAngle}deg)`,
});
setTimeout(() => {
isSpinning = false;
currentAngle = totalAngle % 360;
$(".lunpan").css({ transition: "none", transform: `rotate(${currentAngle}deg)` });
totalAngle = currentAngle;
}, 8000);
});幾個值得注意的設計細節
角度累積:totalAngle 不斷往上加(10圈 + 目標角度),這樣 CSS transition 才能產生「持續旋轉」的視覺效果。動畫結束後再把角度正規化回 0–360,避免數值無限膨脹。
指針方向:原版用的是一張實體輪盤圖片,prizes 的 range 是依照圖片上每個獎項對應的角度硬編碼進去的。後端回傳獲獎名稱,前端再查對應角度。
jQuery 的問題:狀態散落成全域變數(currentAngle、isSpinning),DOM 操作與邏輯混雜,沒有型別保護,也很難封裝成可重用的模組。
改寫為 TypeScript + React
輪盤繪製:Canvas API
不依賴外部圖片,改用 Canvas API 直接畫出輪盤:
function drawWheel(canvas: HTMLCanvasElement) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const size = canvas.width;
const cx = size / 2;
const cy = size / 2;
const outerR = size / 2 - 4;
const innerR = 28;
const SEG_ANGLE = 360 / PRIZES.length;
ctx.clearRect(0, 0, size, size);
for (let i = 0; i < PRIZES.length; i++) {
// -90° 讓第一格從 12 點鐘方向開始
const startRad = ((-90 + i * SEG_ANGLE) * Math.PI) / 180;
const endRad = ((-90 + (i + 1) * SEG_ANGLE) * Math.PI) / 180;
const midRad = (startRad + endRad) / 2;
// 扇形
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, outerR, startRad, endRad);
ctx.closePath();
ctx.fillStyle = PRIZES[i].color;
ctx.fill();
// 文字:旋轉到扇形中心方向再繪製
const textR = (outerR + innerR) / 2 + 8;
ctx.save();
ctx.translate(cx + textR * Math.cos(midRad), cy + textR * Math.sin(midRad));
ctx.rotate(midRad + Math.PI / 2);
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = PRIZES[i].textColor;
ctx.font = `bold ${Math.round(size / 15)}px sans-serif`;
ctx.fillText(PRIZES[i].name, 0, 0);
ctx.restore();
}
}角度計算邏輯
以 12 格輪盤為例,每格剛好 360 / 12 = 30°,從 12 點鐘順時針排列:
索引 i | 格子中心角 |
|---|---|
| 0 | 15° |
| 1 | 45° |
| 2 | 75° |
| 3 | 105° |
| … | … |
中心角公式:(i + 0.5) × 30°
要旋轉幾度才能對準指針?
把輪盤順時針轉 θ 度之後,指針(固定在頂端)指向的是輪盤上「360 - θ」的位置。
反推:要讓第 i 格的中心 (i + 0.5) × 30° 對準指針:
360 - θ = (i + 0.5) × 30
θ = 360 - (i + 0.5) × 30
幾個例子(數字都剛好是整數,方便理解):
目標 i | 中心角 | 需旋轉 θ |
|---|---|---|
| 0 | 15° | 345° |
| 3 | 105° | 255° |
| 6 | 195° | 165° |
| 9 | 285° | 75° |
通用公式:
const targetBase = (360 - (prizeIdx + 0.5) * SEG_ANGLE + 360) % 360;(+ 360) % 360 是為了避免 JavaScript % 對負數回傳負值。)
加入隨機偏移 + 補差值
每次都停在格子正中央會很假,加一個 ±70% 格寬的隨機偏移:
const variation = (Math.random() - 0.5) * SEG_ANGLE * 0.7;
const targetInRound = ((targetBase + variation) % 360 + 360) % 360;
// 從目前角度補差值,再多轉 10 圈
let delta = targetInRound - currentAngleRef.current % 360;
if (delta < 0) delta += 360; // 確保往前轉
totalAngleRef.current = currentAngleRef.current + 10 * 360 + delta;整合成函式:
const SEG_ANGLE = 360 / PRIZES.length;
function calcTargetAngle(prizeIdx: number, currentAngle: number): number {
const targetBase = (360 - (prizeIdx + 0.5) * SEG_ANGLE + 360) % 360;
const variation = (Math.random() - 0.5) * SEG_ANGLE * 0.7;
const targetInRound = ((targetBase + variation) % 360 + 360) % 360;
let delta = targetInRound - currentAngle % 360;
if (delta < 0) delta += 360;
return currentAngle + 10 * 360 + delta;
}狀態管理:useRef vs useState
// 旋轉角度用 ref:不需要觸發 re-render,setTimeout 內也能正確讀值
const currentAngleRef = useRef(0);
const totalAngleRef = useRef(0);
const isSpinning = useRef(false);
// 需要更新畫面的部分才用 state
const [spinning, setSpinning] = useState(false);
const [cssTransform, setCssTransform] = useState("rotate(0deg)");
const [cssTransition, setCssTransition] = useState("none");
const [result, setResult] = useState<Prize | null>(null);旋轉角度不需要驅動 UI 重繪,用 useRef 讓閉包中的 setTimeout 也能拿到最新的值。只有 CSS transform 字串、是否旋轉中、顯示結果這些才用 useState。
完整旋轉函式
const handleSpin = useCallback(() => {
if (isSpinning.current) return;
isSpinning.current = true;
setSpinning(true);
setShowResult(false);
// 實際場景:const prizeIdx = await fetchPrizeFromApi();
const prizeIdx = Math.floor(Math.random() * SEG_COUNT); // Demo 用
const targetBase = (360 - (prizeIdx + 0.5) * SEG_ANGLE + 360) % 360;
const variation = (Math.random() - 0.5) * SEG_ANGLE * 0.7;
const targetInRound = ((targetBase + variation) % 360 + 360) % 360;
let delta = targetInRound - currentAngleRef.current % 360;
if (delta < 0) delta += 360;
totalAngleRef.current = currentAngleRef.current + 10 * 360 + delta;
// 設定 CSS transition + transform → 觸發動畫
setCssTransition("transform 8s cubic-bezier(.21,.02,.14,.98)");
setCssTransform(`rotate(${totalAngleRef.current}deg)`);
setTimeout(() => {
isSpinning.current = false;
// 動畫結束後正規化角度,避免數值無限累積
currentAngleRef.current = totalAngleRef.current % 360;
totalAngleRef.current = currentAngleRef.current;
setCssTransition("none");
setCssTransform(`rotate(${currentAngleRef.current}deg)`);
setSpinning(false);
setResult(PRIZES[prizeIdx]);
setShowResult(true);
}, 8100);
}, []);jQuery vs TypeScript:對照整理
| 項目 | jQuery 版本 | TypeScript 版本 |
|---|---|---|
| 狀態管理 | 全域變數 | useRef / useState |
| DOM 操作 | $(".lunpan").css(...) | CSS transform state → inline style |
| 輪盤圖形 | 靜態圖片 | Canvas API 動態繪製 |
| 型別安全 | 無 | Prize 型別、完整 TypeScript |
| 可重用性 | 耦合頁面 HTML | 獨立 React 元件 |
| 獎項對應 | 硬編碼像素角度 | 數學公式動態計算索引 |
| 中獎決定 | 後端 API 回傳 | 後端 API 回傳(Demo 用前端亂數) |
從 jQuery 改成 TypeScript 最大的改變,不是語法,而是思維的轉換:從「找到 DOM 元素,直接修改它」,變成「宣告狀態,讓 React 負責同步到畫面」。
效果展示
目前這個版本用 CSS transition 固定跑完 8 秒,邏輯簡單清晰。但業主還有一個需求沒實作:玩家可以在 8 秒內自行按停。這個需求讓整個動畫架構必須重新設計——CSS transition 根本做不到,需要換成 requestAnimationFrame。
下一篇會從這個限制出發,完整說明 rAF 的逐幀控制思維,以及如何設計動態緩停算法。