271 lines
9.9 KiB
Markdown
271 lines
9.9 KiB
Markdown
---
|
||
title: "Building a 60 FPS Interactive Wheel of Fortune in HTML5 Canvas & GSAP: Friction, Physics & Sound Triggering"
|
||
description: "How to build a high-performance 60 FPS wheel of fortune using HTML5 2D Canvas, GSAP custom easing, physics damping, and Web Audio tick synchronization."
|
||
tags: ["javascript", "webdev", "canvas", "frontend"]
|
||
canonical_url: "https://entscheidomat.com/ratgeber/gluecksrad-online-drehen"
|
||
target_keywords: ["glücksrad online", "glücksrad drehen", "entscheidungsrad online", "canvas wheel of fortune", "gsap wheel animation"]
|
||
---
|
||
|
||
# Building a 60 FPS Interactive Wheel of Fortune in HTML5 Canvas & GSAP: Friction, Physics & Sound Triggering
|
||
|
||
Interactive spinners and wheels of fortune are among the most engaging UI components on the web. From marketing giveaways to decision utilities like a [Glücksrad online](https://entscheidomat.com/gluecksrad), a well-designed wheel needs to look smooth, feel physically grounded, and land accurately on its selected segment without visual stuttering.
|
||
|
||
However, naive implementations using CSS rotations or DOM elements (`<div>` slices rotated around a pivot) quickly suffer from performance degradation, text blurriness, and dynamic segment layout bugs when scaling beyond 8–10 items.
|
||
|
||
In this article, we will build a production-ready, 60 FPS interactive **HTML5 2D Canvas Wheel of Fortune** integrated with **GSAP (GreenSock)**, custom physics deceleration, and real-time Web Audio tick sound effects.
|
||
|
||
---
|
||
|
||
## 1. The Physics of Rotational Friction & Segment Indexing
|
||
|
||
To make a digital wheel feel tangible, its deceleration must mimic physical rotational friction.
|
||
|
||
### Rotational Physics Equations
|
||
When a force spins a wheel, it acquires an initial angular velocity $\omega_0$ (radians per second). Under constant angular friction $\alpha$, its angular displacement $\theta(t)$ over time $t$ is:
|
||
|
||
$$\theta(t) = \omega_0 t - \frac{1}{2} \alpha t^2$$
|
||
|
||
In GSAP, we can model this friction curve smoothly using `power4.out` or `cubic-bezier(0.25, 1, 0.5, 1)` easing.
|
||
|
||
### Calculating Segment Index from Final Angle
|
||
Suppose a wheel has $N$ segments, each occupying an arc angle of $\Delta \theta = \frac{2\pi}{N}$ radians ($360^\circ / N$).
|
||
|
||
If the wheel settles at a total cumulative rotation angle $\theta_{\text{total}}$ (in degrees), and the pointer is located at the top ($270^\circ$ or $90^\circ$ offset depending on canvas coordinate space), the winning segment index $I_{\text{win}}$ is calculated as:
|
||
|
||
$$I_{\text{win}} = \left\lfloor \frac{(360 - (\theta_{\text{total}} \bmod 360) + \text{offset}) \bmod 360}{360 / N} \right\rfloor$$
|
||
|
||
---
|
||
|
||
## 2. Drawing Responsive Canvas Arcs in TypeScript
|
||
|
||
Below is the core HTML5 2D Canvas renderer. It handles dynamic segment counts, vibrant color palettes, crisp text rendering, and high-DPI (Retina) display scaling.
|
||
|
||
```typescript
|
||
export interface WheelSegment {
|
||
label: string;
|
||
color: string;
|
||
}
|
||
|
||
export class CanvasWheelRenderer {
|
||
private canvas: HTMLCanvasElement;
|
||
private ctx: CanvasRenderingContext2D;
|
||
private segments: WheelSegment[];
|
||
|
||
constructor(canvas: HTMLCanvasElement, segments: WheelSegment[]) {
|
||
this.canvas = canvas;
|
||
const context = canvas.getContext("2d");
|
||
if (!context) throw new Error("Could not get 2D context");
|
||
this.ctx = context;
|
||
this.segments = segments;
|
||
this.setupHighDPI();
|
||
}
|
||
|
||
private setupHighDPI(): void {
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const rect = this.canvas.getBoundingClientRect();
|
||
this.canvas.width = rect.width * dpr;
|
||
this.canvas.height = rect.height * dpr;
|
||
this.ctx.scale(dpr, dpr);
|
||
}
|
||
|
||
/**
|
||
* Renders the wheel at a given rotation angle (in degrees).
|
||
*/
|
||
public draw(rotationAngleDeg: number): void {
|
||
const rect = this.canvas.getBoundingClientRect();
|
||
const width = rect.width;
|
||
const height = rect.height;
|
||
const centerX = width / 2;
|
||
const centerY = height / 2;
|
||
const radius = Math.min(centerX, centerY) - 10;
|
||
|
||
const numSegments = this.segments.length;
|
||
const arcAngle = (2 * Math.PI) / numSegments;
|
||
const rotationRad = (rotationAngleDeg * Math.PI) / 180;
|
||
|
||
this.ctx.clearRect(0, 0, width, height);
|
||
this.ctx.save();
|
||
this.ctx.translate(centerX, centerY);
|
||
this.ctx.rotate(rotationRad);
|
||
|
||
// 1. Draw Segments
|
||
for (let i = 0; i < numSegments; i++) {
|
||
const startAngle = i * arcAngle;
|
||
const endAngle = startAngle + arcAngle;
|
||
|
||
this.ctx.beginPath();
|
||
this.ctx.moveTo(0, 0);
|
||
this.ctx.arc(0, 0, radius, startAngle, endAngle);
|
||
this.ctx.closePath();
|
||
|
||
this.ctx.fillStyle = this.segments[i].color;
|
||
this.ctx.fill();
|
||
this.ctx.lineWidth = 2;
|
||
this.ctx.strokeStyle = "#ffffff";
|
||
this.ctx.stroke();
|
||
|
||
// 2. Draw Text Labels
|
||
this.ctx.save();
|
||
this.ctx.rotate(startAngle + arcAngle / 2);
|
||
this.ctx.textAlign = "right";
|
||
this.ctx.fillStyle = "#ffffff";
|
||
this.ctx.font = "bold 14px sans-serif";
|
||
this.ctx.shadowColor = "rgba(0,0,0,0.5)";
|
||
this.ctx.shadowBlur = 4;
|
||
this.ctx.fillText(this.segments[i].label, radius - 20, 5);
|
||
this.ctx.restore();
|
||
}
|
||
|
||
this.ctx.restore();
|
||
|
||
// 3. Draw Fixed Top Pointer (Indicator)
|
||
this.drawPointer(centerX, centerY - radius);
|
||
}
|
||
|
||
private drawPointer(x: number, y: number): void {
|
||
this.ctx.save();
|
||
this.ctx.beginPath();
|
||
this.ctx.moveTo(x - 12, y - 10);
|
||
this.ctx.lineTo(x + 12, y - 10);
|
||
this.ctx.lineTo(x, y + 15);
|
||
this.ctx.closePath();
|
||
this.ctx.fillStyle = "#ef4444";
|
||
this.ctx.fill();
|
||
this.ctx.strokeStyle = "#ffffff";
|
||
this.ctx.lineWidth = 2;
|
||
this.ctx.stroke();
|
||
this.ctx.restore();
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Integrating GSAP Animation & Web Audio Ticks
|
||
|
||
To achieve 60 FPS animation with dynamic audio feedback, we hook GSAP's `gsap.to()` tween to our Canvas renderer's `draw()` method. Every time the rotation crosses a segment boundary, we trigger a short Web Audio tick sound.
|
||
|
||
```typescript
|
||
import gsap from "gsap";
|
||
|
||
export class InteractiveWheelController {
|
||
private renderer: CanvasWheelRenderer;
|
||
private currentRotation: number = 0;
|
||
private numSegments: number;
|
||
private lastTickSegment: number = -1;
|
||
private audioCtx?: AudioContext;
|
||
|
||
constructor(renderer: CanvasWheelRenderer, numSegments: number) {
|
||
this.renderer = renderer;
|
||
this.numSegments = numSegments;
|
||
}
|
||
|
||
private playTickSound(): void {
|
||
if (!this.audioCtx) {
|
||
this.audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||
}
|
||
if (this.audioCtx.state === "suspended") {
|
||
this.audioCtx.resume();
|
||
}
|
||
|
||
const osc = this.audioCtx.createOscillator();
|
||
const gain = this.audioCtx.createGain();
|
||
|
||
osc.type = "triangle";
|
||
osc.frequency.setValueAtTime(600, this.audioCtx.currentTime);
|
||
osc.frequency.exponentialRampToValueAtTime(150, this.audioCtx.currentTime + 0.03);
|
||
|
||
gain.gain.setValueAtTime(0.3, this.audioCtx.currentTime);
|
||
gain.gain.exponentialRampToValueAtTime(0.001, this.audioCtx.currentTime + 0.03);
|
||
|
||
osc.connect(gain);
|
||
gain.connect(this.audioCtx.destination);
|
||
|
||
osc.start();
|
||
osc.stop(this.audioCtx.currentTime + 0.03);
|
||
}
|
||
|
||
public spinToSegment(winningIndex: number, onComplete?: () => void): void {
|
||
const segmentAngle = 360 / this.numSegments;
|
||
|
||
// Target angle calculation: Full spins (5 rotations) + segment offset
|
||
const fullSpins = 5 * 360;
|
||
// Align winning segment to top pointer (270 degrees)
|
||
const targetSegmentOffset = 270 - (winningIndex * segmentAngle + segmentAngle / 2);
|
||
|
||
// Normalize target angle
|
||
const targetRotation = this.currentRotation + fullSpins + (targetSegmentOffset - (this.currentRotation % 360));
|
||
|
||
gsap.to(this, {
|
||
currentRotation: targetRotation,
|
||
duration: 4.5,
|
||
ease: "power4.out",
|
||
onUpdate: () => {
|
||
// Redraw Canvas
|
||
this.renderer.draw(this.currentRotation);
|
||
|
||
// Calculate tick boundaries
|
||
const currentSegment = Math.floor((this.currentRotation % 360) / segmentAngle);
|
||
if (currentSegment !== this.lastTickSegment) {
|
||
this.playTickSound();
|
||
this.lastTickSegment = currentSegment;
|
||
}
|
||
},
|
||
onComplete: () => {
|
||
if (onComplete) onComplete();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Performance Optimization Checklist
|
||
|
||
| Optimization | Method | Impact |
|
||
| :--- | :--- | :--- |
|
||
| **High-DPI Retina Displays** | `canvas.width = width * devicePixelRatio` | Prevents blurry text on iPhones/Macs |
|
||
| **Procedural Audio** | Web Audio Oscillators instead of MP3 files | 0ms audio latency, zero network requests |
|
||
| **Single Canvas Pipeline** | Direct 2D context drawing over DOM elements | Constant 60 FPS performance regardless of segment count |
|
||
| **GSAP Power4.out** | Realistic friction deceleration curve | Natural physical wheel feel |
|
||
|
||
---
|
||
|
||
## Summary & Live Demo
|
||
|
||
1. HTML5 Canvas 2D is significantly faster and cleaner than rotating DOM elements for wheels with dynamic segment counts.
|
||
2. Combine **GSAP `power4.out` easing** with **Web Audio API procedural sound ticks** for maximum user delight.
|
||
3. Calculate winning segment indices mathematically beforehand to guarantee deterministic UI outcomes.
|
||
|
||
Test an interactive decision wheel live on [Entscheidomat Glücksrad Online](https://entscheidomat.com/gluecksrad).
|
||
|
||
---
|
||
|
||
## FAQ (Schema Structured Data)
|
||
|
||
```json
|
||
{
|
||
"@context": "https://schema.org",
|
||
"@type": "FAQPage",
|
||
"mainEntity": [
|
||
{
|
||
"@type": "Question",
|
||
"name": "Why use HTML5 Canvas instead of CSS for a Wheel of Fortune?",
|
||
"acceptedAnswer": {
|
||
"@type": "Answer",
|
||
"text": "HTML5 Canvas renders all segments and text in a single 60 FPS draw call, eliminating DOM bloat, layout shifts, and blurry text rendering on high-DPI displays."
|
||
}
|
||
},
|
||
{
|
||
"@type": "Question",
|
||
"name": "How do you trigger tick sound effects on a digital wheel?",
|
||
"acceptedAnswer": {
|
||
"@type": "Answer",
|
||
"text": "By monitoring the rotation angle during animation updates and triggering a short Web Audio API oscillator burst whenever the angle crosses a segment boundary angle."
|
||
}
|
||
}
|
||
]
|
||
}
|
||
```
|