GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

팽이 돌리기 9가지 — 각속도와 세차로 계산하는 회전 장면

돌리고, 기울고, 비틀거리고, 눕는다 — 팽이의 일생은 네 동사로 끝나고, 03번은 그 두 번째 동사를 숫자로 계산한다. 아래 아홉 개는 팽이 돌리기를 캔버스 2d 위에서 프레임마다 다시 계산해 그린 장면이며, 파일을 내려받으면 그대로 켜진다.

팽이 아홉 장면을 화면에서 바로 돌려 본다

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

팽이 돌리기 9가지 — 각속도 하나가 기울기와 흔들림을 함께 정한다

아홉 장면의 뿌리는 omega(각속도) 변수 하나다. 01 은 이 값을 줄무늬 각도에 더하고 매 프레임 깎으며, 03 은 값이 작아질수록 기울기 진폭을 키우고, 02 는 같은 값을 나눗수로 써서 축이 도는 속도를 정한다. 04 는 바닥 위 가속의 크기를, 05 는 부딪칠 때 서로 맞바꾸는 양을 이 값에서 얻는다.

06 은 포인터가 이 값에 더하는 토크를 계산하고, 07 은 이 값으로 줄무늬가 번지는 시점을 정하며, 08 은 이 값이 문턱 아래로 떨어질 때 눕는다고 판정한다. 09 가 이 모두를 한 판에 모은 장면이다. 팽이 돌리기 게임을 만든다면 05 의 충돌과 08 의 판정이 규칙의 재료가 되고, 나머지 장면은 그 규칙을 살아 있게 보이는 계산이다.

이 편은 줄을 감아 던지는 줄 팽이 돌리기의 손맛을 06 의 쓸어 넘기기와 Space 충전으로 옮기는 데까지만 간다. 그 위에 얹은 바닥 원근 격자·조명·회전 잔상 원판은 보기 좋게 덧댄 표현 층이고, 계산의 핵심은 각속도 감쇠·세차·바닥 마찰·충돌·넘어짐 판정 다섯 가지다. 비슷한 물리 장면이 궁금하면 바운싱 볼 애니메이션 9가지 — 충돌·중력 물리 계산과 룰렛 돌리기 — 튕겨 돌리고 마찰로 멈추는 돌림판 캔버스 9종을 같이 보면 된다.

기법별 코드로 보는 팽이 회전 계산

아래 코드는 각 데모 index.html 의 연속 구간을 줄 순서 그대로 옮긴 것이다. 공통 그리기 함수보다 장면마다 다른 물리 계산이 있는 줄을 골랐고, 전체 파일은 압축 파일 안에 있다.

01마찰로 각속도가 줄어드는 기본 팽이

decayAngularSpeed() 가 프레임마다 omega 에 exp(-ANGULAR_DECAY × dt) 를 곱하고, 줄어든 값을 줄무늬 각도에 더한다. 느려질수록 slow 비율이 커져 기울기가 0.06 에서 slow 의 제곱에 비례해 커지고, omega 가 4 아래로 내려가면 시작값 38 로 되감겨 장면이 반복된다. 이 장면은 누적면적 23.0%·평균 강도 76.2·23/23 프레임이다.

마찰로 각속도가 줄어드는 기본 팽이
새 창에서 열기

01 · 마찰로 각속도가 줄어드는 기본 팽이 — 측정 누적면적 22.972% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ANGULAR_DECAYdecayAngularSpeeddrawOmegaGauge
  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();
  }

02기울어진 축이 원을 그리며 도는 세차 팽이

PRECESSION_GAIN 은 기울기의 크기를 정하고(tilt = TILT_PER_GAIN × PRECESSION_GAIN), 축이 도는 속도는 별도 상수 PRECESSION_K 에 sin(tilt) 를 곱해 omega 로 나눈 값이다. 분모가 작아질수록 축이 빨리 돌아 느려지는 팽이가 더 휘청이고, 분모는 max(omega, 8) 로 막아 둔다. 이 장면은 누적면적 23.1%·평균 강도 64.6·23/23 프레임이다.

기울어진 축이 원을 그리며 도는 세차 팽이
새 창에서 열기

02 · 기울어진 축이 원을 그리며 도는 세차 팽이 — 측정 누적면적 23.096% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PRECESSION_GAINprecessAxisTILT_PER_GAIN
  function precessAxis(t) {
    var rate = PRECESSION_K * Math.sin(t.tilt) / Math.max(t.omega, 8);
    t.az += rate * dtS;
  }

  function stepWorld() {
    spinTop.omega *= Math.exp(-AXIS_DECAY * dtS);
    spinTop.spin += spinTop.omega * dtS;
    var slow = 1 - clamp(spinTop.omega / OMEGA_FULL, 0, 1);
    spinTop.tilt = TILT_PER_GAIN * PRECESSION_GAIN * (1 + 0.5 * slow);
    precessAxis(spinTop);
    pushTrail(tipTrail, spinTop.tipX, spinTop.tipY, 110);
    if (spinTop.omega < 12) {
      spinTop.omega = OMEGA_FULL;
      addPulse(spinTop.x, spinTop.y + 4, '201,184,255');
    }
    stepPulses();
  }

03느려질수록 기울기 진폭이 커지는 팽이

tiltAmplitude() 는 slow 의 1.6 제곱에 TILT_AMP_MAX(0.88)를 곱해 진폭을 만들고, 기울기는 이 진폭에 사인파를 얹어 좌우로 흔든다. 흔들리는 박자도 slow 가 클수록 빨라지며, 진폭의 폭은 drawAmplitudeFan() 이 부채꼴로 보여 준다. 이 장면은 누적면적 20.2%·평균 강도 68.2·23/23 프레임이다.

느려질수록 기울기 진폭이 커지는 팽이
새 창에서 열기

03 · 느려질수록 기울기 진폭이 커지는 팽이 — 측정 누적면적 20.204% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TILT_AMP_MAXtiltAmplitudedrawAmplitudeFan
  function tiltAmplitude(omega) {
    var slow = 1 - clamp(omega / OMEGA_TOP, 0, 1);
    return TILT_AMP_MAX * Math.pow(slow, 1.6);
  }

  function stepWorld() {
    spinTop.omega *= Math.exp(-OMEGA_FADE * dtS);
    spinTop.spin += spinTop.omega * dtS;
    currentAmp = tiltAmplitude(spinTop.omega);
    var slow = 1 - clamp(spinTop.omega / OMEGA_TOP, 0, 1);
    wobblePhase += (5 + 9 * slow) * dtS;
    spinTop.tilt = 0.05 + currentAmp * (0.55 + 0.45 * Math.sin(wobblePhase));
    spinTop.az += (3 + slow * 5) * dtS;
    if (spinTop.omega < 7) {
      spinTop.omega = OMEGA_TOP;
      addPulse(spinTop.x, spinTop.y + 4, '159,216,255');
    }
    stepPulses();
  }

04바닥에 마찰 궤적을 남기며 떠도는 팽이

omega 에 비례하는 가속을 heading 방향으로 더한 뒤 FLOOR_FRICTION 을 프레임마다 속도에 곱하고, 그 속도로 접점 위치를 옮긴다. 접점은 32ms 마다 한 점씩 궤적에 쌓아 최대 420점까지 선으로 남기고, 벽에 닿으면 wallBounce() 가 되튕긴다. 이 장면은 누적면적 25.7%·평균 강도 60.8·23/23 프레임이다.

바닥에 마찰 궤적을 남기며 떠도는 팽이
새 창에서 열기

04 · 바닥에 마찰 궤적을 남기며 떠도는 팽이 — 측정 누적면적 25.688% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FLOOR_FRICTIONDRIFT_ACCELfloorTrace
  function stepWorld() {
    spinTop.omega *= Math.exp(-SPIN_FADE * dtS);
    spinTop.spin += spinTop.omega * dtS;
    heading += HEADING_RATE * dtS;
    var drive = DRIFT_ACCEL * (spinTop.omega / OMEGA_BASE) * dtS;
    spinTop.vx += Math.cos(heading) * drive;
    spinTop.vy += Math.sin(heading) * drive * 0.7;
    spinTop.vx *= FLOOR_FRICTION;
    spinTop.vy *= FLOOR_FRICTION;
    spinTop.x += spinTop.vx * dtS;
    spinTop.y += spinTop.vy * dtS;
    wallBounce(spinTop, 0.6, 6);
    var slow = 1 - clamp(spinTop.omega / OMEGA_BASE, 0, 1);
    spinTop.tilt = 0.1 + 0.4 * slow * slow + 0.1 * Math.min(1, Math.hypot(spinTop.vx, spinTop.vy) / 120);
    spinTop.az = heading;
    if (elapsed % 32 === 0) pushTrail(floorTrace, spinTop.x, spinTop.y + 3, 420);
    if (spinTop.omega < 9) {
      spinTop.omega = OMEGA_BASE;
      spinTop.vx += 50; spinTop.vy -= 30;
      addPulse(spinTop.x, spinTop.y + 4, '168,230,207');
    }
    stepPulses();
  }

05두 팽이가 부딪쳐 튕겨 나가는 충돌

pairCollide() 는 세로 거리를 1.6배로 늘린 원근 보정 거리가 반지름 합의 0.92배보다 가까워지면 부딪쳤다고 판정한다. 부딪치면 법선 방향으로 튕겨 내면서 각속도 차이의 30% 를 한쪽이 얻고 다른 쪽이 그만큼 잃어, 두 팽이의 각속도 합은 그대로 보존된다. 이 장면은 누적면적 40.6%·평균 강도 72.1·23/23 프레임이며 아홉 장면 가운데 가장 넓게 움직인다.

두 팽이가 부딪쳐 튕겨 나가는 충돌
새 창에서 열기

05 · 두 팽이가 부딪쳐 튕겨 나가는 충돌 — 측정 누적면적 40.629% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

COLLISION_RESTITUTIONresolveTopPairexchangeSpin
  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;
  }

06포인터를 쓸어 넘기면 회전이 붙는 팽이

impartSpin() 은 팽이 중심에서 포인터까지의 벡터와 포인터 이동 벡터의 외적을 토크로 삼아 SPIN_PER_PX 를 곱해 각속도에 더하므로, 쓰는 방향이 바뀌면 회전도 반대로 돈다. 손대지 않으면 가상 쓸기가 네 번을 돌며 같은 계산을 대신 보여 주고, Space 를 누르고 있으면 현재 방향으로 충전된다. 이 장면은 누적면적 15.6%·평균 강도 73.1·23/23 프레임이다.

포인터를 쓸어 넘기면 회전이 붙는 팽이
새 창에서 열기

06 · 포인터를 쓸어 넘기면 회전이 붙는 팽이 — 측정 누적면적 15.637% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SPIN_PER_PXimpartSpinvirtualSwipe
  function impartSpin(px, py, dx, dy) {
    var cx = spinTop.x, cy = spinTop.y - 34;
    var rx = px - cx, ry = (py - cy) * 1.4;
    if (Math.hypot(rx, ry) > spinTop.R * 1.7) return;
    var torque = rx * dy - ry * dx;
    spinTop.omega = clamp(spinTop.omega + torque * SPIN_PER_PX, -OMEGA_MAX, OMEGA_MAX);
    spinTop.kick = Math.min(0.6, spinTop.kick + Math.abs(torque) * 0.004);
  }

07빠르면 줄무늬가 번져 보이는 잔상 팽이

초당 지나가는 줄무늬 수 STRIPE_COUNT × omega / 6.28 이 STRIPE_FUSE_HZ 를 넘기면 줄무늬 위에 평균색 원판을 덧칠하고, 각속도가 8 을 넘으면 지난 각도의 줄무늬를 세 겹 옅게 겹쳐 잔상을 만든다. 팽이는 가속과 감속을 반복하므로 줄무늬가 번졌다 다시 또렷해진다. 이 장면은 누적면적 20.4%·평균 강도 60.4·23/23 프레임이다.

빠르면 줄무늬가 번져 보이는 잔상 팽이
새 창에서 열기

07 · 빠르면 줄무늬가 번져 보이는 잔상 팽이 — 측정 누적면적 20.401% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STRIPE_COUNTSTRIPE_FUSE_HZrevUp
    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;
    }

08기울기가 한계를 넘으면 눕는 넘어짐 판정

judgeTumble() 은 각속도가 FALL_OMEGA 아래이면서 기울기가 TILT_LIMIT 을 넘는 프레임에 넘어짐을 선언하고, 그 뒤로는 중력 비슷한 가속으로 기울기를 키워 눕힌다. 한계 각도에서 처음 닿을 때는 속도의 28% 만큼 한 번 튕기고, 1.1초 쉬었다가 다시 일어나 반복한다. 이 장면은 누적면적 22.4%·평균 강도 68.4·23/23 프레임이다.

기울기가 한계를 넘으면 눕는 넘어짐 판정
새 창에서 열기

08 · 기울기가 한계를 넘으면 눕는 넘어짐 판정 — 측정 누적면적 22.438% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

TILT_LIMITFALL_OMEGAjudgeTumble
  function judgeTumble(t) {
    var slow = 1 - clamp(t.omega / OMEGA_START, 0, 1);
    t.tilt = 0.08 + 0.95 * Math.pow(slow, 2.2);
    return t.omega < FALL_OMEGA && t.tilt > TILT_LIMIT;
  }

  function stepTop(t) {
    if (t.state === 'spin') {
      t.omega *= Math.exp(-OMEGA_FADE * dtS);
      t.spin += t.omega * dtS;
      t.az += (3 + (1 - t.omega / OMEGA_START) * 4) * dtS;
      if (judgeTumble(t)) { t.state = 'fall'; t.tv = 0.5; t.az = 0.35 + (t.x < 160 ? 0 : 2.4); }
    } else if (t.state === 'fall') {
      t.spin += t.omega * dtS;
      t.tv += 18 * Math.sin(t.tilt) * dtS;
      t.tilt += t.tv * dtS;
      if (t.tilt >= 1.5) {
        t.tilt = 1.5;
        if (t.tv > 0.9) { t.tv = -t.tv * 0.28; addPulse(t.x, t.y + 4, '255,255,255'); }
        else { t.tv = 0; t.state = 'down'; t.rest = 0; }
      }

09각속도·세차·충돌을 한 판에 모은 히어로 경기장

heroStep() 은 팽이 세 개마다 감쇠·세차·중앙으로 당기는 힘·바닥 마찰·벽 반동을 계산하고, 모든 쌍에 pairCollide() 를 한 번씩 부른다. 01 부터 05 까지의 계산을 한 화면에 합친 조합이라 가운데 팽이 하나가 아니라 경기장 전체가 움직인다. 이 장면은 누적면적 28.9%·평균 강도 64.0·23/23 프레임이다.

각속도·세차·충돌을 한 판에 모은 히어로 경기장
새 창에서 열기

09 · 각속도·세차·충돌을 한 판에 모은 히어로 경기장 — 측정 누적면적 28.945% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

HERO_PRECESSION_GAINHERO_RESTITUTIONheroStep
  function heroStep() {
    var i, j, t;
    for (i = 0; i < heroTops.length; i += 1) {
      t = heroTops[i];
      t.omega *= Math.exp(-HERO_DECAY * dtS);
      t.spin += t.omega * dtS;
      var slow = 1 - clamp(Math.abs(t.omega) / HERO_OMEGA_START, 0, 1);
      t.kick = Math.max(0, t.kick - dtS * 0.9);
      t.tilt = HERO_PRECESSION_GAIN * 0.3 * (1 + slow) + t.kick * 0.5;
      t.az += 150 * Math.sin(t.tilt) / Math.max(Math.abs(t.omega), 8) * dtS;
      var cxs = 160 - t.x, cys = 120 - t.y, d = Math.hypot(cxs, cys) || 1;
      t.vx += (cxs / d * 14 + Math.cos(t.az) * HERO_DRIFT * 0.4) * dtS;
      t.vy += (cys / d * 14 + Math.sin(t.az) * HERO_DRIFT * 0.3) * dtS;
      t.vx *= HERO_FRICTION; t.vy *= HERO_FRICTION;
      t.x += t.vx * dtS; t.y += t.vy * dtS;
      wallBounce(t, 0.85, 6);
      if (elapsed % 48 === 0) pushTrail(heroTrails[i], t.x, t.y + 3, 70);
      if (Math.abs(t.omega) < 9) {
        t.omega = (i === 1 ? -1 : 1) * HERO_OMEGA_START;
        addPulse(t.x, t.y + 4, '255,241,201');
      }
    }
    for (i = 0; i < heroTops.length; i += 1) {
      for (j = i + 1; j < heroTops.length; j += 1) pairCollide(heroTops[i], heroTops[j], HERO_RESTITUTION, 0.3, 0.5);
    }

값이 장면을 바꾸는 곳 — 각속도·마찰·세차 계수

장면의 인상을 크게 바꾸는 값은 다섯 개로 좁혀진다. 01 의 ANGULAR_DECAY 는 팽이가 멈추기까지 걸리는 시간을, 02 의 PRECESSION_GAIN 은 축 끝이 그리는 원의 크기를, 04 의 FLOOR_FRICTION 은 바닥에서 미끄러지는 거리를 정한다. 07 의 STRIPE_COUNT 는 줄무늬가 번지기 시작하는 각속도를, 05 의 SPIN_MIX 는 부딪친 뒤 느린 쪽이 얼마나 되살아나는지를 바꾼다.

아홉 장면을 측정한 값은 아래 표와 같다. 05 는 팽이 두 개가 서로 밀며 움직이고 부딪칠 때마다 충격 파동이 퍼져 면적이 가장 넓고, 06 은 팽이 하나와 포인터 쓸기 한 줄뿐이라 가장 좁다.

항목 누적면적 평균 강도 움직인 프레임
01 각속도 감쇠 23.0% 76.2 23/23
02 세차 23.1% 64.6 23/23
03 기울기 진폭 20.2% 68.2 23/23
04 바닥 마찰 궤적 25.7% 60.8 23/23
05 두 팽이 충돌 40.6% 72.1 23/23
06 포인터 스핀 15.6% 73.1 23/23
07 잔상 줄무늬 20.4% 60.4 23/23
08 넘어짐 판정 22.4% 68.4 23/23
09 히어로 경기장 28.9% 64.0 23/23

어디서 깨지는가 — 멈추기 직전·좁은 화면·충돌이 겹칠 때

멈추기 직전이 가장 위험하다. 02 의 축 회전 속도는 각속도로 나누는 식이라 값이 0 에 가까우면 폭주하므로 분모를 8 아래로 내리지 않게 막아 두었고, 01·02·03 은 각속도가 4·12·7 아래로 떨어지면 시작값으로 되감아 장면이 멈춘 채로 남지 않게 한다. 그 되감기 문턱을 지우면 팽이는 점점 느려지다 제자리에서 굳는다.

좁은 화면에서는 계산 해상도 CALC_W 320 · CALC_H 200 이 그대로이고 340px 이하에서 캡션 글자만 줄어든다. 충돌이 겹칠 때는 09 가 세 팽이의 쌍을 순서대로 하나씩 풀기 때문에 세 개가 한꺼번에 포개지면 앞 쌍을 풀며 뒤 쌍이 다시 겹칠 수 있고, 이미 멀어지는 쌍은 vn >= 0 검사로 건너뛰어 같은 충돌을 두 번 받지 않는다. 압축 파일을 풀어 값을 직접 바꿔 보려면 비밀번호 tqsqe3wp 을 입력하면 된다.

변형 세 가지와 접근성

이름 값 느낌
세차 계수 조정 02 PRECESSION_GAIN 0.6 → 1.4 값을 줄이면 축 끝이 그리는 원이 작아져 팽이가 거의 곧게 서서 도는 것처럼 보이고, 값을 늘리면 축이 바닥 위에서 큰 원을 그려 몸통이 크게 휘청이는 장면이 된다.
바닥 마찰 비율 조정 04 FLOOR_FRICTION 0.992 → 0.97 값을 줄이면 프레임마다 속도가 더 많이 깎여 팽이가 몇 걸음 못 가고 제자리에서 멈추고, 값을 늘리면 오래 미끄러지며 바닥에 긴 소용돌이 궤적을 남긴다.
잔상 줄무늬 개수 조정 07 STRIPE_COUNT 6 → 12 값을 늘리면 줄무늬가 촘촘해져 같은 각속도에서도 색 띠로 번지는 시점이 빨라지고, 값을 줄이면 굵은 줄무늬가 느려질 때까지 또렷하게 남는다.

prefers-reduced-motion 이 켜져 있으면 아홉 장면 모두 requestAnimationFrame 루프를 시작하지 않고, 준비 계산을 장면마다 20~60걸음 돌린 정지 화면 한 장만 남긴다. 입력을 받는 것은 06 하나뿐이어서 Space 홀드와 좌우 화살표로 회전을 줄 수 있고, 동작 줄임 상태에서도 입력 한 번에 화면을 다시 그린다. 나머지 여덟 장면은 입력 없이 시간만으로 움직이니 키보드 조작이 필요 없다.

코드에서 팽이의 기울기는 무엇으로 계산하는가?

기울기는 각속도가 시작값에서 얼마나 멀어졌는지 나타내는 slow = 1 - omega / 시작값 한 변수에서 나온다. 01·03·08 은 이 slow 를 제곱 꼴로 키워 기울기로 바꾸고, 02 는 같은 비율에 이득 계수를 곱하며, 04·07 은 작은 보정 항만 더한다. 빠를 때는 곧고 느릴수록 기울며 눕는 이 한 방향 규칙이 아홉 장면이 같은 팽이처럼 보이는 이유다. 같은 캔버스 방식으로 픽셀까지 다루는 장면은 html5 canvas 애니메이션 9종 — 물방울·블랙홀 특수효과에, 힘과 스프링 쪽은 물리 시뮬레이션 만들기 9종 — 스프링부터 관성 스크럽까지에 있다. 그리기 API 의 근거는 Canvas API 문서와 requestAnimationFrame 문서에서 읽을 수 있다.

팽이 장면을 쓰기 전에 자주 묻는 질문

팽이 애니메이션을 게임으로 만들려면 무엇을 더해야 하나?

05 의 충돌과 08 의 넘어짐 판정까지는 이미 들어 있고, 점수나 승패를 정하는 규칙은 직접 더해야 한다. 09 의 경기장에는 승자를 가리는 코드가 없고, 속도가 9 아래로 떨어진 팽이는 쓰러지는 대신 시작 속도로 다시 돈다. 승패를 넣으려면 heroStep() 의 그 재시작 분기 대신 08 의 judgeTumble() 판정을 불러 쓰러진 팽이를 세는 것이 가장 짧은 길이다.

외부 라이브러리나 이미지가 필요한가?

필요 없다. 아홉 장면 모두 캔버스 2d 와 한 파일의 스크립트만으로 돌아가고 외부 주소를 부르는 줄이 하나도 없어서, 복사해 붙이면 바로 동작한다.

팽이가 너무 빨리 멈추거나 끝없이 돌면 어디를 고치나?

01 은 ANGULAR_DECAY, 04 는 FLOOR_FRICTION, 08 은 OMEGA_FADE 가 멈추는 속도를 정한다. ANGULAR_DECAY 와 OMEGA_FADE 는 값을 키우면 빨리 멈추고 줄이면 오래 돈다. 반대로 FLOOR_FRICTION 은 프레임마다 곱하는 속도 비율이라 1 에 가까울수록 오래 미끄러지고 작을수록 빨리 선다.

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

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