01마찰로 각속도가 줄어드는 기본 팽이
팽이 몸통의 회전 각도를 프레임마다 각속도만큼 더하고 각속도에 감쇠 비율을 곱해, 처음엔 줄무늬가 빠르게 돌다 점점 느려져 멈추는 회전을 그린다.
쓰임회전 장면의 첫 화면
ANGULAR_DECAYdecayAngularSpeeddrawOmegaGauge

<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">팽이 몸통의 회전 각도를 프레임마다 각속도만큼 더하고 각속도에 감쇠 비율을 곱해, 처음엔 줄무늬가 빠르게 돌다 점점 느려져 멈추는 회전을 그린다.</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(435001);
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;
}
// 기법: 프레임마다 회전각에 각속도를 더하고, 각속도에는 지수 감쇠 비율을 곱한다.
// 각속도가 낮아질수록 기울기 진폭과 세차 속도를 키워 곧게 서 있던 팽이가 서서히 비틀거리게 한다.
var ANGULAR_DECAY = 0.55;
var OMEGA_START = 38;
var spinTop = makeTop(160, 158, 0, 58, OMEGA_START);
function decayAngularSpeed(t) { t.omega *= Math.exp(-ANGULAR_DECAY * dtS); }
function stepWorld() {
decayAngularSpeed(spinTop);
spinTop.spin += spinTop.omega * dtS;
var slow = 1 - clamp(spinTop.omega / OMEGA_START, 0, 1);
spinTop.tilt = 0.06 + 0.5 * slow * slow;
spinTop.az += (2 + slow * 5) * dtS;
if (spinTop.omega < 4) {
spinTop.omega = OMEGA_START;
addPulse(spinTop.x, spinTop.y + 4, '255,214,230');
}
stepPulses();
}
function drawOmegaGauge(t) {
var frac = clamp(t.omega / OMEGA_START, 0, 1);
ctx.save();
ctx.translate(t.x, t.y + 4);
ctx.scale(1, 0.34);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(0, 0, 84, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 12;
ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, 84, -1.5708, -1.5708 + 6.2832 * frac);
ctx.strokeStyle = 'rgba(255,179,199,0.9)';
ctx.lineWidth = 12;
ctx.stroke();
ctx.restore();
}
function drawWorld() {
drawArena();
drawOmegaGauge(spinTop);
drawPulses();
drawTop(spinTop);
}
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 "./SpinTopSceneAngularDecay.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 SpinTopSceneAngularDecay() {
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(435001);
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;
}
// 기법: 프레임마다 회전각에 각속도를 더하고, 각속도에는 지수 감쇠 비율을 곱한다.
// 각속도가 낮아질수록 기울기 진폭과 세차 속도를 키워 곧게 서 있던 팽이가 서서히 비틀거리게 한다.
var ANGULAR_DECAY = 0.55;
var OMEGA_START = 38;
var spinTop = makeTop(160, 158, 0, 58, OMEGA_START);
function decayAngularSpeed(t: Top) { t.omega *= Math.exp(-ANGULAR_DECAY * dtS); }
function stepWorld() {
decayAngularSpeed(spinTop);
spinTop.spin += spinTop.omega * dtS;
var slow = 1 - clamp(spinTop.omega / OMEGA_START, 0, 1);
spinTop.tilt = 0.06 + 0.5 * slow * slow;
spinTop.az += (2 + slow * 5) * dtS;
if (spinTop.omega < 4) {
spinTop.omega = OMEGA_START;
addPulse(spinTop.x, spinTop.y + 4, '255,214,230');
}
stepPulses();
}
function drawOmegaGauge(t: Top) {
var frac = clamp(t.omega / OMEGA_START, 0, 1);
ctx.save();
ctx.translate(t.x, t.y + 4);
ctx.scale(1, 0.34);
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(0, 0, 84, 0, 6.2832);
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.lineWidth = 12;
ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, 84, -1.5708, -1.5708 + 6.2832 * frac);
ctx.strokeStyle = 'rgba(255,179,199,0.9)';
ctx.lineWidth = 12;
ctx.stroke();
ctx.restore();
}
function drawWorld() {
drawArena();
drawOmegaGauge(spinTop);
drawPulses();
drawTop(spinTop);
}
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>
);
}01 마찰로 각속도가 줄어드는 기본 팽이 — 측정 누적면적 22.972% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 팽이 돌리기 9가지 — 각속도와 세차로 계산하는 회전 장면
이 데모가 속한 글
팽이 돌리기 9가지 — 각속도와 세차로 계산하는 회전 장면돌리고, 기울고, 비틀거리고, 눕는다 — 팽이의 일생은 네 동사로 끝나고, 03번은 그 두 번째 동사를 숫자로 계산한다. 아래 아홉 개는 팽이 돌리기를 캔버스 2d 위에서 프레임마다 다시 계산해 그린 장면이며, 파일 이 글의 데모 전부 보기


