04구르며 부딪히는 도형
질량이 다른 원·사각형 여러 개가 충돌 법선 방향으로 반발계수만큼 속도를 교환하고, 충돌 지점이 중심에서 벗어난 만큼 회전 토크가 더해져 구르며 튕겨 나갑니다.
쓰임당구공처럼 부딪히는 로딩 연출, 404 페이지의 굴러다니는 도형
requestAnimationFrametranslaterotate

<main class="stage">
<div class="rb" id="rb">
<p class="rb__hint">
<svg class="rb__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224,128a96,96,0,0,1-96,96c-11.4,0-20-8.6-20-20s8.9-20,20-20a56,56,0,0,0,0-112c-11.1,0-20-8.9-20-20s8.6-20,20-20A96,96,0,0,1,224,128Z" opacity=".2"/><path d="M128,24A104,104,0,0,0,128,232c-15.8,0-28-12.3-28-28s12.4-28,28-28a48,48,0,0,0,0-96c-15.6,0-28-12.4-28-28S112.2,24,128,24Zm0,16c-6.9,0-12,5.2-12,12s5.3,12,12,12a64,64,0,0,1,0,128c-6.7,0-12,5.4-12,12s5.1,12,12,12a88,88,0,0,0,0-176Z"/></g></svg>
<span>도형을 잡아 던지면 충돌 법선으로 속도를 주고받고 접점이 치우친 만큼 굴러갑니다</span>
</p>
<div class="rb__field" id="field" role="application" aria-label="강체 충돌 실험판" tabindex="0">
<canvas class="rb__cv" id="cv"></canvas>
<b class="rb__badge" aria-hidden="true"><span class="rb__badgeNum" id="hitVal">0</span><span class="rb__badgeUnit">충돌 횟수</span></b>
</div>
<div class="rb__bar">
<label class="rb__dial">반발
<input type="range" id="e" min="30" max="85" step="5" value="60" aria-label="반발계수(%)">
</label>
<button class="rb__reset" id="reset" type="button">처음으로</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 구르며 부딪히는 도형 — 움직임은 전부 캔버스 rAF 루프 안의 충격량·토크 계산에 있다.
// @keyframes 를 하나도 두지 않는다 — CSS 애니메이션이 0개여야 렌더러가 실시간으로 24장을 찍어
// 도형이 실제로 구르고 튕기는 모습이 담긴다(도구/demo.py 렌더(), 01·077 선례 "애니메이션": 0).
// 캔버스가 쓰는 색은 여기서 커스텀 프로퍼티로 내려보낸다 — 값은 토큰 한 곳에만 둔다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
.rb {
--circ: #{$subject-sky};
--boxf: #{$subject-lilac};
--ring: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.rb__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream;
}
.rb__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.rb__field {
position: relative; height: 160px;
border-radius: $r-card; overflow: hidden;
background: #211d26;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: grab; touch-action: none;
}
.rb__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.rb__field:active { cursor: grabbing; }
.rb__cv { display: block; width: 100%; height: 100%; }
// 충돌 횟수 — 시뮬에서 갱신된다
.rb__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-cream, .88);
color: $subject-ink; font-weight: 700; font-size: 11px;
}
.rb__badgeNum { font-size: 13px; color: $subject-blue-deep; }
.rb__badgeUnit { font-weight: 700; }
.rb__bar {
display: flex; align-items: center; gap: $sp-3;
}
.rb__dial {
display: flex; align-items: center; gap: $sp-2; flex: 1 1 auto;
font-weight: 700; font-size: 12px; color: $subject-cream;
}
.rb__dial input[type="range"] { flex: 1 1 auto; accent-color: $color; }
.rb__reset {
flex: 0 0 auto; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; font-weight: 700; font-size: 12px;
background: $color; color: $subject-ink; cursor: pointer;
}
.rb__reset:active { transform: translateY(1px); }
@media (max-width: 340px) {
.rb { gap: $sp-1; }
.rb__field { height: 114px; }
.rb__badge { font-size: 10px; padding: 0 $sp-1; }
.rb__badgeNum { font-size: 11px; }
.rb__bar { gap: $sp-2; }
.rb__dial { font-size: 11px; }
.rb__reset { font-size: 11px; padding: $sp-1 $sp-2; }
}
// reduce — 자동 시연 루프는 JS 가 끈다(가상 던지기가 멈춘다).
// 집어 던지는 조작은 살아 있고 정지 장면만 그린다.
@media (prefers-reduced-motion: reduce) {
.rb.is-still .rb__field { cursor: default !important; }
.rb.is-still .rb__badge { opacity: .92 !important; }
}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);
}
.rb {
--circ: #6ec8ff;
--boxf: #b9a0ff;
--ring: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.rb__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
}
.rb__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #b9a0ff;
}
.rb__field {
position: relative;
height: 160px;
border-radius: 16px;
overflow: hidden;
background: #211d26;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: grab;
touch-action: none;
}
.rb__field:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.rb__field:active {
cursor: grabbing;
}
.rb__cv {
display: block;
width: 100%;
height: 100%;
}
.rb__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.88);
color: #17141a;
font-weight: 700;
font-size: 11px;
}
.rb__badgeNum {
font-size: 13px;
color: #0a4fe8;
}
.rb__badgeUnit {
font-weight: 700;
}
.rb__bar {
display: flex;
align-items: center;
gap: 12px;
}
.rb__dial {
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 auto;
font-weight: 700;
font-size: 12px;
color: #fff7e6;
}
.rb__dial input[type=range] {
flex: 1 1 auto;
accent-color: #b9a0ff;
}
.rb__reset {
flex: 0 0 auto;
border: 0;
border-radius: 999px;
padding: 4px 12px;
font-weight: 700;
font-size: 12px;
background: #b9a0ff;
color: #17141a;
cursor: pointer;
}
.rb__reset:active {
transform: translateY(1px);
}
@media (max-width: 340px) {
.rb {
gap: 4px;
}
.rb__field {
height: 114px;
}
.rb__badge {
font-size: 10px;
padding: 0 4px;
}
.rb__badgeNum {
font-size: 11px;
}
.rb__bar {
gap: 8px;
}
.rb__dial {
font-size: 11px;
}
.rb__reset {
font-size: 11px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rb.is-still .rb__field {
cursor: default !important;
}
.rb.is-still .rb__badge {
opacity: 0.92 !important;
}
}// 구르며 부딪히는 도형 — 강체 충돌의 핵심 두 가지를 베어낸 미니 물리엔진.
// ① 충격량: 충돌 법선 방향 상대속도에 반발계수 e 를 곱해 운동량 보존(j = -(1+e)·vRel·n / (1/m1 + 1/m2))
// 으로 두 몸체에 나눠 준다. Verlet 에서는 "이전 위치를 충격량만큼 되돌리는 것"이 곧 속도 변화다.
// ② 토크: 충돌 접점이 중심에서 벗어난 팔(r × n)만큼 각속도를 얻는다 — 구르며 튕기는 이유.
// OBB(회전하는 사각형)와 원의 충돌은 원심을 사각 로컬 좌표로 옮겨 최근접점을 찍는 클램프 근사로 푼다.
var root = document.getElementById('rb');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var eIn = document.getElementById('e');
var hitVal = document.getElementById('hitVal');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var CIRC = css.getPropertyValue('--circ').trim();
var BOXF = css.getPropertyValue('--boxf').trim();
var RING = css.getPropertyValue('--ring').trim();
var W = 0, H = 0, dpr = 1;
var t0 = performance.now();
var GRAV = 1400; // 중력(px/s^2)
var DAMP = 0.9985; // 속도 보존률(공기 저항 아주 작게 — 굴러다녀야 한다)
var WALL_E = 0.72; // 벽·바닥 반발(도형끼리 반발과 별개)
var FRICTION = 0.985; // 바닥 접점 접선 감쇠(미끄러짐)
var LOOP = 2; // 자동 시연 한 바퀴(초)
// 몸체: {kind:'c'|'b', x, y, px, py, r(반지름 또는 절반변), a(회전각), pa(이전 각), m(질량), im(역질량), col}
var bodies = [];
var hits = 0;
var hold = -1; // 잡고 있는 몸체 번호
var holdX = 0, holdY = 0, holdPX = 0, holdPY = 0; // 잡은 위치와 직전 위치(던지기 속도용)
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
build();
}
function build() {
bodies = []; hits = 0; hitVal.textContent = '0'; hold = -1;
var colIn = H * 0.34, gap = W / 6;
// 원 3 + 사각 2 + 원 1 — 질량은 반지름 세제곱 비례(면적 밀도 같은 셈)
var spec = [
{ k: 'c', r: 17, fx: 0.5, fy: 0.20, c: CIRC },
{ k: 'c', r: 13, fx: 0.30, fy: 0.46, c: CIRC },
{ k: 'c', r: 21, fx: 0.72, fy: 0.40, c: CIRC },
{ k: 'b', r: 15, fx: 0.42, fy: 0.62, c: BOXF, sq: 0.78 },
{ k: 'b', r: 18, fx: 0.62, fy: 0.30, c: BOXF, sq: 1.25 },
{ k: 'c', r: 11, fx: 0.86, fy: 0.66, c: CIRC }
];
for (var i = 0; i < spec.length; i++) {
var s = spec[i];
var m = s.k === 'c' ? s.r * s.r * s.r : s.r * s.r * s.r * (s.sq || 1) * 0.9;
var x = W * s.fx, y = H - colIn * (1 - s.fy) * 0.9;
bodies.push({
kind: s.k, x: x, y: y, px: x, py: y,
r: s.r, hw: s.r, hh: s.r * (s.sq || 1),
a: s.k === 'b' ? (i * 0.9 - 1.2) : 0, pa: s.k === 'b' ? (i * 0.9 - 1.2) : 0,
m: m, im: 1 / m, col: s.c
});
}
}
// 몸체 반지름(충돌 판정용 대략 크기)
function rad(b) { return b.kind === 'c' ? b.r : Math.max(b.hw, b.hh); }
// 원-원 충돌 → {nx, ny, pen, cx, cy}
function hitCC(a, b) {
var dx = b.x - a.x, dy = b.y - a.y;
var d = Math.hypot(dx, dy);
var rr = a.r + b.r;
if (d >= rr || d === 0) return null;
return { nx: dx / d, ny: dy / d, pen: rr - d, cx: a.x + dx / d * a.r, cy: a.y + dy / d * a.r };
}
// 점이 회전사각(OBB) 안/가장자리에서 사각 중심 기준 로컬 좌표로 변환
function toLocal(b, px2, py2) {
var c = Math.cos(b.a), s = Math.sin(b.a); // 월드→로컬은 역회전
var dx = px2 - b.x, dy = py2 - b.y;
return { x: dx * c + dy * s, y: -dx * s + dy * c };
}
// 원-OBB 충돌 — 원심을 로컬로 옮겨 클램프한 최근접점으로 판정
function hitCB(c, b) {
var l = toLocal(b, c.x, c.y);
var qx = Math.max(-b.hw, Math.min(b.hw, l.x));
var qy = Math.max(-b.hh, Math.min(b.hh, l.y));
var dx = l.x - qx, dy = l.y - qy;
var d = Math.hypot(dx, dy);
if (d >= c.r) return null;
var nx, ny, pen;
if (d > 0.0001) { nx = dx / d; ny = dy / d; pen = c.r - d; }
else {
// 원심이 사각 안 — 가장 얕은 축으로 밀어낸다
var ox = b.hw - Math.abs(l.x), oy = b.hh - Math.abs(l.y);
if (ox < oy) { nx = l.x >= 0 ? 1 : -1; ny = 0; pen = ox + c.r; }
else { nx = 0; ny = l.y >= 0 ? 1 : -1; pen = oy + c.r; }
}
var co = Math.cos(b.a), si = Math.sin(b.a); // 로컬 법선 → 월드로 회전
var wnx = nx * co - ny * si, wny = nx * si + ny * co;
return { nx: wnx, ny: wny, pen: pen, cx: qx, cy: qy };
}
function collide(a, b) {
if (a.kind === 'c' && b.kind === 'c') return hitCC(a, b);
if (a.kind === 'c' && b.kind === 'b') { var h = hitCB(a, b); if (h) { h.nx = -h.nx; h.ny = -h.ny; } return h; }
if (a.kind === 'b' && b.kind === 'c') return hitCB(b, a);
// 사각-사각은 원 근사(반지름 = 평균 반변)로만 밀어낸다 — 6개 몸체 미니엔진의 절충
var ra = (a.hw + a.hh) * 0.5, rb2 = (b.hw + b.hh) * 0.5;
var dx = b.x - a.x, dy = b.y - a.y;
var d = Math.hypot(dx, dy);
if (d >= (ra + rb2) * 0.9 || d === 0) return null;
return { nx: dx / d, ny: dy / d, pen: (ra + rb2) * 0.9 - d, cx: 0, cy: 0 };
}
// 충격량 적용 — Verlet 은 이전 위치를 되돌리는 것으로 속도를 바꾼다
function impulse(a, b, n, cx2, cy2, e) {
var avx = a.x - a.px, avy = a.y - a.py;
var bvx = b.x - b.px, bvy = b.y - b.py;
var vRel = (avx - bvx) * n.x + (avy - bvy) * n.y;
if (vRel > 0) return false; // 이미 떨어지는 중
var j = -(1 + e) * vRel / (a.im + b.im);
var jx = n.x * j, jy = n.y * j;
a.px -= jx * a.im; a.py -= jy * a.im; // 정방향 충격량 = 속도 증가
b.px += jx * b.im; b.py += jy * b.im;
// 토크 — 접점 팔이 중심에서 벗어났으면 각속도로 돌린다(사각만 받는다)
if (a.kind === 'b') {
var la = toLocal(a, cx2, cy2);
a.pa -= (la.x * n.y - la.y * n.x) * j * a.im * 0.00013;
}
if (b.kind === 'b') {
var lb = toLocal(b, cx2, cy2);
b.pa += (lb.x * n.y - lb.y * n.x) * j * b.im * 0.00013;
}
return true;
}
function step(now) {
var dt = Math.min((now - t0) / 1000, 0.033);
t0 = now;
var e = Number(eIn.value) / 100;
var i, j2, bd;
// (1) 관성 + 중력 + 회전 감쇠
for (i = 0; i < bodies.length; i++) {
bd = bodies[i];
if (i === hold) continue;
var vx = bd.x - bd.px;
var vy = bd.y - bd.py + GRAV * dt * dt;
bd.px = bd.x; bd.py = bd.y;
bd.x += vx * DAMP;
bd.y += vy * DAMP;
var na = bd.a + (bd.a - bd.pa) * 0.995;
bd.pa = bd.a; bd.a = na;
}
if (hold >= 0) {
var hb = bodies[hold];
hb.px = holdPX; hb.py = holdPY; // 직전 손 위치 → 이전 위치. 놓으면 이 차이가 던지기 속도
hb.x = holdX; hb.y = holdY;
hb.pa += (hb.a - hb.pa) * 0.8;
}
// (2) 짝 충돌 — 위치 보정(침투 밀어내기) + 충격량
for (i = 0; i < bodies.length; i++) {
for (j2 = i + 1; j2 < bodies.length; j2++) {
var A = bodies[i], B = bodies[j2];
if (i === hold && j2 === hold) continue;
var h = collide(A, B);
if (!h) continue;
var cxW, cyW;
if (A.kind === 'b' && B.kind === 'b') { cxW = (A.x + B.x) * 0.5; cyW = (A.y + B.y) * 0.5; }
else if (A.kind === 'b') {
var co = Math.cos(A.a), si = Math.sin(A.a);
cxW = A.x + h.cx * co - h.cy * si; cyW = A.y + h.cx * si + h.cy * co;
} else if (B.kind === 'b') {
var co2 = Math.cos(B.a), si2 = Math.sin(B.a);
cxW = B.x + h.cx * co2 - h.cy * si2; cyW = B.y + h.cx * si2 + h.cy * co2;
} else { cxW = h.cx; cyW = h.cy; }
// 침투 보정 — 질량 역수 비율로 나눠 밀어낸다(잡은 몸체는 움직이지 않는다)
var tot = (i === hold ? 0 : A.im) + (j2 === hold ? 0 : B.im);
if (tot > 0) {
var cor = Math.min(h.pen, 6) / tot * 0.55;
if (i !== hold) { A.x -= h.nx * cor * A.im; A.y -= h.ny * cor * A.im; }
if (j2 !== hold) { B.x += h.nx * cor * B.im; B.y += h.ny * cor * B.im; }
}
if (impulse(A, B, h, cxW, cyW, e)) { hits++; hitVal.textContent = hits; }
}
}
// (3) 벽·바닥 — 반사 + 바닥 접점 토크(구르기)
for (i = 0; i < bodies.length; i++) {
bd = bodies[i];
if (i === hold) continue;
var rr = rad(bd);
if (bd.x < rr) { bd.x = rr; bd.px = bd.x + (bd.x - bd.px) * WALL_E; bd.pa += (bd.py - bd.y) * 0.02; }
if (bd.x > W - rr) { bd.x = W - rr; bd.px = bd.x + (bd.x - bd.px) * WALL_E; bd.pa -= (bd.py - bd.y) * 0.02; }
if (bd.y > H - rr) {
var vy = bd.y - bd.py;
bd.y = H - rr;
bd.py = bd.y + vy * WALL_E; // 반발
var vx = bd.x - bd.px;
bd.px = bd.x - vx * FRICTION; // 마찰
bd.pa -= vx * 0.02 / bd.r; // 구르기 토크(접선 속도 → 회전)
}
if (bd.y < rr) { bd.y = rr; bd.py = bd.y + (bd.y - bd.py) * WALL_E; }
}
}
function draw() {
ctx.clearRect(0, 0, W, H);
for (var i = 0; i < bodies.length; i++) {
var b = bodies[i];
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(b.a);
ctx.lineJoin = 'round';
ctx.lineWidth = 2.5;
ctx.strokeStyle = RING;
if (b.kind === 'c') {
ctx.fillStyle = b.col;
ctx.beginPath();
ctx.arc(0, 0, b.r, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// 회전이 보이는 표시 — 바깥 점 하나
ctx.fillStyle = RING;
ctx.beginPath();
ctx.arc(b.r * 0.55, 0, Math.max(2.5, b.r * 0.16), 0, Math.PI * 2);
ctx.fill();
} else {
ctx.fillStyle = b.col;
ctx.beginPath();
ctx.roundRect(-b.hw, -b.hh, b.hw * 2, b.hh * 2, 4);
ctx.fill();
ctx.stroke();
// 비대칭 마킹 — 어디가 위인지(회전) 알려준다
ctx.fillStyle = RING;
ctx.beginPath();
ctx.arc(b.hw * 0.45, -b.hh * 0.45, Math.max(2.5, b.hw * 0.14), 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
if (hold >= 0) {
ctx.strokeStyle = RING;
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.arc(bodies[hold].x, bodies[hold].y, rad(bodies[hold]) + 7, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
}
// 사람이 안 만졌을 때 도는 자동 시연 — 결정적 가상 "던지기".
// 한 주기마다 도형 하나를 사인파 경로로 끌어 올려 놓는다. Math.random 금지.
var manual = false;
var demoPhase = 0;
function demoThrow(dtSec) {
demoPhase += dtSec / LOOP;
var idx = Math.floor(demoPhase) % bodies.length;
var frac = demoPhase - Math.floor(demoPhase);
var b = bodies[idx];
if (!b) return;
var ph = demoPhase * Math.PI * 2;
if (frac < 0.62) {
// 잡는 구간 — 목표(들어 올리는 경로)를 따라간다. 이전 프레임 손 위치가 곧 이전 위치
var tx = W * 0.5 + Math.sin(ph) * W * 0.28;
var ty = H * 0.30 + Math.sin(ph * 2) * H * 0.08;
hold = bodies.indexOf(b);
holdPX = holdX; holdPY = holdY;
holdX = tx; holdY = ty;
} else if (hold >= 0) {
// 놓기 — 마지막 두 프레임 이동을 살짝 증폭해 던지기 속도로 만든다
holdPX = holdX - (holdX - holdPX) * 1.6;
holdPY = holdY - (holdY - holdPY) * 1.6;
hold = -1;
}
}
function frame(now) {
var dt = Math.min((now - t0) / 1000, 0.033);
if (!manual) demoThrow(dt);
step(now);
draw();
requestAnimationFrame(frame);
}
// 조작 — 도형을 집어 던진다. 놓는 순간의 이동 속도가 그대로 던지기 속도
function pick(x, y) {
var best = -1, bd2 = 30;
for (var i = 0; i < bodies.length; i++) {
var b = bodies[i];
var d = Math.hypot(b.x - x, b.y - y);
if (d < rad(b) + 12 && d < bd2 + rad(b)) { bd2 = d; best = i; }
}
return best;
}
field.addEventListener('pointerdown', function (e) {
var r = field.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
var idx = pick(x, y);
if (idx < 0) return;
manual = true;
hold = idx;
var b = bodies[idx];
holdX = b.x; holdY = b.y; holdPX = b.x; holdPY = b.y;
field.setPointerCapture(e.pointerId);
if (reduce.matches) { draw(); }
});
field.addEventListener('pointermove', function (e) {
if (hold < 0) return;
var r = field.getBoundingClientRect();
holdPX = holdX; holdPY = holdY;
holdX = Math.max(6, Math.min(W - 6, e.clientX - r.left));
holdY = Math.max(6, Math.min(H - 6, e.clientY - r.top));
});
window.addEventListener('pointerup', function () { hold = -1; });
document.getElementById('reset').addEventListener('click', function () {
manual = false; demoPhase = 0; build();
});
window.addEventListener('resize', fit);
fit();
requestAnimationFrame(frame);// RigidBodyCollide — vanilla 판(run/306/demo/04-rigid-body-collide-rotate/index.html) 의 React 포트.
// ① 충격량: 충돌 법선 방향 상대속도에 반발계수 e 를 곱한 운동량 보존으로 두 몸체에 나눠 준다.
// ② 토크: 충돌 접점이 중심에서 벗어난 팔만큼 각속도를 얻는다. OBB×원은 로컬 좌표 클램프 근사.
// Verlet 에서는 "이전 위치를 되돌리는 것"이 곧 속도 변화다. 값·주석 의미는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./RigidBodyCollide.module.scss";
export type RigidBodyCollideProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다 (292 선례)
type Box<T> = { current: T };
type InputEvt = { target: { value: string } };
type Body = {
kind: "c" | "b";
x: number; y: number; px: number; py: number;
r: number; hw: number; hh: number;
a: number; pa: number;
m: number; im: number;
};
type Hit = { nx: number; ny: number; pen: number; cx: number; cy: number };
const GRAV = 1400; // 중력(px/s^2)
const DAMP = 0.9985; // 속도 보존률
const WALL_E = 0.72; // 벽·바닥 반발
const FRICTION = 0.985; // 바닥 접선 감쇠
const LOOP = 2; // 자동 시연 한 바퀴(초)
const FIELD_LABEL = "강체 충돌 실험판";
export default function RigidBodyCollide({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
}: RigidBodyCollideProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const hitValRef = useRef<HTMLSpanElement | null>(null);
const eInRef = useRef<HTMLInputElement | null>(null);
const bodiesRef = useRef<Body[]>([]) as Box<Body[]>;
const sizeRef = useRef({ W: 0, H: 0 }) as Box<{ W: number; H: number }>;
const holdRef = useRef(-1) as Box<number>;
const holdPtRef = useRef({ x: 0, y: 0, px: 0, py: 0 }) as Box<{ x: number; y: number; px: number; py: number }>;
const hitsRef = useRef(0) as Box<number>;
const manualRef = useRef(false) as Box<boolean>;
const demoPhaseRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(0) as Box<number>;
const [rest, setRest] = useState(60);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function ctx2d(): CanvasRenderingContext2D | null {
const cv = cvRef.current;
if (!cv) return null;
return cv.getContext("2d");
}
function build(): void {
const { W, H } = sizeRef.current;
const bodies: Body[] = [];
const colIn = H * 0.34;
// 원 4 + 사각 2 — 질량은 반지름 세제곱 비례(면적 밀도 같은 셈)
const spec: { k: "c" | "b"; r: number; fx: number; fy: number; sq?: number }[] = [
{ k: "c", r: 17, fx: 0.5, fy: 0.2 },
{ k: "c", r: 13, fx: 0.3, fy: 0.46 },
{ k: "c", r: 21, fx: 0.72, fy: 0.4 },
{ k: "b", r: 15, fx: 0.42, fy: 0.62, sq: 0.78 },
{ k: "b", r: 18, fx: 0.62, fy: 0.3, sq: 1.25 },
{ k: "c", r: 11, fx: 0.86, fy: 0.66 }
];
for (let i = 0; i < spec.length; i++) {
const s = spec[i];
const m = s.k === "c" ? s.r * s.r * s.r : s.r * s.r * s.r * (s.sq || 1) * 0.9;
const x = W * s.fx, y = H - colIn * (1 - s.fy) * 0.9;
const a0 = s.k === "b" ? i * 0.9 - 1.2 : 0;
bodies.push({
kind: s.k, x, y, px: x, py: y,
r: s.r, hw: s.r, hh: s.r * (s.sq || 1),
a: a0, pa: a0, m, im: 1 / m
});
}
bodiesRef.current = bodies;
hitsRef.current = 0;
if (hitValRef.current) hitValRef.current.textContent = "0";
holdRef.current = -1;
demoPhaseRef.current = 0;
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctx2d();
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.W = r.width;
sizeRef.current.H = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
build();
}
// 몸체 반지름(충돌 판정용 대략 크기)
function rad(b: Body): number {
return b.kind === "c" ? b.r : Math.max(b.hw, b.hh);
}
// 원-원 충돌 → {nx, ny, pen, cx, cy}
function hitCC(a: Body, b: Body): Hit | null {
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.hypot(dx, dy);
const rr = a.r + b.r;
if (d >= rr || d === 0) return null;
return { nx: dx / d, ny: dy / d, pen: rr - d, cx: a.x + (dx / d) * a.r, cy: a.y + (dy / d) * a.r };
}
// 점을 회전사각(OBB) 중심 기준 로컬 좌표로 변환(월드→로컬은 역회전)
function toLocal(b: Body, px2: number, py2: number): { x: number; y: number } {
const c = Math.cos(b.a), s = Math.sin(b.a);
const dx = px2 - b.x, dy = py2 - b.y;
return { x: dx * c + dy * s, y: -dx * s + dy * c };
}
// 원-OBB 충돌 — 원심을 로컬로 옮겨 클램프한 최근접점으로 판정
function hitCB(c: Body, b: Body): Hit | null {
const l = toLocal(b, c.x, c.y);
const qx = Math.max(-b.hw, Math.min(b.hw, l.x));
const qy = Math.max(-b.hh, Math.min(b.hh, l.y));
const dx = l.x - qx, dy = l.y - qy;
const d = Math.hypot(dx, dy);
if (d >= c.r) return null;
let nx: number, ny: number, pen: number;
if (d > 0.0001) { nx = dx / d; ny = dy / d; pen = c.r - d; }
else {
// 원심이 사각 안 — 가장 얕은 축으로 밀어낸다
const ox = b.hw - Math.abs(l.x), oy = b.hh - Math.abs(l.y);
if (ox < oy) { nx = l.x >= 0 ? 1 : -1; ny = 0; pen = ox + c.r; }
else { nx = 0; ny = l.y >= 0 ? 1 : -1; pen = oy + c.r; }
}
const co = Math.cos(b.a), si = Math.sin(b.a); // 로컬 법선 → 월드로 회전
return { nx: nx * co - ny * si, ny: nx * si + ny * co, pen, cx: qx, cy: qy };
}
function collide(a: Body, b: Body): Hit | null {
if (a.kind === "c" && b.kind === "c") return hitCC(a, b);
if (a.kind === "c" && b.kind === "b") {
const h = hitCB(a, b);
if (h) { h.nx = -h.nx; h.ny = -h.ny; }
return h;
}
if (a.kind === "b" && b.kind === "c") return hitCB(b, a);
// 사각-사각은 원 근사로만 밀어낸다 — 6개 몸체 미니엔진의 절충
const ra = (a.hw + a.hh) * 0.5, rb2 = (b.hw + b.hh) * 0.5;
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.hypot(dx, dy);
if (d >= (ra + rb2) * 0.9 || d === 0) return null;
return { nx: dx / d, ny: dy / d, pen: (ra + rb2) * 0.9 - d, cx: 0, cy: 0 };
}
// 충격량 적용 — Verlet 은 이전 위치를 되돌리는 것으로 속도를 바꾼다
function impulse(a: Body, b: Body, n: { x: number; y: number }, cx2: number, cy2: number, e: number): boolean {
const avx = a.x - a.px, avy = a.y - a.py;
const bvx = b.x - b.px, bvy = b.y - b.py;
const vRel = (avx - bvx) * n.x + (avy - bvy) * n.y;
if (vRel > 0) return false; // 이미 떨어지는 중
const j = (-(1 + e) * vRel) / (a.im + b.im);
const jx = n.x * j, jy = n.y * j;
a.px -= jx * a.im; a.py -= jy * a.im;
b.px += jx * b.im; b.py += jy * b.im;
// 토크 — 접점 팔이 중심에서 벗어났으면 각속도로 돌린다(사각만 받는다)
if (a.kind === "b") {
const la = toLocal(a, cx2, cy2);
a.pa -= (la.x * n.y - la.y * n.x) * j * a.im * 0.00013;
}
if (b.kind === "b") {
const lb = toLocal(b, cx2, cy2);
b.pa += (lb.x * n.y - lb.y * n.x) * j * b.im * 0.00013;
}
return true;
}
function step(now: number): void {
const { W, H } = sizeRef.current;
const bodies = bodiesRef.current;
const dt = Math.min((now - t0Ref.current) / 1000, 0.033);
t0Ref.current = now;
const e = eInRef.current ? Number(eInRef.current.value) / 100 : 0.6;
const hold = holdRef.current;
// (1) 관성 + 중력 + 회전 감쇠
for (let i = 0; i < bodies.length; i++) {
const bd = bodies[i];
if (i === hold) continue;
const vx = bd.x - bd.px;
const vy = bd.y - bd.py + GRAV * dt * dt;
bd.px = bd.x; bd.py = bd.y;
bd.x += vx * DAMP;
bd.y += vy * DAMP;
const na = bd.a + (bd.a - bd.pa) * 0.995;
bd.pa = bd.a; bd.a = na;
}
if (hold >= 0) {
const hb = bodies[hold];
hb.px = holdPtRef.current.px; hb.py = holdPtRef.current.py; // 직전 손 위치 → 놓으면 던지기 속도
hb.x = holdPtRef.current.x; hb.y = holdPtRef.current.y;
hb.pa += (hb.a - hb.pa) * 0.8;
}
// (2) 짝 충돌 — 위치 보정 + 충격량
for (let i = 0; i < bodies.length; i++) {
for (let j2 = i + 1; j2 < bodies.length; j2++) {
const A = bodies[i], B = bodies[j2];
const h = collide(A, B);
if (!h) continue;
let cxW: number, cyW: number;
if (A.kind === "b" && B.kind === "b") { cxW = (A.x + B.x) * 0.5; cyW = (A.y + B.y) * 0.5; }
else if (A.kind === "b") {
const co = Math.cos(A.a), si = Math.sin(A.a);
cxW = A.x + h.cx * co - h.cy * si; cyW = A.y + h.cx * si + h.cy * co;
} else if (B.kind === "b") {
const co2 = Math.cos(B.a), si2 = Math.sin(B.a);
cxW = B.x + h.cx * co2 - h.cy * si2; cyW = B.y + h.cx * si2 + h.cy * co2;
} else { cxW = h.cx; cyW = h.cy; }
// 침투 보정 — 질량 역수 비율로 나눠 밀어낸다(잡은 몸체는 안 움직인다)
const tot = (i === hold ? 0 : A.im) + (j2 === hold ? 0 : B.im);
if (tot > 0) {
const cor = (Math.min(h.pen, 6) / tot) * 0.55;
if (i !== hold) { A.x -= h.nx * cor * A.im; A.y -= h.ny * cor * A.im; }
if (j2 !== hold) { B.x += h.nx * cor * B.im; B.y += h.ny * cor * B.im; }
}
if (impulse(A, B, { x: h.nx, y: h.ny }, cxW, cyW, e)) {
hitsRef.current++;
if (hitValRef.current) hitValRef.current.textContent = String(hitsRef.current);
}
}
}
// (3) 벽·바닥 — 반사 + 바닥 접점 토크(구르기)
for (let i = 0; i < bodies.length; i++) {
const bd = bodies[i];
if (i === hold) continue;
const rr = rad(bd);
if (bd.x < rr) { bd.x = rr; bd.px = bd.x + (bd.x - bd.px) * WALL_E; bd.pa += (bd.py - bd.y) * 0.02; }
if (bd.x > W - rr) { bd.x = W - rr; bd.px = bd.x + (bd.x - bd.px) * WALL_E; bd.pa -= (bd.py - bd.y) * 0.02; }
if (bd.y > H - rr) {
const vy = bd.y - bd.py;
bd.y = H - rr;
bd.py = bd.y + vy * WALL_E; // 반발
const vx = bd.x - bd.px;
bd.px = bd.x - vx * FRICTION; // 마찰
bd.pa -= (vx * 0.02) / bd.r; // 구르기 토크(접선 속도 → 회전)
}
if (bd.y < rr) { bd.y = rr; bd.py = bd.y + (bd.y - bd.py) * WALL_E; }
}
}
function draw(circ: string, boxf: string, ring: string): void {
const ctx = ctx2d();
if (!ctx) return;
const bodies = bodiesRef.current;
ctx.clearRect(0, 0, sizeRef.current.W, sizeRef.current.H);
for (let i = 0; i < bodies.length; i++) {
const b = bodies[i];
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(b.a);
ctx.lineJoin = "round";
ctx.lineWidth = 2.5;
ctx.strokeStyle = ring;
if (b.kind === "c") {
ctx.fillStyle = circ;
ctx.beginPath();
ctx.arc(0, 0, b.r, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// 회전이 보이는 표시 — 바깥 점 하나
ctx.fillStyle = ring;
ctx.beginPath();
ctx.arc(b.r * 0.55, 0, Math.max(2.5, b.r * 0.16), 0, Math.PI * 2);
ctx.fill();
} else {
ctx.fillStyle = boxf;
ctx.beginPath();
ctx.roundRect(-b.hw, -b.hh, b.hw * 2, b.hh * 2, 4);
ctx.fill();
ctx.stroke();
// 비대칭 마킹 — 어디가 위인지(회전) 알려준다
ctx.fillStyle = ring;
ctx.beginPath();
ctx.arc(b.hw * 0.45, -b.hh * 0.45, Math.max(2.5, b.hw * 0.14), 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
if (holdRef.current >= 0) {
ctx.strokeStyle = ring;
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 4]);
ctx.beginPath();
ctx.arc(bodies[holdRef.current].x, bodies[holdRef.current].y, rad(bodies[holdRef.current]) + 7, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
}
// 사람이 안 만졌을 때 도는 자동 시연 — 결정적 가상 "던지기"(사인파, 난수 없음)
function demoThrow(dtSec: number): void {
const { W, H } = sizeRef.current;
const bodies = bodiesRef.current;
demoPhaseRef.current += dtSec / LOOP;
const idx = Math.floor(demoPhaseRef.current) % bodies.length;
const frac = demoPhaseRef.current - Math.floor(demoPhaseRef.current);
const b = bodies[idx];
if (!b) return;
const ph = demoPhaseRef.current * Math.PI * 2;
const hp = holdPtRef.current;
if (frac < 0.62) {
// 잡는 구간 — 목표(들어 올리는 경로)를 따라간다
hp.px = hp.x; hp.py = hp.y;
hp.x = W * 0.5 + Math.sin(ph) * W * 0.28;
hp.y = H * 0.3 + Math.sin(ph * 2) * H * 0.08;
holdRef.current = idx;
} else if (holdRef.current >= 0) {
// 놓기 — 마지막 두 프레임 이동을 증폭해 던지기 속도로
hp.px = hp.x - (hp.x - hp.px) * 1.6;
hp.py = hp.y - (hp.y - hp.py) * 1.6;
holdRef.current = -1;
}
}
useEffect(() => {
fit();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
// 캔버스가 쓰는 색은 module.scss 의 커스텀 프로퍼티에서 읽는다 — 값은 스타일 한 곳에만 둔다
const css = getComputedStyle(rootRef.current as Element);
const circ = css.getPropertyValue("--circ").trim() || "#6ec8ff";
const boxf = css.getPropertyValue("--boxf").trim() || "#b9a0ff";
const ring = css.getPropertyValue("--ring").trim() || "#fff7e6";
t0Ref.current = performance.now();
function frame(now: number): void {
const dt = Math.min((now - t0Ref.current) / 1000, 0.033);
if (!manualRef.current) demoThrow(dt);
step(now);
draw(circ, boxf, ring);
rafRef.current = requestAnimationFrame(frame);
}
if (!reduce.matches) rafRef.current = requestAnimationFrame(frame);
else { step(performance.now()); draw(circ, boxf, ring); }
function onResize(): void { fit(); }
function onReduceChange(): void {
if (reduce.matches) {
manualRef.current = true;
holdRef.current = -1;
cancelAnimationFrame(rafRef.current);
} else if (!manualRef.current) {
t0Ref.current = performance.now();
rafRef.current = requestAnimationFrame(frame);
}
}
window.addEventListener("resize", onResize);
reduce.addEventListener("change", onReduceChange);
return () => {
window.removeEventListener("resize", onResize);
reduce.removeEventListener("change", onReduceChange);
cancelAnimationFrame(rafRef.current);
};
}, []);
// 조작 — 도형을 집어 던진다. 놓는 순간의 이동 속도가 그대로 던지기 속도
function pick(x: number, y: number): number {
const bodies = bodiesRef.current;
let best = -1, bd2 = Infinity;
for (let i = 0; i < bodies.length; i++) {
const b = bodies[i];
const d = Math.hypot(b.x - x, b.y - y);
if (d < rad(b) + 12 && d - rad(b) < bd2) { bd2 = d - rad(b); best = i; }
}
return best;
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const x = event.clientX - r.left, y = event.clientY - r.top;
const idx = pick(x, y);
if (idx < 0) return;
manualRef.current = true;
holdRef.current = idx;
const b = bodiesRef.current[idx];
holdPtRef.current = { x: b.x, y: b.y, px: b.x, py: b.y };
field.setPointerCapture(event.pointerId);
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
if (holdRef.current < 0) return;
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
const hp = holdPtRef.current;
hp.px = hp.x; hp.py = hp.y;
hp.x = Math.max(6, Math.min(sizeRef.current.W - 6, event.clientX - r.left));
hp.y = Math.max(6, Math.min(sizeRef.current.H - 6, event.clientY - r.top));
}
function onPointerUp(): void {
holdRef.current = -1;
}
function onReset(): void {
manualRef.current = false;
build();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
if (reduce.matches) return; // 줄인 모드에서는 정지 장면만 둔다
t0Ref.current = performance.now();
cancelAnimationFrame(rafRef.current);
const css = getComputedStyle(rootRef.current as Element);
const circ = css.getPropertyValue("--circ").trim() || "#6ec8ff";
const boxf = css.getPropertyValue("--boxf").trim() || "#b9a0ff";
const ring = css.getPropertyValue("--ring").trim() || "#fff7e6";
rafRef.current = requestAnimationFrame(function loop(now: number): void {
const dt = Math.min((now - t0Ref.current) / 1000, 0.033);
if (!manualRef.current) demoThrow(dt);
step(now);
draw(circ, boxf, ring);
rafRef.current = requestAnimationFrame(loop);
});
}
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224,128a96,96,0,0,1-96,96c-11.4,0-20-8.6-20-20s8.9-20,20-20a56,56,0,0,0,0-112c-11.1,0-20-8.9-20-20s8.6-20,20-20A96,96,0,0,1,224,128Z" opacity=".2"/><path d="M128,24A104,104,0,0,0,128,232c-15.8,0-28-12.3-28-28s12.4-28,28-28a48,48,0,0,0,0-96c-15.6,0-28-12.4-28-28S112.2,24,128,24Zm0,16c-6.9,0-12,5.2-12,12s5.3,12,12,12a64,64,0,0,1,0,128c-6.7,0-12,5.4-12,12s5.1,12,12,12a88,88,0,0,0,0-176Z"/></g></svg>
<span>도형을 잡아 던지면 충돌 법선으로 속도를 주고받고 접점이 치우친 만큼 굴러갑니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={hitValRef}>0</span><span className={styles.badgeUnit}>충돌 횟수</span></b>
</div>
<div className={styles.bar}>
<label className={styles.dial}>반발
<input
ref={eInRef}
type="range" min={30} max={85} step={5} value={rest} aria-label="반발계수(%)"
onChange={(event: InputEvt) => setRest(Number(event.target.value))}
/>
</label>
<button className={styles.reset} type="button" onClick={onReset}>처음으로</button>
</div>
</div>
</main>
);
}04 구르며 부딪히는 도형 — 측정 누적면적 6.933% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 자바스크립트 물리엔진 원리 9종 — Verlet 로프부터 래그돌까지
이 데모가 속한 글
자바스크립트 물리엔진 원리 9종 — Verlet 로프부터 래그돌까지자바스크립트 물리엔진은 로프가 늘어나고 천이 찢어지며 젤리가 출렁이는 움직임을 계산으로 만들어 냅니다. 이 아홉 판은 라이브러리 없이 캔버스와 Verlet 적분만으로 관절 인형·부력·도미노 연쇄·액체 방울까지 움직이 이 글의 데모 전부 보기


