# AI 꾸러미 — 숫자 굴러가기 (계기판) — Odometer / rolling counter
> 자릿수마다 숫자 띠를 세로로 굴려 값을 바꾸고, 아래 자리가 9 → 0 으로 넘어갈 때만 윗자리가 함께 돌아 받아올림이 눈에 보이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i94

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 숫자 굴러가기 (계기판) — Odometer / rolling counter

#### 1. 목표
점수 · 합계 표시를 자릿수마다 세로 띠가 굴러가는 계기판 숫자로 보여 줘 — 999 → 1000 처럼 받아올림이 줄줄이 돌아가게. 분위기는 보라 · 금빛 계기판.

#### 2. 핵심 기술 용어
- **Odometer / rolling counter** — 자동차 거리계처럼 굴러가는 숫자
- **Digit reel (slot reel)** — 자릿수마다 0~9 가 적힌 세로 띠
- **Carry propagation** — 받아올림이 윗자리로 번지기

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표

#### 4. 조건
- 값은 실수로 두고 트윈(easeOutBack)으로 다음 정수까지 굴린다 — 정수만 쓰면 띠가 움직이지 않는다
- 윗자리는 아래 자리가 모두 9 일 때만 움직이게 (위 식 그대로)
- 칸 밖은 clip 으로 자르고 위아래에 어두운 그러데이션 — 둥근 북 느낌
- 움직이는 자리는 테두리로 강조 (받아올림은 다른 색)

#### 5. 완성 기준 (이게 보이면 성공)
- +1 할 때마다 일의 자리 띠가 한 칸 굴러 넘어간다
- 999 → 1000 에서 일 · 십 · 백 · 천의 자리가 한꺼번에 돌고 「받아올림 3번!」 이 보인다
- 되감기 때는 모든 자리가 거꾸로 굴러 처음 값으로 돌아간다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 값 v 를 실수로 두고, 자리 k(1 · 10 · 100 …)마다 「지금 보일 숫자」 cv 를 계산한다.
- 일의 자리: cv = v mod 10 — 소수 부분만큼 띠가 위로 밀린다.
- 윗자리: cv = ⌊v / 10^k⌋ mod 10 + clamp(v mod 10^k − (10^k − 1), 0, 1). 아래 자리가 모두 9 에서 넘어가는 마지막 1 동안만 함께 돈다.
- 띠에는 지금 숫자의 앞뒤(−1 ~ +2)만 그리고, 칸 밖은 clip 으로 자르며 위아래를 어둡게 덮어 둥근 북처럼 보이게 한다.

## 핵심 코드 — 자리마다 보일 숫자(cv) 계산 + 띠 그리기
(발췌: demos/demosMotionA.ts i94 의 draw 를 정리)
```ts
const mod = (a: number, b: number) => ((a % b) + b) % b;
const clamp01 = (x: number) => Math.max(0, Math.min(1, x));
// v = 992 + n + outBack((s - n) / 0.7, 1.2) 처럼 다음 정수로 굴러가는 실수 값
for (let col = 0; col < 4; col++) {
  const k = 3 - col;                 // 천 · 백 · 십 · 일
  const pw = Math.pow(10, k);
  const cv = k === 0
    ? mod(v, 10)
    : mod(Math.floor(v / pw), 10) + clamp01(mod(v, pw) - (pw - 1)); // 아래가 모두 9 일 때만 돈다
  const x = x0 + col * (CW + 8);
  g.save();
  g.beginPath(); g.roundRect(x, y0, CW, CH, 6); g.clip(); // 칸 밖은 자른다
  g.fillStyle = '#ffffff'; g.fillRect(x, y0, CW, CH);
  const base = Math.floor(cv);
  for (let d = base - 1; d <= base + 2; d++) {             // 앞뒤 숫자만
    const y = y0 + CH / 2 + (d - cv) * CH * 0.85;
    g.fillStyle = '#2a2350';
    g.fillText(String(mod(d, 10)), x + CW / 2, y);
  }
  const sh = g.createLinearGradient(0, y0, 0, y0 + CH);  // 둥근 북처럼 위아래 어둡게
  sh.addColorStop(0, 'rgba(30,20,70,.75)'); sh.addColorStop(0.28, 'rgba(30,20,70,0)');
  sh.addColorStop(0.72, 'rgba(30,20,70,0)'); sh.addColorStop(1, 'rgba(30,20,70,.75)');
  g.fillStyle = sh; g.fillRect(x, y0, CW, CH);
  g.restore();
}
```

## 흔한 실수 · 확인 목록
- [ ] **윗자리를 그냥 v / 10^k 로 굴리면 늘 조금씩 움직인다** — 윗자리는 정수 부분 + 「아래 자리가 9 에서 넘어가는 마지막 1」 만큼만 움직여야 진짜 거리계처럼 보인다.
- [ ] **0 ~ 9 전부를 그리면 낭비이고 겹친다** — 지금 숫자 −1 ~ +2 만 그리고 clip 으로 자른다.
- [ ] **mod 를 % 로 쓰면 되감기(음수)에서 숫자가 깨진다** — ((a % b) + b) % b 로 늘 0 ~ 9.

## 완성 기준 체크리스트
- [ ] +1 할 때마다 일의 자리 띠가 한 칸 굴러 넘어간다
- [ ] 999 → 1000 에서 일 · 십 · 백 · 천의 자리가 한꺼번에 돌고 「받아올림 3번!」 이 보인다
- [ ] 되감기 때는 모든 자리가 거꾸로 굴러 처음 값으로 돌아간다

## 이 기술 정보
- id: `i94` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 자리마다 숫자 4개 + 그러데이션 두 장. 아주 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i94
- 쓰면 좋을 때: 점수 · 돈이 바뀌는 순간을 눈에 띄게 / 자릿값 · 받아올림 원리를 보여 줄 때
- 쓰지 말 때: 값이 한 번에 수천씩 뛰는 경우 — 일의 자리가 너무 빨라 흐려진다. 대신 마지막 몇 자리만 굴리거나 숫자 튀기기 / 작은 표 안의 많은 숫자 — 대신 바로 바꾸기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i94 — `src/demos/demosMotionA.ts:521`
```ts
const i94 = {
  kind: '2d' as const,
  caption: '+1 씩 더하면 일의 자리 띠가 돌고, 999 → 1000 에서 받아올림이 줄줄이 돌아가요',
  make() {
    const o = { s: 1 };
    let tt = 0;
    return {
      controls: [speedCtl(o)],
      draw(g: G, w: number, h: number, _t: number, dt: number) {
        tt += Math.max(0, dt) * o.s;
        const T = 7.4;
        const p = tt % T;
        const STEP = 0.36;
        const N = 16;
        let v: number;
        let back = false;
        if (p < N * STEP) {
          const s = p / STEP;
          const n = Math.floor(s);
          v = 992 + n + outBack(clamp01((s - n) / 0.7), 1.2);
        } else {
          back = true;
          v = lerp(992 + N, 992, inOut(seg(p, N * STEP + 0.5, T - 0.15)));
        }
        bg(g, w, h, '#2a2350', '#100c26');
        view(g, w, h);
        txt(g, back ? '되감기 ◀◀' : '+1 씩 더하기', VW / 2, 18, 11, back ? '#ffb4d0' : '#ffe27a');
        const CW = 40;
        const CH = 58;
        const gapX = 8;
        const x0 = VW / 2 - (CW * 4 + gapX * 3) / 2;
        const y0 = 40;
        // 판
        rr(g, x0 - 10, y0 - 10, CW * 4 + gapX * 3 + 20, CH + 20, 12);
        g.fillStyle = '#3b2f6e';
        g.fill();
        g.strokeStyle = '#ffd166';
        g.lineWidth = 2;
        g.stroke();
        const names = ['천', '백', '십', '일'];
        let rolling = 0;
        for (let col = 0; col < 4; col++) {
          const k = 3 - col;
          const pw = Math.pow(10, k);
          let cv: number;
          if (k === 0) cv = mod(v, 10);
          else cv = mod(Math.floor(v / pw), 10) + clamp01(mod(v, pw) - (pw - 1));
          const fr = cv - Math.floor(cv);
          const moving = fr > 0.02 && fr < 0.98;
          if (moving && k > 0) rolling++;
          const x = x0 + col * (CW + gapX);
          g.save();
          rr(g, x, y0, CW, CH, 6);
          g.clip();
          const gr = g.createLinearGradient(0, y0, 0, y0 + CH);
          gr.addColorStop(0, '#d9dcef');
          gr.addColorStop(0.5, '#ffffff');
          gr.addColorStop(1, '#d9dcef');
          g.fillStyle = gr;
          g.fillRect(x, y0, CW, CH);
          const base = Math.floor(cv);
          for (let d = base - 1; d <= base + 2; d++) {
            const y = y0 + CH / 2 + (d - cv) * CH * 0.85;
            txt(g, String(mod(d, 10)), x + CW / 2, y, 36, col === 0 ? '#e0446a' : '#2a2350', 'center', TF, 400);
          }
          const sh = g.createLinearGradient(0, y0, 0, y0 + CH);
          sh.addColorStop(0, 'rgba(30,20,70,.75)');
          sh.addColorStop(0.28, 'rgba(30,20,70,0)');
          sh.addColorStop(0.72, 'rgba(30,20,70,0)');
          sh.addColorStop(1, 'rgba(30,20,70,.75)');
          g.fillStyle = sh;
          g.fillRect(x, y0, CW, CH);
          g.restore();
          if (moving) {
            rr(g, x - 2, y0 - 2, CW + 4, CH + 4, 7);
            g.strokeStyle = k > 0 ? '#ff7aa8' : '#7ee0ff';
            g.lineWidth = 2;
            g.stroke();
          }
          txt(g, names[col]!, x + CW / 2, y0 + CH + 20, 10, 'rgba(255,255,255,.75)', 'center', F, 700);
        }
        if (!back && rolling >= 2) {
          g.save();
          g.translate(VW / 2, 154);
          pill(g, `받아올림 ${rolling}번!`, 0, 0, 11, '#ff5c8a');
          g.restore();
        } else {
          txt(g, `${Math.round(back ? v : Math.floor(v + 0.001))}`, VW / 2, 154, 11, 'rgba(255,255,255,.55)', 'center', F, 700);
        }
        g.restore();
      },
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [암호 풀리는 글자 (scramble)](https://ai-techstudio.web.app/ai/t/i93.md) `i93`
- 다음에 해 볼 기술: [튀어 오르는 숫자](https://ai-techstudio.web.app/ai/t/i202.md) `i202` · [격자 물결 (stagger grid)](https://ai-techstudio.web.app/ai/t/i101.md) `i101`
