# AI 꾸러미 — 타자기 + 깜박이는 커서
> 글자가 쳐지고 커서가 깜박임  
> 견본: https://ai-techstudio.web.app/#t/i95

이 문서를 코드 도우미(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: `i95` · 분류: 이펙트 · 연출 › 모션 그래픽 · 2D · 난이도 쉬움 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i95
- 쓰면 좋을 때: 쓰면 좋을 곳 — 선배 대사 · 문제 지문

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i95 — `src/demos/demosMotionA.ts:617`
```ts
const i95 = {
  kind: '2d' as const,
  caption: '선배 대사가 한 글자씩 쳐지고, 쉼표 · 마침표에서 잠깐 멈춰요 — 쉬는 동안 커서가 깜박',
  make() {
    const o = { s: 1 };
    let tt = 0;
    const text = '자료를 그냥 믿지 마. 두 곳을 맞대 보면, 숫자가 거짓말하는 곳이 보일 거야.';
    const chars = [...text];
    const at: number[] = [];
    let acc = 0.4;
    chars.forEach((c, i) => {
      at.push(acc);
      acc += 0.055 + hsh(i) * 0.03;
      if (c === '.' || c === ',') acc += 0.38;
    });
    const typedEnd = acc;
    const holdEnd = typedEnd + 1.6;
    const eraseEnd = holdEnd + chars.length * 0.012;
    const T = eraseEnd + 0.5;
    return {
      controls: [speedCtl(o)],
      draw(g: G, w: number, h: number, _t: number, dt: number) {
        tt += Math.max(0, dt) * o.s;
        const p = tt % T;
        bg(g, w, h, '#33406f', '#151b38');
        view(g, w, h);
        // 선배 얼굴
        const ax = 40;
        const ay = 78;
        const talking = p < typedEnd && p > 0.4;
        g.fillStyle = '#ffd9b8';
        g.beginPath();
        g.arc(ax, ay, 22, 0, TAU);
        g.fill();
        g.fillStyle = '#2b2440';
        g.beginPath();
        g.arc(ax, ay - 6, 23, Math.PI * 1.05, Math.PI * 1.95);
        g.fill();
        g.strokeStyle = '#2b2440';
        g.lineWidth = 1.6;
        g.strokeRect(ax - 15, ay - 2, 11, 8);
        g.strokeRect(ax + 4, ay - 2, 11, 8);
        g.beginPath();
        g.moveTo(ax - 4, ay + 1);
        g.lineTo(ax + 4, ay + 1);
        g.stroke();
        g.fillStyle = '#2b2440';
        g.fillRect(ax - 11, ay + 1, 3, 3);
        g.fillRect(ax + 8, ay + 1, 3, 3);
        const mo = talking ? 1.5 + 2.5 * Math.abs(Math.sin(p * 22)) : 1;
        g.fillStyle = '#c24a5a';
        g.beginPath();
        g.ellipse(ax, ay + 13, 4, mo, 0, 0, TAU);
        g.fill();
        pill(g, '선배 윤정확', ax, ay + 36, 8, '#ffd166', '#2b2440');
        // 말풍선
        const bx = 78;
        const by = 38;
        const bw = 192;
        const bh = 92;
        rr(g, bx, by, bw, bh, 12);
        g.fillStyle = '#fffaf0';
        g.fill();
        g.beginPath();
        g.moveTo(bx, ay - 6);
        g.lineTo(bx - 10, ay);
        g.lineTo(bx, ay + 6);
        g.fill();
        // 몇 글자 보이나
        let n = 0;
        if (p < holdEnd) {
          while (n < chars.length && at[n]! <= p) n++;
        } else {
          n = Math.max(0, chars.length - Math.floor((p - holdEnd) / 0.012));
          if (p >= eraseEnd) n = 0;
        }
        g.font = `700 13px ${F}`;
        g.textBaseline = 'middle';
        g.textAlign = 'left';
        const maxW = bw - 24;
        let x = bx + 12;
        let y = by + 18;
        const LH = 21;
        // 낱말 단위 줄바꿈 (전체 문장 기준으로 줄을 미리 정해 두어 쳐지는 동안 글자가 튀지 않게)
        const pos: [number, number][] = [];
        const words = text.split(' ');
        words.forEach((wd, wi) => {
          const ww = g.measureText(wd).width;
          if (x + ww > bx + 12 + maxW) {
            x = bx + 12;
            y += LH;
          }
          for (const c of wd) {
            pos.push([x, y]);
            x += g.measureText(c).width;
          }
          if (wi < words.length - 1) {
            pos.push([x, y]);
            x += g.measureText(' ').width;
          }
        });
        let cx = bx + 12;
        let cy = by + 18;
        for (let i = 0; i < n; i++) {
          const [px, py] = pos[i]!;
          const age = p - at[i]!;
          const pop = p < holdEnd ? clamp01(age / 0.08) : 1;
          g.fillStyle = `rgba(43,36,64,${0.3 + 0.7 * pop})`;
          g.fillText(chars[i]!, px, py + (1 - pop) * 3);
          cx = px + g.measureText(chars[i]!).width;
          cy = py;
        }
        const busy = p < typedEnd && p > 0.4 && n > 0 && p - at[n - 1]! < 0.12;
        const on = busy || Math.floor(tt * 2.2) % 2 === 0;
        if (on) {
          g.fillStyle = '#ff5c8a';
          g.fillRect(cx + 1, cy - 8, 2, 16);
        }
        g.restore();
      },
    };
  },
};
```

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