上一篇把轉盤從 jQuery 改寫成 TypeScript + Canvas,動畫交給 CSS transition 跑完固定 8 秒。這個方案乾淨、夠用,但業主還有一個需求沒實作:
玩家可以在 8 秒內自行按停,停在當下的位置,緩速滑行到對應獎項。
這個需求讓 CSS transition 直接出局。
CSS transition 為什麼做不到
CSS transition 的運作方式:你給它一個起點和終點,它負責動畫,你不需要管中間過程。
這在「固定跑完」的場景完全夠用。但「中途停止」需要兩件事:
- 知道此刻轉到幾度了:才能從當前位置計算緩停路徑
- 動態改變終點:玩家按停的時機不同,終點就不同
CSS transition 兩件事都做不到——你沒辦法在動畫途中讀出當前值,也沒辦法平滑地改變終點(強行改只會產生跳動)。
requestAnimationFrame 的思維
requestAnimationFrame(rAF)的方式完全不同:你自己逐幀計算,每幀更新角度,讓瀏覽器在下一次繪製前執行你的函式。
瀏覽器準備繪製下一幀
→ 執行你的 tick 函式
→ 更新 currentAngle
→ 呼叫下一個 requestAnimationFrame
→ 瀏覽器繪製
→ 執行你的 tick 函式
→ 更新 currentAngle
→ 呼叫下一個 requestAnimationFrame
→ ...
因為每一幀都在你的掌控裡,你可以:
- 隨時讀出當前角度(
angleRef.current) - 隨時切換行為:加速 → 巡航 → 緩停
速度設計:怎麼達到「10 圈 / 8 秒」
業主要求至少旋轉 10 圈、持續 8 秒。要在 rAF 裡達到這個效果,關鍵是巡航速度的設定。
@60fps,8 秒 = 480 幀。要在 480 幀內轉 10 圈(3600°):
3600° ÷ 480 幀 ≈ 7.5 deg/frame
實際取 CRUISE_SPEED = 8,略高於下限,讓視覺上看起來夠快:
const CRUISE_SPEED = 8; // deg/frame,@60fps ≈ 10.7 圈 / 8 秒起步時不要瞬間跳到巡航速度,每幀逐步加速:
speedRef.current = Math.min(speedRef.current + 0.4, CRUISE_SPEED);
angleRef.current += speedRef.current;狀態機設計
整個互動流程用四個 phase 描述:
idle → spinning → stopping → done
- idle:等待開始
- spinning:巡航中,玩家可隨時按停,或等 8 秒自動觸發
- stopping:緩速滑行到目標獎項
- done:停定,顯示結果
phase 同時存在 useState(驅動 UI)和 phaseRef(在 rAF tick 裡讀,避免 stale closure):
const [phase, setPhase] = useState<Phase>("idle");
const phaseRef = useRef<Phase>("idle");
// 切換 phase 時兩個都要更新
phaseRef.current = "spinning";
setPhase("spinning");tickRef 模式:讓 rAF 自我呼叫不出錯
rAF 的使用方式是:在 tick 裡更新角度,然後呼叫下一個 requestAnimationFrame(tick),讓它持續跑。
問題出在「tick 呼叫自己」這件事──在 React 裡,tick 通常是 useCallback,而 useCallback 產生的函式在建立當下就把所有用到的變數「封存」起來(這就是 closure)。如果 tick 直接寫 requestAnimationFrame(tick),跑的永遠是第一次建立的舊版本,不管後來狀態怎麼變。
解法很直觀:不要叫 tick 呼叫自己,改成叫它呼叫一個 ref。ref 的特性是永遠指向最新的值,所以每次執行都保證是最新版本的 tick:
const tickRef = useRef<() => void>(() => {});
const tick = useCallback(() => {
// ...
requestAnimationFrame(tickRef.current!); // 呼叫 ref,不是直接呼叫自己
}, []);
// tick 每次更新,就把 ref 也更新
useEffect(() => {
tickRef.current = tick;
}, [tick]);這樣 rAF 每幀執行的,都是最新版本的 tick。
tick 主迴圈
const tick = useCallback(() => {
const p = phaseRef.current;
if (p === "spinning") {
// 加速到巡航速度後保持
speedRef.current = Math.min(speedRef.current + 0.4, CRUISE_SPEED);
angleRef.current += speedRef.current;
setDisplayAngle(angleRef.current);
rafRef.current = requestAnimationFrame(tickRef.current!);
return;
}
if (p === "stopping") {
stopFrameRef.current += 1;
const t = Math.min(stopFrameRef.current / stopFramesRef.current, 1);
const eased = dynamicEaseOut(t, stopExpRef.current);
angleRef.current = stopOriginRef.current + eased * stopDeltaRef.current;
setDisplayAngle(angleRef.current);
if (t < 1) {
rafRef.current = requestAnimationFrame(tickRef.current!);
} else {
phaseRef.current = "done";
setPhase("done");
setResult(prizeRef.current);
}
}
}, []);緩停算法:動態 easing
緩停最難的地方是:玩家按停時的速度不一定——如果玩家在輪盤剛加速完就按停,起速很高;如果等了一陣子才按,速度已經在巡航了。不管哪種情況,緩停都不能有跳動感。
getAlignDelta:計算目標角度差值
從當前位置(正規化到 0–360)到目標獎項,最短需要轉多少度:
function getAlignDelta(prizeName: string, fromNormalized: number): number {
const prize = PRIZES.find((p) => p.name === prizeName);
if (!prize) return 0;
// 從該獎項的多個角度區間中隨機選一個,再隨機落點
const [min, max] = prize.range[Math.floor(Math.random() * prize.range.length)];
const target = Math.floor(Math.random() * (max - min + 1)) + min;
let delta = target - fromNormalized;
if (delta < 0) delta += 360; // 確保往前轉
return delta;
}dynamicEaseOut:讓起步速度平滑接續
標準 ease-out 是 f(t) = 1 - (1-t)^n,n 越大曲線越陡、起步越快。
關鍵是讓 f'(0)(起步速度)等於當前的巡航速度,這樣就沒有跳動。對 f 微分:
f'(0) = n
再換算成「每幀行進的角度」:
n = v0 × T / totalDelta
其中 v0 是當前速度(deg/frame)、T 是預定緩停幀數、totalDelta 是緩停總行程。
function dynamicEaseOut(t: number, n: number) {
return 1 - Math.pow(1 - t, n);
}
// beginDecel:收到停止指令時計算緩停路徑
const beginDecel = useCallback(() => {
clearTimeout(autoStopTimerRef.current);
const norm = ((angleRef.current % 360) + 360) % 360;
const alignDelta = getAlignDelta(prizeRef.current, norm);
const v0 = speedRef.current;
const T = stopFramesRef.current; // 預定 120 幀
const totalDelta = 2 * 360 + alignDelta; // 額外 2 圈 + 對齊差值
const n = Math.max((v0 * T) / totalDelta, 0.5);
stopOriginRef.current = angleRef.current;
stopDeltaRef.current = totalDelta;
stopFrameRef.current = 0;
stopExpRef.current = n;
phaseRef.current = "stopping";
setPhase("stopping");
}, []);緩停固定多跑 2 圈再對齊,是為了保留視覺上的「滑行」感,不會讓輪盤感覺像突然剎車。
handleStart 與 handleStop
const handleStart = useCallback(() => {
if (phaseRef.current !== "idle" && phaseRef.current !== "done") return;
speedRef.current = 0;
setResult(null);
// mock API:直接拿到中獎結果,實際應由後端回傳
prizeRef.current = selectedPrize;
phaseRef.current = "spinning";
setPhase("spinning");
rafRef.current = requestAnimationFrame(tickRef.current!);
// 8 秒後若玩家還沒按停,自動觸發
clearTimeout(autoStopTimerRef.current);
autoStopTimerRef.current = setTimeout(() => {
if (phaseRef.current === "spinning") beginDecel();
}, AUTO_STOP_MS);
}, [selectedPrize, beginDecel]);
const handleStop = useCallback(() => {
if (phaseRef.current !== "spinning") return;
beginDecel();
}, [beginDecel]);CSS transition 與 rAF 的選擇
| CSS transition | requestAnimationFrame | |
|---|---|---|
| 實作複雜度 | 低 | 高 |
| 中途停止 | ✗ | ✓ |
| 讀取當前角度 | ✗ | ✓ |
| 動態調整終點 | ✗ | ✓ |
| 效能 | 瀏覽器優化,極佳 | 自行管理,需注意 |
功能需求決定方案選擇。如果動畫是「一次決定終點跑完」,CSS transition 更乾淨;一旦需要「中途介入、動態終點」,rAF 是唯一選項。