"use client"; import { useEffect, useId, useRef, useState } from "react"; import { useReducedMotion } from "motion/react"; type SpinWheelProps = { /** Prizes round the wheel, 3 or more. */ items?: string[]; /** Enamel colours, used in turn. */ colors?: string[]; /** Tick on each peg. */ sound?: boolean; /** Called with the winning item and its index. */ onResult?: (item: string, index: number) => void; /** Read out by screen readers. */ label?: string; className?: string; }; const SIZE = 360; const CX = 180; const CY = 188; const R = 146; // Painted face. const HUB = 46; const BULBS = 24; const TAU = Math.PI * 2; const ITEMS = ["Chai", "Vadai", "Try again", "Bajji", "Jackpot", "Sundal", "Murukku", "Free refill"]; const COLORS = ["#c2342b", "#f0b43c", "#1b7a6e", "#f3e4c4"]; const dark = (hex: string) => { const v = parseInt(hex.slice(1), 16); return 0.299 * (v >> 16) + 0.587 * ((v >> 8) & 255) + 0.114 * (v & 255) < 150; }; const polar = (r: number, a: number) => [CX + r * Math.cos(a), CY + r * Math.sin(a)]; const clamp = (v: number, m: number) => Math.max(-m, Math.min(m, v)); type Engine = { spin: (speed: number) => void; grab: (x: number, y: number) => void; drag: (x: number, y: number) => void; drop: () => void }; /* SpinWheel: a fairground wheel in enamel paint, with brass pegs, a leather flipper and a ring of bulbs. Flick it or press the hub; the pegs slow it, the flipper holds it back at the end, and where it stops is the prize. */ export function SpinWheel({ items = ITEMS, colors = COLORS, sound = true, onResult, label = "Prize wheel", className = "" }: SpinWheelProps) { const reduce = useReducedMotion(); const id = useId().replace(/:/g, ""); const svg = useRef(null); const wheel = useRef(null); const flipper = useRef(null); const engine = useRef(null); const callback = useRef(onResult); const [spinning, setSpinning] = useState(false); const [win, setWin] = useState(-1); const n = items.length; const slice = TAU / n; useEffect(() => { callback.current = onResult; }, [onResult]); useEffect(() => { const zone = slice * 0.15; // Near a peg, the flipper pushes back. let angle = slice / 2; let speed = 0; let live = false; // A proper spin, so its stop counts. let held = false; let hand = 0; let handSpeed = 0; let handTime = 0; let flip = 0; let lastPeg = Math.floor(angle / slice); let lastTick = 0; let raf = 0; let then = 0; let audio: AudioContext | undefined; const peg = () => { const f = ((-angle % slice) + slice) % slice; return f < slice / 2 ? f : f - slice; }; const paint = () => { wheel.current?.setAttribute("transform", `rotate(${((angle * 180) / Math.PI).toFixed(2)} ${CX} ${CY})`); const d = peg(); const bend = Math.abs(d) < zone ? (d < 0 ? 1 : -1) * (1 - Math.abs(d) / zone) * 30 : 0; flip += (bend - flip) * 0.55; flipper.current?.setAttribute("transform", `rotate(${flip.toFixed(1)} ${CX} 6)`); }; const click = (now: number) => { if (!audio || now - lastTick < 34) return; lastTick = now; const t = audio.currentTime; const osc = audio.createOscillator(); const gain = audio.createGain(); osc.type = "square"; osc.frequency.value = 1500; gain.gain.setValueAtTime(0.045, t); gain.gain.exponentialRampToValueAtTime(0.0001, t + 0.03); osc.connect(gain).connect(audio.destination); osc.start(t); osc.stop(t + 0.035); }; const finish = () => { setSpinning(false); if (!live) return; live = false; const i = Math.floor((((-angle % TAU) + TAU) % TAU) / slice) % n; setWin(i); callback.current?.(items[i], i); }; const tick = (now: number) => { const dt = Math.min(0.033, (now - then) / 1000 || 0.016); then = now; if (!held) { const steps = Math.max(1, Math.ceil(Math.abs(speed) / 8)); for (let s = 0; s < steps; s++) { const h = dt / steps; const d = peg(); const dir = speed === 0 ? 0 : Math.sign(speed); speed -= (dir * 0.8 + speed * 0.3) * h; if (Math.sign(speed) !== dir) speed = 0; if (Math.abs(d) < zone) speed -= (d < 0 ? -1 : 1) * 22 * (1 - Math.abs(d) / zone) * h; angle += speed * h; } } const pegNow = Math.floor(angle / slice); if (pegNow !== lastPeg) { lastPeg = pegNow; click(now); } paint(); if (!held && Math.abs(speed) < 0.012 && Math.abs(peg()) >= zone) { speed = 0; raf = 0; flipper.current?.setAttribute("transform", `rotate(0 ${CX} 6)`); finish(); return; } raf = requestAnimationFrame(tick); }; const wake = () => { if (raf) return; then = performance.now(); raf = requestAnimationFrame(tick); }; const wakeSound = () => { if (!sound) return; audio ??= new AudioContext(); void audio.resume(); }; const pointAngle = (x: number, y: number) => Math.atan2(y - CY, x - CX); engine.current = { spin(start) { if (live) return; wakeSound(); setWin(-1); live = true; if (reduce) { angle = -(Math.floor(Math.random() * n) + 0.5) * slice; paint(); finish(); return; } setSpinning(true); speed = start; wake(); }, grab(x, y) { if (live) return; wakeSound(); held = true; hand = pointAngle(x, y); handSpeed = 0; handTime = performance.now(); setWin(-1); wake(); }, drag(x, y) { if (!held) return; const a = pointAngle(x, y); const now = performance.now(); const d = Math.atan2(Math.sin(a - hand), Math.cos(a - hand)); const gap = Math.max(1, now - handTime) / 1000; handSpeed = handSpeed * 0.5 + (d / gap) * 0.5; angle += d; hand = a; handTime = now; }, drop() { if (!held) return; held = false; const flick = performance.now() - handTime < 120 ? clamp(handSpeed, 22) : 0; if (Math.abs(flick) > 3) { this.spin(flick); return; } speed = 0; // A slow let-go just settles off the peg. }, }; return () => { cancelAnimationFrame(raf); engine.current = null; void audio?.close(); }; }, [items, n, slice, sound, reduce]); const local = (e: React.PointerEvent) => { const box = svg.current!.getBoundingClientRect(); return { x: ((e.clientX - box.left) / box.width) * SIZE, y: ((e.clientY - box.top) / box.height) * SIZE }; }; const fills = items.map((_, i) => (i === n - 1 && n % colors.length === 1 ? colors[1 % colors.length] : colors[i % colors.length])); const won = win >= 0 ? items[win] : ""; const hubSize = won ? Math.min(16, 118 / Math.max(5, won.length)) : 19; return (
{ e.currentTarget.setPointerCapture(e.pointerId); const { x, y } = local(e); engine.current?.grab(x, y); }} onPointerMove={(e) => { const { x, y } = local(e); engine.current?.drag(x, y); }} onPointerUp={() => engine.current?.drop()} onPointerCancel={() => engine.current?.drop()} > {/* Wooden rim with its bulbs, fixed to the wall. */} {Array.from({ length: BULBS }, (_, i) => { const [x, y] = polar(R + 14, (i / BULBS) * TAU); return ( ); })} {/* The turning face. Its transform stays a constant prop, so renders leave the live angle alone. */} {items.map((item, i) => { const a = -Math.PI / 2 + i * slice; const b = a + slice; const [x1, y1] = polar(R, a); const [x2, y2] = polar(R, b); const mid = ((a + slice / 2) * 180) / Math.PI; return ( = 0 && win !== i ? 0.5 : 1, transition: "opacity 0.3s" }}> {item} ); })} {items.map((_, i) => { const [x, y] = polar(R - 3, -Math.PI / 2 + i * slice); return ( ); })} {/* Lacquer light does not turn with the wheel. */} {/* Hub: the real button sits on top of it. */} {won || (spinning ? "" : "Spin")} {/* Leather flipper, pinned at the top of the rim. */}
); }