# AI 꾸러미 — 뜻을 연기하는 글자
> 「커진다」는 커지고 「빙글」은 돌고 「늘어난다」는 늘어남 — 단어마다 다른 움직임  
> 견본: https://ai-techstudio.web.app/#t/i91

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

## 주문서

### 만들어 줘: 뜻을 연기하는 글자

#### 1. 목표
수학 용어(확대에 「뜻을 연기하는 글자」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 「커진다」는 커지고 「빙글」은 돌고 「늘어난다」는 늘어남 — 단어마다 다른 움직임

#### 2. 핵심 기술 용어
- 뜻을 연기하는 글자 — 「커진다」는 커지고 「빙글」은 돌고 「늘어난다」는 늘어남 — 단어마다 다른 움직임

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

#### 4. 조건
- devicePixelRatio 를 맞춰 또렷하게
- 매 프레임 다시 그릴 필요 없는 그림은 화면 밖 캔버스에 미리 구워 두기
- 한 프레임 16ms 안 (폰 포함)

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「「커진다」는 커지고 「빙글」은 돌고 「늘어난다」는 늘어남 — 단어마다 다른 움직임」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 「커진다」는 커지고 「빙글」은 돌고 「늘어난다」는 늘어남 — 단어마다 다른 움직임


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「「커진다」는 커지고 「빙글」은 돌고 「늘어난다」는 늘어남 — 단어마다 다른 움직임」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i91` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 보통 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i91
- 쓰면 좋을 때: 쓰면 좋을 곳 — 수학 용어(확대 · 회전 · 대칭 · 이동) 소개

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i91 — `src/demos/demosMotionA.ts:251`
```ts
const i91 = {
  kind: '2d' as const,
  caption: '확대는 커지고, 회전은 90°씩 돌고, 대칭은 거울로 뒤집히고, 이동은 미끄러져요 — 단어가 뜻대로 움직여요',
  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;
        bg(g, w, h, '#20285e', '#0d1233');
        view(g, w, h);
        const tiles = [
          { x: 8, y: 8, c1: '#ff7aa8', c2: '#c2366c', name: '확대' },
          { x: 142, y: 8, c1: '#7ec8ff', c2: '#2f6fd0', name: '회전' },
          { x: 8, y: 90, c1: '#9be87d', c2: '#3b9a3a', name: '대칭' },
          { x: 142, y: 90, c1: '#ffcf5c', c2: '#d98a1a', name: '이동' },
        ];
        const TW = 130;
        const TH = 77;
        tiles.forEach((tl, i) => {
          const gr = g.createLinearGradient(tl.x, tl.y, tl.x, tl.y + TH);
          gr.addColorStop(0, tl.c1);
          gr.addColorStop(1, tl.c2);
          rr(g, tl.x, tl.y, TW, TH, 12);
          g.fillStyle = gr;
          g.fill();
          g.save();
          rr(g, tl.x, tl.y, TW, TH, 12);
          g.clip();
          const cx = tl.x + TW / 2;
          const cy = tl.y + TH / 2 - 2;
          const word = (sx: number, sy: number, a = 1, rot = 0, ox = 0): void => {
            g.save();
            g.globalAlpha = a;
            g.translate(cx + ox, cy);
            g.rotate(rot);
            g.scale(sx, sy);
            g.font = `400 26px ${TF}`;
            g.textAlign = 'center';
            g.textBaseline = 'middle';
            g.fillStyle = 'rgba(0,0,0,.25)';
            g.fillText(tl.name, 1.5, 2.5);
            g.fillStyle = '#fff';
            g.fillText(tl.name, 0, 0);
            g.restore();
          };
          if (i === 0) {
            // 확대: 0.7 → 1.5 숨쉬듯, 원래 크기 점선
            const k = inOut(0.5 - 0.5 * Math.cos(tt * 1.8));
            const s = lerp(0.7, 1.35, k);
            g.setLineDash([3, 3]);
            g.strokeStyle = 'rgba(255,255,255,.6)';
            g.lineWidth = 1;
            g.strokeRect(cx - 30, cy - 15, 60, 30);
            g.setLineDash([]);
            word(s, s);
            txt(g, `×${s.toFixed(1)}`, tl.x + TW - 8, tl.y + TH - 9, 9, '#fff', 'right');
          } else if (i === 1) {
            // 회전: 90°씩 딸깍
            const q = tt * 0.9;
            const n = Math.floor(q);
            const f = outBack(seg(q - n, 0, 0.45), 1.6);
            const ang = (n + f) * (Math.PI / 2);
            g.strokeStyle = 'rgba(255,255,255,.55)';
            g.lineWidth = 1.5;
            g.beginPath();
            g.arc(cx, cy, 33, -Math.PI / 2, -Math.PI / 2 + (mod(ang, TAU) || 0.001));
            g.stroke();
            word(1, 1, 1, ang);
            txt(g, `${Math.round(mod((n + Math.min(1, f)) * 90, 360))}°`, tl.x + TW - 8, tl.y + TH - 9, 9, '#fff', 'right');
          } else if (i === 2) {
            // 대칭: 축을 중심으로 거울 복사가 뒤집혀 넘어감
            const k = inOut(0.5 - 0.5 * Math.cos(tt * 1.5));
            g.setLineDash([4, 3]);
            g.strokeStyle = 'rgba(255,255,255,.85)';
            g.lineWidth = 1.2;
            g.beginPath();
            g.moveTo(cx, tl.y + 6);
            g.lineTo(cx, tl.y + TH - 6);
            g.stroke();
            g.setLineDash([]);
            const draw = (sx: number, a: number): void => {
              g.save();
              g.globalAlpha = a;
              g.translate(cx, cy);
              g.scale(sx, 1);
              g.font = `400 22px ${TF}`;
              g.textAlign = 'center';
              g.textBaseline = 'middle';
              g.fillStyle = '#fff';
              g.fillText(tl.name, -32, 0);
              g.restore();
            };
            draw(1, 1);
            draw(lerp(1, -1, k), 0.35 + 0.5 * k);
            txt(g, '거울', tl.x + TW - 8, tl.y + TH - 9, 9, '#fff', 'right');
          } else {
            // 이동: 미끄러지며 잔상
            const k = inOut(0.5 - 0.5 * Math.cos(tt * 1.4));
            const v = Math.sin(tt * 1.4);
            const ox = lerp(-30, 30, k);
            for (let j = 4; j >= 1; j--) word(1, 1, 0.12 * (1 - j / 5) * Math.abs(v) * 2, 0, ox - j * 6 * Math.sign(v));
            word(1, 1, 1, 0, ox);
            g.strokeStyle = 'rgba(255,255,255,.7)';
            g.lineWidth = 1;
            for (let j = 0; j <= 6; j++) {
              const xx = cx - 30 + j * 10;
              g.beginPath();
              g.moveTo(xx, tl.y + TH - 12);
              g.lineTo(xx, tl.y + TH - (j % 3 === 0 ? 18 : 15));
              g.stroke();
            }
            g.beginPath();
            g.moveTo(cx - 30, tl.y + TH - 12);
            g.lineTo(cx + 30, tl.y + TH - 12);
            g.stroke();
            g.fillStyle = '#fff';
            g.beginPath();
            g.arc(cx + ox, tl.y + TH - 12, 2.5, 0, TAU);
            g.fill();
            txt(g, `${ox >= 0 ? '+' : ''}${(ox / 10).toFixed(1)}칸`, tl.x + TW - 8, tl.y + 10, 9, '#fff', 'right');
          }
          g.restore();
        });
        g.restore();
      },
    };
  },
};
```

## 관련 기술
- 이 효과를 만든 기술: [글자 차례 등장 (키네틱 타이포)](https://ai-techstudio.web.app/ai/t/i90.md) `i90`
