09Hero Arena Combining Spin, Precession, and Collisions
Several tops spin at different rates inside a round arena with decay, precession, and collisions computed together, and any top whose spin drops below the threshold is restarted at full speed so the scene never stalls.
Best forLanding or blog hero background demo
HERO_PRECESSION_GAINHERO_RESTITUTIONheroStep

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<p class="bp__caption">Several tops start inside a round arena with different angular speeds, running decay, precession, and collisions on one screen at once, in a hero scene that picks the last top still standing.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, .85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bp { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__canvas:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: -6px;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(435009);
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners = [];
function makeTop(x, y, palIndex, R, omega) {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses = [];
function addPulse(x, y, rgb) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr, x, y, max) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr, rgb, w) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R, n, base, alpha, color) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t, rest, margin) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a, b, rest, spinMix, kickAmt) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 각속도 감쇠(01)와 세차(02)와 두 팽이 충돌(05)을 한 경기장에서 같이 돌린다.
// 각속도가 줄면 기울기가 커지고 세차가 빨라지며, 세 팽이는 서로 부딪혀 각속도를 맞바꾸다 바닥 마찰로 천천히 떠돈다.
var HERO_OMEGA_START = 36;
var HERO_DECAY = 0.22;
var HERO_PRECESSION_GAIN = 0.9;
var HERO_RESTITUTION = 0.9;
var HERO_FRICTION = 0.995;
var HERO_DRIFT = 50;
var heroTops = [
makeTop(70, 110, 0, 24, 36), makeTop(250, 120, 3, 24, -30), makeTop(160, 175, 1, 24, 26)
];
heroTops[0].vx = 50; heroTops[1].vx = -50; heroTops[2].vy = -40;
var heroTrails = [[], [], []];
function heroStep() {
var i, j, t;
for (i = 0; i < heroTops.length; i += 1) {
t = heroTops[i];
t.omega *= Math.exp(-HERO_DECAY * dtS);
t.spin += t.omega * dtS;
var slow = 1 - clamp(Math.abs(t.omega) / HERO_OMEGA_START, 0, 1);
t.kick = Math.max(0, t.kick - dtS * 0.9);
t.tilt = HERO_PRECESSION_GAIN * 0.3 * (1 + slow) + t.kick * 0.5;
t.az += 150 * Math.sin(t.tilt) / Math.max(Math.abs(t.omega), 8) * dtS;
var cxs = 160 - t.x, cys = 120 - t.y, d = Math.hypot(cxs, cys) || 1;
t.vx += (cxs / d * 14 + Math.cos(t.az) * HERO_DRIFT * 0.4) * dtS;
t.vy += (cys / d * 14 + Math.sin(t.az) * HERO_DRIFT * 0.3) * dtS;
t.vx *= HERO_FRICTION; t.vy *= HERO_FRICTION;
t.x += t.vx * dtS; t.y += t.vy * dtS;
wallBounce(t, 0.85, 6);
if (elapsed % 48 === 0) pushTrail(heroTrails[i], t.x, t.y + 3, 70);
if (Math.abs(t.omega) < 9) {
t.omega = (i === 1 ? -1 : 1) * HERO_OMEGA_START;
addPulse(t.x, t.y + 4, '255,241,201');
}
}
for (i = 0; i < heroTops.length; i += 1) {
for (j = i + 1; j < heroTops.length; j += 1) pairCollide(heroTops[i], heroTops[j], HERO_RESTITUTION, 0.3, 0.5);
}
}
function stepWorld() { heroStep(); stepPulses(); }
function drawWorld() {
drawArena();
var rgbs = ['255,179,199', '159,216,255', '168,230,207'];
for (var i = 0; i < heroTops.length; i += 1) drawTrail(heroTrails[i], rgbs[i], 7);
drawPulses();
var order = heroTops.slice().sort(function (a, b) { return a.y - b.y; });
for (var j = 0; j < order.length; j += 1) drawTop(order[j]);
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs = null;
var acc = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < 60; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
fit();
window.addEventListener('resize', fit);
cleaners.push(function () { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
})();import { useEffect, useRef } from "react";
import styles from "./SpinTopSceneHeroArena.module.scss";
type Pal = { a: string; b: string; mid: string; body: string };
type Top = { x: number; y: number; vx: number; vy: number; omega: number; tilt: number; az: number; spin: number; R: number; pal: Pal; kick: number; tipX: number; tipY: number };
type Pulse = { x: number; y: number; born: number; rgb: string };
type Pt = { x: number; y: number };
export default function SpinTopSceneHeroArena() {
const rootRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
const canvasEl = canvasRef.current;
if (!rootEl || !canvasEl) return;
const root: HTMLDivElement = rootEl;
const canvas: HTMLCanvasElement = canvasEl;
const ctx0 = canvas.getContext("2d");
if (!ctx0) return;
const ctx: CanvasRenderingContext2D = ctx0;
const wrap: HTMLElement = canvas.parentElement ?? root;
var CALC_W = 320, CALC_H = 200;
var FIXED_DT = 16;
var dtS = FIXED_DT / 1000;
var STRIPE_COUNT = 6;
var STRIPE_FUSE_HZ = 14;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var demo = !reduce;
if (!demo) root.classList.remove('is-demo');
function stopDemo() { demo = false; root.classList.remove('is-demo'); }
function seedRand(seed: number) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(435009);
function clamp(v: number, lo: number, hi: number) { return v < lo ? lo : (v > hi ? hi : v); }
var PALS = [
{ a: '#ffb3c7', b: '#fff1c9', mid: '#ffd6c8', body: '#f29bb7' },
{ a: '#a8e6cf', b: '#e6fbf1', mid: '#c9f1e0', body: '#7fcfae' },
{ a: '#c9b8ff', b: '#f1ecff', mid: '#ddd2ff', body: '#a590ef' },
{ a: '#9fd8ff', b: '#e6f5ff', mid: '#c4e6ff', body: '#6fb7ec' }
];
var cw = 480, ch = 300, sc = 1.5, elapsed = 0;
var rafId = 0;
var cleaners: Array<() => void> = [];
function makeTop(x: number, y: number, palIndex: number, R: number, omega: number): Top {
return { x: x, y: y, vx: 0, vy: 0, omega: omega, tilt: 0.08, az: rand() * 6.283, spin: rand() * 6.283,
R: R, pal: PALS[palIndex % PALS.length], kick: 0, tipX: x, tipY: y };
}
var pulses: Pulse[] = [];
function addPulse(x: number, y: number, rgb?: string) { pulses.push({ x: x, y: y, born: elapsed, rgb: rgb || '255,255,255' }); }
function stepPulses() {
var live: Pulse[] = [];
for (var i = 0; i < pulses.length; i += 1) if (elapsed - pulses[i].born < 520) live.push(pulses[i]);
pulses = live;
}
function drawPulses() {
for (var i = 0; i < pulses.length; i += 1) {
var p = pulses[i], f = (elapsed - p.born) / 520;
ctx.save();
ctx.translate(p.x, p.y);
ctx.scale(1, 0.38);
ctx.beginPath();
ctx.arc(0, 0, 12 + f * 62, 0, 6.2832);
ctx.strokeStyle = 'rgba(' + p.rgb + ',' + (0.8 * (1 - f)) + ')';
ctx.lineWidth = 3;
ctx.stroke();
ctx.restore();
}
}
function pushTrail(arr: Pt[], x: number, y: number, max: number) { arr.push({ x: x, y: y }); if (arr.length > max) arr.shift(); }
function drawTrail(arr: Pt[], rgb: string, w: number) {
ctx.lineCap = 'round';
for (var i = 1; i < arr.length; i += 1) {
var f = i / arr.length;
ctx.beginPath();
ctx.moveTo(arr[i - 1].x, arr[i - 1].y);
ctx.lineTo(arr[i].x, arr[i].y);
ctx.strokeStyle = 'rgba(' + rgb + ',' + (0.62 * f) + ')';
ctx.lineWidth = w * (0.35 + 0.65 * f);
ctx.stroke();
}
}
var glowX = 160, glowY = 150, glowS = 1;
function drawArena() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1d1a2b';
ctx.fillRect(0, 0, cw, ch);
ctx.setTransform(sc, 0, 0, sc, 0, 0);
var g = ctx.createRadialGradient(160, 118, 10, 160, 118, 200);
g.addColorStop(0, '#463e68');
g.addColorStop(1, '#2a2442');
ctx.fillStyle = g;
ctx.fillRect(0, 0, CALC_W, CALC_H);
ctx.strokeStyle = 'rgba(255,255,255,0.09)';
ctx.lineWidth = 1;
for (var k = 1; k <= 4; k += 1) {
ctx.beginPath();
ctx.ellipse(160, 120, 44 * k, 21 * k, 0, 0, 6.2832);
ctx.stroke();
}
// 원근 격자: 팽이 위치(glowX)에 가까울수록 밝게 비친다
var gl = ctx.createRadialGradient(glowX, glowY, 6, glowX, glowY, 140);
gl.addColorStop(0, 'rgba(255,255,255,' + (0.62 * glowS) + ')');
gl.addColorStop(1, 'rgba(255,255,255,0.07)');
ctx.strokeStyle = gl;
ctx.lineWidth = 1;
for (var gr = 0; gr < 10; gr += 1) {
var gf = gr / 9;
var gy = 72 + 128 * Math.pow(gf, 1.7);
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(CALC_W, gy);
ctx.stroke();
}
for (var gc = -9; gc <= 9; gc += 1) {
ctx.beginPath();
ctx.moveTo(160 + gc * 12, 72);
ctx.lineTo(160 + gc * 52, CALC_H);
ctx.stroke();
}
var pool = ctx.createRadialGradient(glowX, glowY + 4, 0, glowX, glowY + 4, 120);
pool.addColorStop(0, 'rgba(255,238,252,' + (0.3 * glowS) + ')');
pool.addColorStop(1, 'rgba(255,238,252,0)');
ctx.fillStyle = pool;
ctx.fillRect(0, 0, CALC_W, CALC_H);
}
function wedges(R: number, n: number, base: number, alpha: number, color: string) {
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
for (var i = 0; i < n; i += 1) {
var a0 = base + i * 6.2832 / n;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.arc(0, 0, R, a0, a0 + 3.1416 / n);
ctx.closePath();
ctx.fill();
}
ctx.globalAlpha = 1;
}
function drawTop(t: Top) {
var R = t.R, L = R * 1.25;
// 바닥에 남는 회전 잔상 원판과 도는 점선 고리
var gw = Math.abs(t.omega);
ctx.save();
ctx.translate(t.x, t.y + 3);
ctx.scale(1, 0.3);
wedges(R * 1.7, STRIPE_COUNT, t.spin * 0.8, 0.2 + 0.14 * clamp(gw / 30, 0, 1), t.pal.a);
ctx.beginPath();
ctx.arc(0, 0, R * 1.95, 0, 6.2832);
ctx.setLineDash([R * 0.3, R * 0.2]);
ctx.lineDashOffset = -t.spin * R * 0.6;
ctx.strokeStyle = t.pal.a;
ctx.globalAlpha = 0.6;
ctx.lineWidth = 8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
glowX = t.x + (t.tipX - t.x) * 0.5;
glowY = t.y;
glowS = 0.55 + 0.45 * clamp(gw / 30, 0, 1);
var s = Math.sin(t.tilt), c = Math.cos(t.tilt);
var cx = t.x + Math.cos(t.az) * s * L * 1.15;
var cy = t.y - c * L * 0.95 + Math.sin(t.az) * s * L * 0.35;
ctx.save();
ctx.translate(t.x + R * 0.28, t.y + 1);
ctx.scale(1, 0.26);
ctx.beginPath();
ctx.arc(0, 0, R * (0.8 + 0.6 * s), 0, 6.2832);
ctx.fillStyle = 'rgba(10,8,20,' + (0.4 - 0.12 * s) + ')';
ctx.fill();
ctx.restore();
var dx = cx - t.x, dy = cy - t.y;
var dist = Math.hypot(dx, dy) || 1;
var phi = Math.atan2(dx, -dy);
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(phi);
ctx.beginPath();
ctx.moveTo(-R, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = t.pal.body;
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, dist); ctx.lineTo(R, 0); ctx.closePath();
ctx.fillStyle = 'rgba(30,16,60,0.24)';
ctx.fill();
var kv = 0.34 + 0.3 * s;
ctx.save();
ctx.scale(1, kv);
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.b;
ctx.fill();
var sgn = t.omega < 0 ? -1 : 1;
var w = Math.abs(t.omega);
wedges(R, STRIPE_COUNT, t.spin, 1, t.pal.a);
if (w > 8) {
for (var k = 1; k <= 3; k += 1) wedges(R, STRIPE_COUNT, t.spin - sgn * k * w * 0.012, 0.2 / k, t.pal.a);
}
var f = w * STRIPE_COUNT / 6.2832;
var blur = clamp((f - STRIPE_FUSE_HZ) / 26, 0, 0.8);
if (blur > 0) {
ctx.globalAlpha = blur;
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.fillStyle = t.pal.mid;
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(0, 0, R, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
ctx.lineWidth = 1 / kv;
ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -R * 0.62);
ctx.strokeStyle = t.pal.body;
ctx.lineWidth = R * 0.16;
ctx.lineCap = 'round';
ctx.stroke();
ctx.beginPath();
ctx.arc(0, -R * 0.66, R * 0.12, 0, 6.2832);
ctx.fillStyle = '#ffffff';
ctx.fill();
ctx.restore();
t.tipX = cx + Math.sin(phi) * R * 0.66;
t.tipY = cy - Math.cos(phi) * R * 0.66;
}
function wallBounce(t: Top, rest: number, margin: number) {
var m = t.R + margin;
if (t.x < m) { t.x = m; t.vx = Math.abs(t.vx) * rest; }
if (t.x > CALC_W - m) { t.x = CALC_W - m; t.vx = -Math.abs(t.vx) * rest; }
var top = t.R * 2.1, bottom = CALC_H - 8;
if (t.y < top) { t.y = top; t.vy = Math.abs(t.vy) * rest; }
if (t.y > bottom) { t.y = bottom; t.vy = -Math.abs(t.vy) * rest; }
}
function pairCollide(a: Top, b: Top, rest: number, spinMix: number, kickAmt: number) {
var dx = b.x - a.x, dy = (b.y - a.y) * 1.6;
var dist = Math.hypot(dx, dy);
var minDist = (a.R + b.R) * 0.92;
if (dist <= 0 || dist >= minDist) return false;
var nx = dx / dist, ny = dy / dist;
var overlap = (minDist - dist) * 0.5;
a.x -= nx * overlap; a.y -= ny * overlap / 1.6;
b.x += nx * overlap; b.y += ny * overlap / 1.6;
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
if (vn >= 0) return false;
var j = -(1 + rest) * vn / 2;
a.vx -= j * nx; a.vy -= j * ny;
b.vx += j * nx; b.vy += j * ny;
var tr = (b.omega - a.omega) * spinMix;
a.omega += tr; b.omega -= tr;
a.vx += -ny * tr * 1.4; a.vy += nx * tr * 1.4;
b.vx -= -ny * tr * 1.4; b.vy -= nx * tr * 1.4;
a.kick = kickAmt; b.kick = kickAmt;
addPulse((a.x + b.x) / 2, (a.y + b.y) / 2 + 6, '255,255,255');
return true;
}
// 기법: 각속도 감쇠(01)와 세차(02)와 두 팽이 충돌(05)을 한 경기장에서 같이 돌린다.
// 각속도가 줄면 기울기가 커지고 세차가 빨라지며, 세 팽이는 서로 부딪혀 각속도를 맞바꾸다 바닥 마찰로 천천히 떠돈다.
var HERO_OMEGA_START = 36;
var HERO_DECAY = 0.22;
var HERO_PRECESSION_GAIN = 0.9;
var HERO_RESTITUTION = 0.9;
var HERO_FRICTION = 0.995;
var HERO_DRIFT = 50;
var heroTops = [
makeTop(70, 110, 0, 24, 36), makeTop(250, 120, 3, 24, -30), makeTop(160, 175, 1, 24, 26)
];
heroTops[0].vx = 50; heroTops[1].vx = -50; heroTops[2].vy = -40;
var heroTrails = [[], [], []];
function heroStep() {
var i, j, t;
for (i = 0; i < heroTops.length; i += 1) {
t = heroTops[i];
t.omega *= Math.exp(-HERO_DECAY * dtS);
t.spin += t.omega * dtS;
var slow = 1 - clamp(Math.abs(t.omega) / HERO_OMEGA_START, 0, 1);
t.kick = Math.max(0, t.kick - dtS * 0.9);
t.tilt = HERO_PRECESSION_GAIN * 0.3 * (1 + slow) + t.kick * 0.5;
t.az += 150 * Math.sin(t.tilt) / Math.max(Math.abs(t.omega), 8) * dtS;
var cxs = 160 - t.x, cys = 120 - t.y, d = Math.hypot(cxs, cys) || 1;
t.vx += (cxs / d * 14 + Math.cos(t.az) * HERO_DRIFT * 0.4) * dtS;
t.vy += (cys / d * 14 + Math.sin(t.az) * HERO_DRIFT * 0.3) * dtS;
t.vx *= HERO_FRICTION; t.vy *= HERO_FRICTION;
t.x += t.vx * dtS; t.y += t.vy * dtS;
wallBounce(t, 0.85, 6);
if (elapsed % 48 === 0) pushTrail(heroTrails[i], t.x, t.y + 3, 70);
if (Math.abs(t.omega) < 9) {
t.omega = (i === 1 ? -1 : 1) * HERO_OMEGA_START;
addPulse(t.x, t.y + 4, '255,241,201');
}
}
for (i = 0; i < heroTops.length; i += 1) {
for (j = i + 1; j < heroTops.length; j += 1) pairCollide(heroTops[i], heroTops[j], HERO_RESTITUTION, 0.3, 0.5);
}
}
function stepWorld() { heroStep(); stepPulses(); }
function drawWorld() {
drawArena();
var rgbs = ['255,179,199', '159,216,255', '168,230,207'];
for (var i = 0; i < heroTops.length; i += 1) drawTrail(heroTrails[i], rgbs[i], 7);
drawPulses();
var order = heroTops.slice().sort(function (a, b) { return a.y - b.y; });
for (var j = 0; j < order.length; j += 1) drawTop(order[j]);
}
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sc = cw / CALC_W;
drawWorld();
}
var lastTs: number | null = null;
var acc = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepWorld(); acc -= FIXED_DT; }
drawWorld();
rafId = requestAnimationFrame(tick);
}
for (var w0 = 0; w0 < 60; w0 += 1) { elapsed += FIXED_DT; stepWorld(); }
fit();
window.addEventListener('resize', fit);
cleaners.push(function () { window.removeEventListener('resize', fit); });
if (!reduce) rafId = requestAnimationFrame(tick);
function destroy() {
cancelAnimationFrame(rafId);
for (var i = 0; i < cleaners.length; i += 1) cleaners[i]();
}
return destroy;
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.bp__caption}>원형 경기장 안에서 여러 팽이를 서로 다른 각속도로 시작시켜 감쇠·세차·충돌을 한 화면에서 동시에 돌리고, 마지막까지 서 있는 팽이를 가리는 히어로 장면을 만든다.</p>
</div>
</main>
);
}09 Hero Arena Combining Spin, Precession, and Collisions — Measured area 28.945% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Spinning Top Animation: 9 Canvas Physics Scenes
The post this demo lives in
Spinning Top Animation: 9 Canvas Physics ScenesSpin, tilt, stagger, fall — a top's whole life fits in four verbs, and scene 03 turns the second one into a number you c See all demos in this post
If you like this effect - similar demos
game09 The hero scene — a finished wheel with a confetti burst and a saveable resultSpin Wheel: 9 Canvas Roulettes, Flick to Stop
game09 Hero Scene Combining Every Ball-Physics Technique9 Bouncing Ball Animation Patterns With Physics
game09 A Hedge-Garden Maze Seen From Above, With Fog From Center Distance and Shadows From Height and Sun AngleMaze Generator: 9 Carve-and-Solve Scenes