05Two Tops Colliding and Bouncing Apart
When the perspective-corrected distance between two tops drops below their combined radius, they bounce apart and trade part of their spin difference, so the slower top speeds back up.
Best forHead-to-head scene that decides a winner
COLLISION_RESTITUTIONresolveTopPairexchangeSpin

<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">On the frame where the distance between two centers drops below the sum of the radii, the collision direction is computed and their velocities are swapped, with the hit side losing angular speed so one stops sooner.</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(435005);
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;
}
// 기법: 두 팽이를 원으로 보고 겹치면 법선 방향 충격량으로 튕겨 내고(COLLISION_RESTITUTION),
// 각속도 차이의 일부를 서로 맞바꿔 각운동량을 주고받는다. 한쪽이 얻은 만큼 다른 쪽이 잃고, 접선 방향으로도 밀린다.
var COLLISION_RESTITUTION = 0.9;
var SPIN_MIX = 0.3;
var ATTRACT = 34;
var topA = makeTop(80, 120, 0, 30, 36);
var topB = makeTop(240, 150, 3, 30, -24);
topA.vx = 70; topA.vy = 8;
topB.vx = -70; topB.vy = -6;
var tops = [topA, topB];
function resolveTopPair(a, b) {
return pairCollide(a, b, COLLISION_RESTITUTION, SPIN_MIX, 0.5);
}
function exchangeSpin(t) {
var floorKeep = Math.exp(-0.06 * dtS);
t.omega *= floorKeep;
if (Math.abs(t.omega) < 14) t.omega += (t.omega < 0 ? -1 : 1) * 10 * dtS;
}
function stepWorld() {
var dx = topB.x - topA.x, dy = topB.y - topA.y;
var d = Math.hypot(dx, dy) || 1;
topA.vx += dx / d * ATTRACT * dtS; topA.vy += dy / d * ATTRACT * dtS;
topB.vx -= dx / d * ATTRACT * dtS; topB.vy -= dy / d * ATTRACT * dtS;
for (var i = 0; i < tops.length; i += 1) {
var t = tops[i];
exchangeSpin(t);
t.spin += t.omega * dtS;
t.vx *= 0.999; t.vy *= 0.999;
t.x += t.vx * dtS; t.y += t.vy * dtS;
wallBounce(t, 0.9, 6);
t.kick = Math.max(0, t.kick - dtS * 0.9);
t.az += (3 + t.kick * 10) * dtS;
t.tilt = 0.08 + t.kick * 0.7;
}
resolveTopPair(topA, topB);
stepPulses();
}
function drawWorld() {
drawArena();
drawPulses();
var order = topA.y < topB.y ? [topA, topB] : [topB, topA];
drawTop(order[0]);
drawTop(order[1]);
}
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 < 30; 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 "./SpinTopSceneTwoTopCollision.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 SpinTopSceneTwoTopCollision() {
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(435005);
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;
}
// 기법: 두 팽이를 원으로 보고 겹치면 법선 방향 충격량으로 튕겨 내고(COLLISION_RESTITUTION),
// 각속도 차이의 일부를 서로 맞바꿔 각운동량을 주고받는다. 한쪽이 얻은 만큼 다른 쪽이 잃고, 접선 방향으로도 밀린다.
var COLLISION_RESTITUTION = 0.9;
var SPIN_MIX = 0.3;
var ATTRACT = 34;
var topA = makeTop(80, 120, 0, 30, 36);
var topB = makeTop(240, 150, 3, 30, -24);
topA.vx = 70; topA.vy = 8;
topB.vx = -70; topB.vy = -6;
var tops = [topA, topB];
function resolveTopPair(a: Top, b: Top) {
return pairCollide(a, b, COLLISION_RESTITUTION, SPIN_MIX, 0.5);
}
function exchangeSpin(t: Top) {
var floorKeep = Math.exp(-0.06 * dtS);
t.omega *= floorKeep;
if (Math.abs(t.omega) < 14) t.omega += (t.omega < 0 ? -1 : 1) * 10 * dtS;
}
function stepWorld() {
var dx = topB.x - topA.x, dy = topB.y - topA.y;
var d = Math.hypot(dx, dy) || 1;
topA.vx += dx / d * ATTRACT * dtS; topA.vy += dy / d * ATTRACT * dtS;
topB.vx -= dx / d * ATTRACT * dtS; topB.vy -= dy / d * ATTRACT * dtS;
for (var i = 0; i < tops.length; i += 1) {
var t = tops[i];
exchangeSpin(t);
t.spin += t.omega * dtS;
t.vx *= 0.999; t.vy *= 0.999;
t.x += t.vx * dtS; t.y += t.vy * dtS;
wallBounce(t, 0.9, 6);
t.kick = Math.max(0, t.kick - dtS * 0.9);
t.az += (3 + t.kick * 10) * dtS;
t.tilt = 0.08 + t.kick * 0.7;
}
resolveTopPair(topA, topB);
stepPulses();
}
function drawWorld() {
drawArena();
drawPulses();
var order = topA.y < topB.y ? [topA, topB] : [topB, topA];
drawTop(order[0]);
drawTop(order[1]);
}
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 < 30; 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>
);
}05 Two Tops Colliding and Bouncing Apart — Measured area 40.629% · 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
game06 A Snake That Exits the Far Side of a Wall and Is Drawn in Two Halves at the SeamCSS Snake Game: 9 Boards That Find Their Own Path
game05 A Win Scene Where a Line Sweeps Through the Centers of Three Matching Cells and Its Glow Swells and FadesCSS Tic Tac Toe: 9 Boards That Count Ahead