GODRICH

물리 시뮬레이션 만들기 9종 — 스프링부터 관성 스크럽까지

물리 시뮬레이션 만들기는 중력·스프링·마찰 같은 힘을 프레임마다 계산해 화면 요소의 자리를 직접 정하는 일입니다. 여기 실은 아홉 판은 라이브러리 없이 requestAnimationFrame 과 transform 두 가지만으로 짜서, 클릭·드래그·훑기에 그 자리에서 반응합니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개를 이 순서로 늘어놓은 기준은 힘이 어디서 오는가입니다. 앞의 셋은 힘이 하나뿐인 판 — 목표로 끌어당기는 스프링(01), 아래로만 잡아당기는 중력(02), 각도가 곧 상태값인 진자(03)입니다. 가운데 셋은 움직이는 몸체가 여럿이라 서로 부딪히거나 끌어당기는 판으로, 철가루 서른두 개(04)와 벽돌 열여덟 칸(05), 점 예순 개의 천(06)이 이어집니다. 마지막 셋은 사람 손의 속도가 그대로 힘이 되는 판 — 커서가 지나간 방향이 바람이 되고(07), 상자를 흔든 가속이 공에 반대로 걸리고(08), 손을 놓는 순간의 속도가 미끄러짐이 됩니다(09). 무대 색은 잉크 넷·노랑 둘·페이퍼 둘·주황 하나로 돌렸습니다. 포인터를 각도로 바꿔 입체를 돌리는 쪽은 마우스를 따라오는 CSS 3D 회전 장면 9종에, 소리를 그림으로 바꾸는 쪽은 오디오 비주얼라이저 9종에 있습니다.

01스프링 따라오기

판을 클릭하거나 방향키로 목표를 옮기면, 칩은 강성·감쇠 두 다이얼이 시키는 만큼 목표를 지나쳤다 되돌아와 붙습니다. 가속을 먼저 속도에 넣고 그 속도로 위치를 옮기는 준암시적 오일러이고, dt 를 0.033초에서 자르는 한 줄이 다른 탭에 갔다 돌아온 첫 프레임에 칩이 판 밖으로 날아가는 것을 막습니다. 실제로 눌러 보면 목표 320px 을 362.4px 까지 지나쳤다가 320.0px 에 멎고(run/292/_probe/interact.json), 격자 미리보기는 누적면적 11.9%, 강도 106.9, 움직인 프레임 23/23 을 기록했습니다.

requestAnimationFrametranslatepointerdownkeydown
function step(now) {
  // 준암시적 오일러: 가속을 먼저 반영하고 위치를 옮긴다. dt 는 프레임 간격(초)이다.
  var dt = Math.min((now - last) / 1000, 0.033);
  last = now;
  var k = Number(stiffIn.value) * 60;
  var c = Number(dampIn.value) * 1.5;
  vel.x += (k * (target.x - pos.x) - c * vel.x) * dt;
  vel.y += (k * (target.y - pos.y) - c * vel.y) * dt;
  pos.x += vel.x * dt;
  pos.y += vel.y * dt;
  frame++;
  if (frame % 2 === 0) {
    trail[2] = trail[1]; trail[1] = trail[0]; trail[0] = { x: pos.x, y: pos.y };
  }
  paint();
  var settled = Math.abs(target.x - pos.x) < 0.5 && Math.abs(target.y - pos.y) < 0.5
    && Math.abs(vel.x) < 4 && Math.abs(vel.y) < 4;
  if (settled) {
    pos.x = target.x; pos.y = target.y; vel.x = 0; vel.y = 0;
    trail = [ { x: pos.x, y: pos.y }, { x: pos.x, y: pos.y }, { x: pos.x, y: pos.y } ];
    paint(); running = false; return;
  }
  requestAnimationFrame(step);
}

02중력 드롭

공 넷이 함께 떨어져 바닥에서 반발 슬라이더 값만큼 되튀고, 남은 세로 속도가 초당 40px 아래로 내려가면 바닥에 붙어 끝납니다. 공끼리는 겹친 거리의 절반씩 서로 밀어내기만 하므로, 속도를 주고받는 계산 없이도 무더기가 자연스럽게 갈라집니다. 미리보기는 누적면적 5.9%, 강도 187.6, 움직인 프레임 22/23 이고, 드래그해서 놓은 파일 아이콘이 떨어지는 자리나 게임 느낌의 보상 효과에 씁니다.

requestAnimationFrametranslatepointerdown
    b.vy += G * dt;
    b.x += b.vx * dt;
    b.y += b.vy * dt;
    // 바닥 충돌: 반발계수만큼 세게 되튕기고 남은 속도가 느리면 바닥에 붙여 끝낸다
    if (b.y + b.size >= floor) {
      b.y = floor - b.size;
      if (Math.abs(b.vy) > 40) { b.vy = -b.vy * e; }
      else { b.vy = 0; b.done = true; }
    }

03진자 흔들기

추를 잡아끌면 커서가 가리키는 방향이 그대로 팔의 각도가 되고, 놓으면 -(g/L)·sinθ 에서 감쇠를 뺀 각가속도가 매 프레임 각도에 쌓입니다. 회전축이 위쪽 경첩이라 transform-origin 을 맨 위 변(50% 0)에 두는 것이 팔을 흔드는 전부이고, 끌 수 있는 각도는 ±75도에서 잘라 팔이 뒤집히지 않게 했습니다. 격자 미리보기는 감쇠가 없는 ±26도 사인 흔들림을 2초 주기로 재생해 누적면적 5.6%, 강도 119.3, 움직인 프레임 23/23 을 기록했습니다.

rotatetransform-originrequestAnimationFrame
// 팔 전체가 고정점을 축으로 돈다 — transform-origin 을 맨 위로 (붙는 지점)
.pd__arm {
  position: absolute; left: 50%; top: 10px; width: 0; height: 118px;
  transform-origin: 50% 0;
}
@keyframes pdSwing {
  0%   { transform: rotate(26deg); }
  25%  { transform: rotate(0deg); }
  50%  { transform: rotate(-26deg); }
  75%  { transform: rotate(0deg); }
  100% { transform: rotate(26deg); }
}

04자석 파티클

철가루 서른두 개가 8×4 격자에 지터를 준 자리에 흩어져 있고, 커서가 자석이 되어 버튼으로 고른 방향에 따라 빨아들이거나 밀어냅니다. 힘은 거리 제곱에 반비례하되 분모에 900 을 더해 커서에 닿는 순간 값이 폭발하지 않게 막았고, 제자리로 당기는 스프링과 0.86 배 감쇠가 함께 걸려 무리가 흩어지지 않고 돌아옵니다. 미리보기는 누적면적 18.8%, 강도 158.9, 움직인 프레임 23/23 이고, 히어로 배경처럼 넓은 면을 덮는 자리에 씁니다.

requestAnimationFramepointermovetranslate
function step(now) {
  var dt = Math.min((now - last) / 1000, 0.033);
  last = now;
  var lively = now - idleAt < 1600, allCalm = true;
  for (var i = 0; i < dots.length; i++) {
    var d = dots[i];
    var dx = d.x - mag.x, dy = d.y - mag.y;
    var r2 = dx * dx + dy * dy;
    // 쿨롱 간이: 힘 = 부호 / (r² + 연화) — 거리 제곱에 반비례하고 0 나누기를 막는다
    var f = (attract ? -5200 : 4200) / (r2 + 900);
    var len = Math.sqrt(r2) || 1;
    d.vx += (dx / len) * f * dt * 60;
    d.vy += (dy / len) * f * dt * 60;
    // 제자리 스프링 + 감쇠 — 철가루가 흩어지지 않고 돌아온다
    d.vx += (d.hx - d.x) * 46 * dt;
    d.vy += (d.hy - d.y) * 46 * dt;
    d.vx *= 0.86; d.vy *= 0.86;
    d.x += d.vx * dt; d.y += d.vy * dt;
    if (Math.abs(d.vx) + Math.abs(d.vy) > 3 || Math.abs(d.hx - d.x) + Math.abs(d.hy - d.y) > 0.4) { allCalm = false; }
    d.el.style.transform = 'translate(' + d.x + 'px, ' + d.y + 'px)';
  }
  if (!lively && allCalm) { paintDots(); running = false; return; }
  requestAnimationFrame(step);
}

05패들 튕기기

패들이 마우스를 따라오고 공은 3×6 으로 놓인 벽돌 열여덟 칸과 벽에서 튕기며 점수를 올립니다. 패들 반사는 맞은 자리에 따라 수평 성분을 꺾어서, 가운데를 맞히면 곧게 오르고 끝을 맞히면 옆으로 크게 눕습니다. 벽돌 격자의 원점은 BX·BY 상수 한 쌍뿐이라 그리는 자리와 충돌 판정이 같은 값을 보고, 미리보기는 누적면적 6.2%, 강도 164.0, 움직인 프레임 23/23 을 기록했습니다.

requestAnimationFramepointermovekeydown
// 벽돌 3×6 — 왼쪽 여백 22, 위 여백 24, 칸 60×14, 간격 4. 행 색은 CSS 클래스가 정한다.
// 격자의 원점은 여기 한 곳뿐이다 — 그리는 자리(laid)와 충돌 판정(step)이 같은 BX·BY 를 본다.
// 두 곳에 따로 적으면 위 여백만 옮기는 순간 공이 보이지 않는 띠에서 튕긴다
var COLS = 6, ROWS = 3, BX = 22, BY = 24, BW = 60, BH = 14, GAP = 4;
var bricks = [];
for (var r = 0; r < ROWS; r++) {
  for (var c = 0; c < COLS; c++) {
    var el = document.createElement('i');
    el.className = 'pb__brick pb__brick--r' + r + ' pb__brick--r' + r + 'c' + c;
    el.style.transform = 'translate(0, 0)';  // 실제 위치는 laid() 가 판 폭에 맞춰 넣는다
    bricksBox.appendChild(el);
    bricks.push({ el: el, r: r, c: c, alive: true });
  }
}

06천 출렁이기

12×5 로 놓인 점 예순 개 가운데 맨 윗줄 열두 개는 핀으로 고정되고, 커서 반경 36px 안에 든 점만 밀려났다 스프링으로 제자리에 돌아옵니다. 파동은 이웃 끌림이 만드는데, 점마다 왼쪽·오른쪽 변위의 평균과 위쪽까지 합한 평균을 조금씩 따라가기 때문에 한 점을 민 힘이 옆줄로 번집니다. 제자리 좌표는 CSS 변수 --hx·--hy 가 들고 있고 자바스크립트는 오프셋만 calc() 로 얹어서 창 폭이 바뀌어도 격자가 흐트러지지 않으며, 미리보기는 누적면적 12.9%, 강도 114.4, 움직인 프레임 23/23 입니다.

requestAnimationFramepointermovetranslate
function paint() {
  for (var i = 0; i < dots.length; i++) {
    var d = dots[i];
    d.el.style.transform = 'translate(calc(var(--hx) + ' + d.ox.toFixed(1) + 'px), calc(var(--hy) + ' + d.oy.toFixed(1) + 'px))';
  }
  cursor.style.transform = 'translate(' + ptr.x + 'px, ' + ptr.y + 'px)';
}
    // 이웃 끌림 — 왼쪽·오른쪽·위 점의 현재 변위를 조금 따라간다 (천의 가로 실)
    var L = dots[d.r * COLS + Math.max(0, d.c - 1)];
    var R = dots[d.r * COLS + Math.min(COLS - 1, d.c + 1)];
    var U = dots[Math.max(0, d.r - 1) * COLS + d.c];
    d.vx += ((L.ox + R.ox) / 2 - d.ox) * 60 * dt;
    d.vy += ((L.oy + R.oy + U.oy) / 3 - d.oy) * 60 * dt;
    // 제자리 스프링 + 감쇠
    d.vx += -d.ox * 90 * dt; d.vy += -d.oy * 90 * dt;
    d.vx *= 0.9; d.vy *= 0.9;
    d.ox += d.vx * dt; d.oy += d.vy * dt;

07플로우 필드

대시 서른여섯 개가 커서의 이동 방향을 바람으로 받아 그쪽으로 흐르고, 진행 방향에 맞춰 rotate 까지 돌아 결이 눈에 보입니다. 마지막 입력이 0.9초 이내면 조타력이 2.4, 그보다 오래되면 0.4 로 떨어져 손을 멈춘 뒤 흐름이 천천히 풀리고, 속도는 190 에서 잘라 대시가 순간이동처럼 건너뛰지 않게 했습니다. 가장자리에 닿은 대시는 반대편으로 돌아 들어와 화면이 비지 않고, 미리보기는 누적면적 19.1%, 강도 164.0, 움직인 프레임 23/23 입니다.

requestAnimationFramepointermoverotate
function step(now) {
  var dt = Math.min((now - last) / 1000, 0.033);
  last = now;
  var w = field.clientWidth, h = field.clientHeight;
  var gust = now - windUntil < 900;   // 최근 입력 바람이 살아 있는 동안 조타력이 세다
  for (var i = 0; i < parts.length; i++) {
    var p = parts[i];
    // 흐름장: 모든 대시가 같은 방향으로 조타된다 — 힘은 커서 속도에서 온다
    p.vx += (wind.x - p.vx) * (gust ? 2.4 : 0.4) * dt;
    p.vy += (wind.y - p.vy) * (gust ? 2.4 : 0.4) * dt;
    var sp = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
    if (sp > 190) { p.vx *= 190 / sp; p.vy *= 190 / sp; }
    p.x += p.vx * dt;
    p.y += p.vy * dt;
    p.ang = Math.atan2(p.vy, p.vx) * 180 / Math.PI;
    // 가장자리에서 반대편으로 돌아온다 — 흐름장의 공간이 잇달아 이어진 셈이다
    if (p.x > w + 8) { p.x = -8; } else if (p.x < -8) { p.x = w + 8; }
    if (p.y > h + 8) { p.y = -8; } else if (p.y < -8) { p.y = h + 8; }
  }
  var name = dirName();
  if (dirText.textContent !== name) { dirText.textContent = name; }
  paint();
  requestAnimationFrame(step);
}

08관성 상자

상자를 잡고 좌우로 흔들면 상자 가속의 반대 방향 힘이 공 셋에 걸려 공이 뒤로 굴러가고, 손을 떼면 상자가 스프링으로 가운데에 돌아옵니다. 공은 벽에서 0.7 배로 되튀고 속도가 초당 20px 밑으로 떨어지면 그 자리에 서며, 미리보기는 누적면적 11.8%, 강도 145.4, 움직인 프레임 21/23 입니다. 가장 잘 깨지는 곳은 가속을 지우는 자리인데, 손을 뗀 프레임마다 boxAX 를 0 으로 되돌리지 않으면 화살표 키로 준 힘이 영원히 남아 공이 한쪽 벽에 붙고 루프도 멎지 않습니다.

requestAnimationFramepointerdowntranslate
function step(now) {
  var dt = Math.min((now - last) / 1000, 0.033);
  last = now;
  var inner = innerW(), allCalm = true;
  if (!dragging) {
    // 손을 뗀 뒤 상자는 스프링으로 중앙으로 돌아온다
    boxVX += (-90 * boxX - 9 * boxVX) * dt;
    boxX += boxVX * dt;
  }
  for (var i = 0; i < balls.length; i++) {
    var b = balls[i];
    // 관성: 상자 가속의 반대 방향 힘이 공에게 걸리고 벽에서는 반발로 튕긴다
    b.vx += -boxAX * 1.1 * dt;
    b.x += b.vx * dt;
    if (b.x < 0) { b.x = 0; if (Math.abs(b.vx) > 20) { b.vx = -b.vx * 0.7; } else { b.vx = 0; } }
    if (b.x > inner - b.size) { b.x = inner - b.size; if (Math.abs(b.vx) > 20) { b.vx = -b.vx * 0.7; } else { b.vx = 0; } }
    if (Math.abs(b.vx) > 1 || Math.abs(boxVX) > 2 || Math.abs(boxX) > 0.5) { allCalm = false; }
  }
  // 손을 떼고 있을 때 가속은 한 프레임짜리 충격이다 — 안 지우면 화살표 키로 준 힘이 영원히 남아
  // 공이 한쪽 벽에 붙고 allCalm 이 끝내 참이 안 돼 루프도 안 멎는다. 끄는 동안은 moveBox 가 매번 다시 넣는다
  if (!dragging) { boxAX = 0; }
  paint();
  if (allCalm && !dragging) { reset(); running = false; return; }
  requestAnimationFrame(step);
}

09관성 스크럽

카드 줄을 세게 훑고 놓으면 최근 120밀리초 표본의 기울기가 속도가 되어 미끄러지다가, 마찰로 느려지면 가장 가까운 칸으로 당겨져 붙습니다. 감속을 Math.pow(0.91, dt * 60) 로 둔 덕분에 프레임이 밀려도 같은 시간에 같은 만큼 줄고, 카드 폭 120px 에 간격 12px 을 더한 132px 이 칸의 눈금입니다. 실제로 던져 보면 놓은 직후 -272.0px 에서 160밀리초 뒤 -360.6px 을 지나 -396.0px, 즉 눈금 세 칸에 정확히 멎었고(run/292/_probe/interact2.json) 미리보기는 누적면적 33.3%, 강도 180.0, 움직인 프레임 20/23 입니다.

requestAnimationFramepointeruptranslate
function step(now) {
  var dt = Math.min((now - last) / 1000, 0.033);
  last = now;
  // 손이 붙어 있는 동안은 물리를 돌리지 않는다 — 돌리면 마찰 감속과 스냅 스프링이 끄는 손과 싸운다
  if (dragging) { requestAnimationFrame(step); return; }
  if (settling) {
    // 카드에 붙는 마지막 스프링 — 목표는 가장 가까운 칸
    var target = snapTarget();
    vx += (150 * (target - x) - 13 * vx) * dt;
    x += vx * dt;
    if (Math.abs(target - x) < 0.4 && Math.abs(vx) < 6) {
      x = target; vx = 0; settling = false; paint(); running = false; return;
    }
  } else {
    // 마찰 감속 — 프레임 비율과 무관하게 60fps 기준 0.91^dt 로 지수 감쇠시킨다
    vx *= Math.pow(0.91, dt * 60);
    x += vx * dt;
    var c = clampX(x);
    if (c !== x) { x = c; vx = 0; }
    if (Math.abs(vx) < 160) { settling = true; vx = 0; }
  }
  paint();
  requestAnimationFrame(step);
}

어디서 깨지는가 — 함정

물리 코드가 깨진 자리는 대개 식이 아니라 식 바깥이었고, 식 안에서 깨진 한 번은 단위 때문이었습니다. 05 는 자바스크립트가 벽돌에 pb__brick--r0c0 처럼 행과 열을 붙인 클래스만 달아 줬는데 색 규칙은 .pb__brick--r0 을 보고 있어서, 클래스 선택자가 어느 벽돌과도 정확히 맞지 않아 판이 배경만 남은 채 그려졌습니다. 행 클래스를 따로 하나 더 붙이게 고치자 세 줄이 주황·노랑·크림으로 돌아왔고, 지금 판에는 3×6 열여덟 칸이 그 세 색으로 서 있습니다(run/292/_probe/interact2.json05_벽돌색·05_벽돌수). 09 는 반대로 물리가 너무 많이 돌았습니다. 손가락이 아직 붙어 있는 동안에도 rAF 루프가 마찰 감속과 스냅 스프링을 계속 계산해서, 끄는 손이 옮겨 놓은 만큼을 물리가 뒤로 당기며 서로 싸웠습니다. step() 맨 위에 if (dragging) { requestAnimationFrame(step); return; } 한 줄을 넣어 손이 붙어 있는 프레임에는 아무것도 적분하지 않게 하자 끄는 동안 역행이 사라졌습니다.

세 번째는 단위입니다. 03 의 angle·avel 은 도(deg)로 들고 있는데 복원항 -(g/L)·sinθ 가 내놓는 값은 라디안/s² 이라, 변환 없이 도 단위 속도에 더하면 되돌리는 힘이 180/π 배, 그러니까 약 57배 작아집니다. 그래서 30도에서 놓아도 6초 동안 29.1도까지, 겨우 0.9도밖에 움직이지 않았습니다. 지금은 Math.sin(angle / RAD) 으로 각을 라디안으로 넘기고 결과에 다시 RAD 를 곱해 도/s² 로 되돌리며, 중력도 22 에서 1800px/s² 로 올려 02 중력 드롭과 같은 눈금에 맞췄습니다 — 단위만 고쳐 놓으면 팔 118px 에 22px/s² 라 주기가 14.6초여서 여전히 흔들리지 않기 때문입니다. 고친 판을 30도에서 놓아 재 보면 6초 동안 영점통과 8회, 최대 26.9도, 6초 뒤 7.28도로 주기 1.6초짜리 감쇠 진동이 나옵니다(run/292/_probe/swing03.json).

var G = 1800, LEN = 118, DAMP = 0.4, RAD = 180 / Math.PI;
  avel += (-(G / LEN) * Math.sin(angle / RAD) * RAD - DAMP * avel) * dt;

더 중요한 것은 이 오류를 어떤 게이트도 잡지 못했다는 사실입니다. 격자 미리보기는 CSS @keyframes pdSwing(±26도)이고 측정.json 은 그 미리보기를 재기 때문에, 03 은 고장 나 있는 내내 누적면적 5.61%, 강도 119.3, 움직인 프레임 23/23 을 똑같이 찍었습니다. 아홉 판이 모두 재는 경로(CSS 키프레임)와 손이 닿는 경로(rAF 적분)를 따로 들고 있으니, 렌더 게이트를 통과했다는 말은 사람이 실제로 만졌을 때 어떤지에 대해 아무것도 말해 주지 않습니다.

네 번째는 격자의 원점을 두 곳에 적어 두는 버릇입니다. 05 의 벽돌은 그리는 laid() 와 충돌을 판정하는 step() 이 같은 BX·BY 를 봐야 하는데, 위 여백을 각자 들고 있으면 눈에 보이는 벽돌과 공이 부딪히는 사각형이 어긋나 공이 아무것도 없는 띠에서 튕깁니다.

변형 바꾼 값 느낌
강성 높임 01 강성 슬라이더 3 → 6 (k = 값 x 60) 도착이 빨라지고 오버슈트가 커진다
감쇠 높임 01 감쇠 슬라이더 8 → 12 (c = 값 x 1.5) 오버슈트가 줄어 미끄러지듯 붙는다
반발 높임 02 반발 슬라이더 0.62 → 0.88 공이 오래 살아남아 바닥을 두드린다

그래서 이 아홉 판은 미리보기와 조작 경로를 늘 함께 열어 보아야 합니다. 아홉 판을 담은 압축 파일의 비밀번호는 qc5pgrv8 이고, 풀어 보면 미리보기용 .is-demo 규칙과 실제 적분 루프가 항목마다 나란히 들어 있어 두 경로를 따로 손볼 수 있습니다.

접근성

아홉 판 모두 조작하는 면에 role="application"tabindex="0" 을 주고, 포인터로 하는 일을 화살표 키로도 하게 했습니다. 목표 옮기기(01), Enter 로 다시 떨어뜨리기(02), 15도씩 당기기(03), 자석 옮기기(04), 패들 밀기와 Enter 공 놓기(05), 커서 자리 옮기기(06), 바람 불기(07), 상자 흔들기(08), 한 칸씩 넘기기(09)가 전부 키보드로 됩니다. 끌기가 스크롤과 다투지 않도록 아홉 판의 조작 면에는 touch-action: none 을 걸었고, 각도(03)와 점수(05)처럼 숫자가 바뀌는 곳에는 aria-live="polite" 를 붙여 보이지 않아도 값이 읽히게 했습니다. prefers-reduced-motion: reduce 에서는 미리보기 애니메이션을 전부 끄고 판마다 가장 잘 읽히는 한 장을 세웁니다 — 칩은 출발 자리(01), 공은 바닥(02), 추는 수직(03), 철가루와 천의 점은 제자리 좌표(04·06), 대시는 흩뿌려진 자리(07), 상자는 가운데(08), 카드 줄은 첫 칸(09)입니다. 자바스크립트 쪽도 같은 질의를 matchMedia 로 물어서, 사람이 조작하면 진동 없이 목표 상태로 한 번에 옮깁니다.

FAQ

프레임이 흔들려도 시뮬레이션 결과가 같나요?

아홉 판 모두 dt 를 0.033초에서 자르고 그 dt 를 식에 곱하기 때문에, 프레임이 느려져도 한 프레임에 몰아서 뛰지 않고 다음 프레임에 나눠 갑니다. 지수 감쇠처럼 곱셈으로 줄이는 값은 09 의 Math.pow(0.91, dt * 60) 처럼 지수에 시간을 넣어야 모니터 주사율이 달라도 같은 곡선이 나옵니다. 프레임 콜백의 인자와 호출 규칙은 requestAnimationFrame 문서에 있습니다.

터치 화면에서도 같은 코드가 그대로 도나요?

아홉 판은 pointerdown·pointermove·pointerup 하나로 마우스·터치·펜을 같이 받습니다. Pointer Events 지원율은 2026년 9월 12일 기준 96.75%이고 남은 쪽은 오페라 미니 계열이라, 터치 기기에서 브라우저가 먼저 스크롤을 가져가지 않도록 touch-action: none 을 걸고 끌기를 시작할 때 setPointerCapture 로 포인터를 붙잡아 두면 됩니다.

움직임을 줄여 달라는 사람에게는 무엇을 남겨야 하나요?

끌기나 스크롤 같은 조작에서 시작된 움직임은 끌 수 있어야 한다는 것이 WCAG 2.2 의 Animation from Interactions 기준입니다. 이 편은 움직임을 아예 없애는 대신 결과만 남기는 쪽을 골라서, 칩은 목표에 바로 붙고 카드는 가장 가까운 칸에 바로 서게 했습니다. 같은 방식의 정지 화면을 다른 편에서 보려면 SVG 그래프 그리기 9종의 접근성 절을 함께 보면 됩니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.