# AI 꾸러미 — 만화 펜선 + 스크린톤 — Manga inking (tapered stroke)
> 양끝이 가늘고 가운데가 굵은 펜 선, 45° 격자 점으로 찍은 스크린톤 그늘, 가운데로 모이는 집중선과 충격 흔들림으로 만화 한 컷을 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i335

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

## 주문서

### 만들어 줘: 만화 펜선 + 스크린톤 — Manga inking (tapered stroke)

#### 1. 목표
정답을 맞힌 순간 연출을 흑백 만화 한 컷으로 그려 줘 — 끝이 가는 펜 선, 점 스크린톤 그늘, 집중선, 「두둥!」 하는 충격 흔들림. 분위기는 소년 만화 「두둥!」.

#### 2. 핵심 기술 용어
- **Manga inking (tapered stroke)** — 굵기가 변하는 펜 선 — 끝을 뾰족하게
- **Screentone (halftone dots)** — 스크린톤 — 일정 간격 점으로 회색
- **Speed lines / focus lines** — 집중선 — 가운데로 모이는 쐐기 선
- **Canvas clip + pattern fill** — 모양으로 잘라 미리 구운 톤 깔기

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

#### 4. 조건
- 톤 점은 화면(또는 칸)에 고정된 격자 — 물체를 따라 움직이면 안 된다
- 선 굵기는 길이 비율로 (짧은 선도 양끝이 가늘게)
- 모서리는 양끝을 조금 넘겨(0.8 ~ 2.2 단위) 그어 펜으로 그은 맛을 낸다
- 흔들림은 시간을 1/15초로 끊어 만화 같은 덜컥임으로

#### 5. 완성 기준 (이게 보이면 성공)
- 펜 선 끝이 뾰족하고, 「펜 굵기 변화」 슬라이더(0 ~ 1)를 0 으로 하면 고른 선이 된다
- 그늘 면에 점 스크린톤이 보이고, 점 크기 슬라이더로 짙기가 바뀐다
- 3초마다 집중선이 조여들고 칸이 덜컥 흔들린다
- 원본 그림과 가르는 선으로 비교된다

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

## 원리
- 펜 선: 선을 1.2 단위로 다시 나누고, 점마다 굵기 = 최대 × (1 − amt + amt · sin(π·진행)^0.55) 로 — 양끝은 가늘고 가운데 굵다. 왼쪽 · 오른쪽 테두리를 모아 다각형으로 칠한다.
- 스크린톤: 45° 돌린 격자(간격 2.6 × √½, 칸 하나 건너 하나) 위에 점을 찍는다. 점 반지름이 곧 회색의 짙기 — 짙은 톤 0.34, 옅은 톤 0.19.
- 톤은 화면 크기에 한 장씩 미리 구워 두고, 칠할 모양으로 잘라(clip) 그대로 깐다 — 면이 움직여도 점이 화면에 고정돼 진짜 톤처럼 보인다.
- 집중선: 가운데에서 바깥으로 가늘어지는 쐐기 150개. 충격 값 exp(−4.5·t) 로 칸이 흔들리고 주인공이 7% 커졌다 돌아온다.

## 핵심 코드 — 끝이 가는 펜 선 + 스크린톤 점 굽기
(발췌: demos/demosHandB.ts taper() · bakeTone() 을 정리)
```ts
type P = [number, number];
function taper(g: CanvasRenderingContext2D, src: P[], wMax: number, amt: number, seed: number) {
  const pts = resample(src, false, 1.2);       // 1.2 단위로 다시 나눈 점
  const cum = cumLen(pts);
  const L = cum[cum.length - 1] || 1;
  const left: P[] = [], right: P[] = [];
  for (let i = 0; i < pts.length; i++) {
    const a = pts[Math.max(0, i - 1)], b = pts[Math.min(pts.length - 1, i + 1)];
    let dx = b[0] - a[0], dy = b[1] - a[1];
    const l = Math.hypot(dx, dy) || 1; dx /= l; dy /= l;
    const prof = Math.pow(Math.sin(Math.PI * cum[i] / L), 0.55);           // 끝 0 → 가운데 1
    const w = wMax * (1 - amt + amt * prof) * (1 + 0.18 * n1(cum[i] * 0.07, seed)) * 0.5; // 필압 잡음
    const p = pts[i];
    left.push([p[0] - dy * w, p[1] + dx * w]);
    right.push([p[0] + dy * w, p[1] - dx * w]);
  }
  g.beginPath();
  left.forEach((p, i) => (i ? g.lineTo(p[0], p[1]) : g.moveTo(p[0], p[1])));
  for (let i = right.length - 1; i >= 0; i--) g.lineTo(right[i][0], right[i][1]);
  g.closePath();
  g.fillStyle = '#111';
  g.fill();
}

// 스크린톤: 45° 격자(칸 하나 건너 하나)에 점 — rad 는 간격 대비 반지름(0.34 짙게 · 0.19 옅게)
function bakeTone(g: CanvasRenderingContext2D, w: number, h: number, pitch: number, rad: (x: number, y: number) => number) {
  const q = pitch * Math.SQRT1_2;
  g.fillStyle = '#161616';
  g.beginPath();
  for (let k = 0; k * q < h; k++)
    for (let m = 0; m * q < w; m++) {
      if ((k + m) & 1) continue;
      const x = m * q, y = k * q, r = rad(x, y) * pitch;
      if (r < 0.04) continue;
      g.moveTo(x + r, y);
      g.arc(x, y, r, 0, Math.PI * 2);
    }
  g.fill();
}
```

## 흔한 실수 · 확인 목록
- [ ] **톤을 물체 좌표에 찍으면 움직일 때 점이 미끄러진다** — 톤은 화면에 고정된 격자로 한 장 굽고 모양으로 잘라 깐다 — 실제 스크린톤처럼.
- [ ] **굵기를 lineWidth 로만 바꾸면 끝이 뭉툭하다** — 선 하나를 양쪽 테두리 다각형으로 만들어 칠해야 끝이 뾰족해진다.
- [ ] **점 격자를 0° 로 두면 모아레 줄무늬가 잘 보인다** — 만화 톤처럼 45° 로 돌린 격자(칸 하나 건너 하나)를 쓴다.
- [ ] **집중선이 가운데까지 들어오면 주인공이 가려진다** — 쐐기 안쪽 끝을 60 ~ 100 단위로 멈춰 가운데를 비운다.

## 완성 기준 체크리스트
- [ ] 펜 선 끝이 뾰족하고, 「펜 굵기 변화」 슬라이더(0 ~ 1)를 0 으로 하면 고른 선이 된다
- [ ] 그늘 면에 점 스크린톤이 보이고, 점 크기 슬라이더로 짙기가 바뀐다
- [ ] 3초마다 집중선이 조여들고 칸이 덜컥 흔들린다
- [ ] 원본 그림과 가르는 선으로 비교된다

## 이 기술 정보
- id: `i335` · 분류: 2D · 화면 › 손그림 그림체 (2D) · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 톤 3장 · 종이는 크기가 바뀔 때만 굽는다. 매 프레임은 쐐기 150개와 펜 선 몇십 개라 폰도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i335
- 쓰면 좋을 때: 정답 · 반전 같은 순간을 강조할 때 / 흑백 인쇄물 같은 이야기 장면
- 쓰지 말 때: 넓은 면을 매 프레임 새 톤으로 다시 찍는 것 — 톤은 미리 구워 잘라 쓴다 / 컬러가 중요한 화면 — 스크린톤은 흑백 명암 표현이다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeManga — `src/demos/demosHandB.ts:1796`
```ts
function makeManga() {
  let tap = 0.85;
  let dot = 1;
  let cmp = true;
  let kT = '';
  let tones: HTMLCanvasElement[] = [];
  let kP = '';
  let paper: HTMLCanvasElement | null = null;
  const toneIn = (g: G, f: Fit, path: () => void, cv: HTMLCanvasElement): void => {
    g.save();
    path();
    g.clip();
    fullImg(g, f, cv);
    g.restore();
  };
  return {
    draw(g: G, w: number, h: number, t: number): void {
      reset(g);
      const f = fitOf(g, w, h);
      const kp = keyOf(f);
      if (kp !== kP) {
        paper = bakePaper(f, [246, 243, 234], 1, 0.18, 7);
        kP = kp;
      }
      const kt = keyOf(f, dot);
      if (kt !== kT) {
        tones = [
          bakeTone(f, 2.6, () => 0.34 * dot),
          bakeTone(f, 2.6, () => 0.19 * dot),
          bakeTone(f, 2.6, (_x, y) => 0.4 * smooth(80, 10, y) * dot),
        ];
        kT = kt;
      }
      const [dark, light, grad] = tones as [HTMLCanvasElement, HTMLCanvasElement, HTMLCanvasElement];
      g.drawImage(paper!, 0, 0, w, h);
      const tq = Math.floor(t * 15) / 15;
      const tc = tq % 3;
      const imp = Math.exp(-tc * 4.5);
      const step = Math.floor(t * 15);
      const shx = (hash2(step, 1) - 0.5) * 3.2 * imp;
      const shy = (hash2(step, 2) - 0.5) * 3.2 * imp;
      enter(g, f);
      g.save();
      g.translate(shx, shy);
      g.save();
      g.beginPath();
      g.rect(10, 10, 260, 155);
      g.clip();
      g.fillStyle = '#fdfdfb';
      g.fillRect(0, 0, 280, 175);
      fullImg(g, f, grad);
      // 집중선
      const C: P = [150, 98];
      const r = rng(5);
      g.fillStyle = '#111';
      g.beginPath();
      for (let i = 0; i < 150; i++) {
        const a = (i / 150) * TAU + (r() - 0.5) * 0.035;
        const rin = (60 + r() * 40) * (1 - 0.2 * imp);
        const wo = 0.4 + r() * 1.9;
        const dx = Math.cos(a);
        const dy = Math.sin(a);
        const R = 240;
        g.moveTo(C[0] + dx * R - dy * wo, C[1] + dy * R + dx * wo);
        g.lineTo(C[0] + dx * rin, C[1] + dy * rin);
        g.lineTo(C[0] + dx * R + dy * wo, C[1] + dy * R - dx * wo);
        g.closePath();
      }
      g.fill();
      // 정육면체 (윗면 옅은 톤 · 옆면 짙은 톤)
      const cb = cube(54, 130, 34);
      polyPath(g, cb.front);
      g.fillStyle = '#fff';
      g.fill();
      polyPath(g, cb.top);
      g.fill();
      polyPath(g, cb.side);
      g.fill();
      toneIn(g, f, () => polyPath(g, cb.top), light);
      toneIn(g, f, () => polyPath(g, cb.side), dark);
      const cedges: P[][] = [
        [cb.front[0]!, cb.front[1]!],
        [cb.front[1]!, cb.front[2]!],
        [cb.front[2]!, cb.front[3]!],
        [cb.front[3]!, cb.front[0]!],
        [cb.front[0]!, cb.top[3]!],
        [cb.top[3]!, cb.top[2]!],
        [cb.front[1]!, cb.top[2]!],
        [cb.top[2]!, cb.side[2]!],
        [cb.side[2]!, cb.front[2]!],
      ];
      cedges.forEach((e, i) => taper(g, over(e[0]!, e[1]!, 0.8, 0.8), 1.9, tap, 50 + i));
      // 웃는 세모 (충격에 커짐)
      g.save();
      const zs = 1 + 0.07 * imp;
      g.translate(150, 100);
      g.scale(zs, zs);
      g.translate(-150, -100);
      const tr = tri(150, 100, 76);
      polyPath(g, tr.pts);
      g.fillStyle = '#fff';
      g.fill();
      toneIn(
        g,
        f,
        () => {
          polyPath(g, tr.pts);
          g.clip();
          polyPath(g, [tr.pts[0]!, tr.pts[1]!, [150 + 76 * 0.14, tr.pts[1]![1]]]);
        },
        dark,
      );
      toneIn(
        g,
        f,
        () => {
          polyPath(g, tr.pts);
          g.clip();
          g.beginPath();
          g.rect(100, tr.pts[1]![1] - 9, 100, 9);
        },
        light,
      );
      for (let i = 0; i < 3; i++) {
        const a = tr.pts[i]!;
        const b = tr.pts[(i + 1) % 3]!;
        taper(g, over(a, b, 2.2, 2.2), 3.2, tap, 60 + i);
      }
      // 큰 눈 · 반짝
      tr.eyes.forEach((p) => {
        g.fillStyle = '#111';
        g.beginPath();
        g.ellipse(p[0], p[1] - 2, 4.2, 6.2, 0, 0, TAU);
        g.fill();
        g.fillStyle = '#fff';
        g.beginPath();
        g.arc(p[0] - 1.4, p[1] - 4.4, 1.7, 0, TAU);
        g.fill();
        g.beginPath();
        g.arc(p[0] + 1.3, p[1] + 0.6, 0.85, 0, TAU);
        g.fill();
      });
      // 벌린 입 + 혀 톤
      const mx = 150;
      const my = 107;
      const mouth = (): void => {
        g.beginPath();
        g.moveTo(mx - 8, my);
        g.quadraticCurveTo(mx, my + 15, mx + 8, my);
        g.closePath();
      };
      mouth();
      g.fillStyle = '#111';
      g.fill();
      g.save();
      mouth();
      g.clip();
      g.fillStyle = '#fff';
      g.beginPath();
      g.ellipse(mx, my + 9.5, 5, 3.6, 0, 0, TAU);
      g.fill();
      toneIn(
        g,
        f,
        () => {
          g.beginPath();
          g.ellipse(mx, my + 9.5, 5, 3.6, 0, 0, TAU);
        },
        light,
      );
      g.restore();
      // 볼 빗금 (만화식 홍조)
      tr.ch.forEach((p, j) => {
        for (let i = 0; i < 3; i++) taper(g, [[p[0] - 3 + i * 2.6, p[1] + 2.4], [p[0] - 1 + i * 2.6, p[1] - 2.4]], 1.1, 1, 70 + i + j * 3);
      });
      // 반짝 별
      const sx = 182;
      const sy = 56;
      const sp = (k: number): void => {
        g.beginPath();
        g.moveTo(sx, sy - 7 * k);
        g.quadraticCurveTo(sx, sy, sx + 7 * k, sy);
        g.quadraticCurveTo(sx, sy, sx, sy + 7 * k);
        g.quadraticCurveTo(sx, sy, sx - 7 * k, sy);
        g.quadraticCurveTo(sx, sy, sx, sy - 7 * k);
      };
      sp(1.25);
      g.fillStyle = '#111';
      g.fill();
      sp(1);
      g.fillStyle = '#fff';
      g.fill();
      g.restore();
      // 말풍선
      const bx = 70;
      const by = 38;
      const ell = arcPts(bx, by, 46, 0, TAU, 60, 19);
      polyPath(g, ell);
      g.fillStyle = '#fff';
      g.fill();
      taper(g, ell.slice(0, 31), 1.9, tap * 0.7, 80);
      taper(g, ell.slice(30), 1.9, tap * 0.7, 81);
      const tail: P[] = [
        [96, 52],
        [120, 72],
        [106, 50],
      ];
      polyPath(g, tail);
      g.fillStyle = '#fff';
      g.fill();
      taper(g, [tail[0]!, tail[1]!], 1.7, tap, 82);
      taper(g, [tail[1]!, tail[2]!], 1.7, tap, 83);
      g.fillStyle = '#111';
      g.font = `900 14px ${F}`;
      g.textAlign = 'center';
      g.fillText('정답은 7 !', bx, by + 5);
      g.textAlign = 'left';
      // 효과음 글씨
      const sfx = 1 + 0.3 * imp;
      g.save();
      g.translate(228, 46);
      g.scale(sfx, sfx);
      g.rotate(-0.12);
      g.font = `900 27px ${F}`;
      g.lineJoin = 'round';
      const glyphs: [string, number, number, number][] = [
        ['두', -24, 0, -0.12],
        ['둥', 4, 6, 0.1],
        ['!', 26, 2, 0.2],
      ];
      for (const [ch, x, y, a] of glyphs) {
        g.save();
        g.translate(x, y);
        g.rotate(a);
        g.textAlign = 'center';
        g.strokeStyle = '#fff';
        g.lineWidth = 6;
        g.strokeText(ch, 0, 0);
        g.fillStyle = '#111';
        g.fillText(ch, 0, 0);
        g.restore();
      }
      g.restore();
      g.restore();
      // 칸 테두리
      g.strokeStyle = '#111';
      g.lineWidth = 2.6;
      g.strokeRect(10, 10, 260, 155);
      g.restore();
      g.restore();
      if (cmp)
        splitter(
          g,
          w,
          h,
          t,
          () => {
            drawClean(
              g,
              f,
              [
                { k: 'cube', x: 54, y: 130, s: 34 },
                { k: 'tri', x: 150, y: 100, s: 76 },
              ],
              '#f6f6f2',
            );
            enter(g, f);
            g.beginPath();
            g.ellipse(70, 38, 46, 19, 0, 0, TAU);
            g.fillStyle = '#fff';
            g.fill();
            g.strokeStyle = COL.ink;
            g.lineWidth = 1.2;
            g.stroke();
            g.fillStyle = COL.ink;
            g.font = `500 13px ${F}`;
            g.textAlign = 'center';
            g.fillText('정답은 7 !', 70, 43);
            g.textAlign = 'left';
            g.restore();
          },
          '만화',
        );
    },
    controls: [
      { type: 'range', label: '펜 굵기 변화 (끝 가늘게)', min: 0, max: 1, step: 0.05, value: tap, on: (v) => (tap = v) },
      { type: 'range', label: '스크린톤 점 크기', min: 0.4, max: 1.6, step: 0.05, value: dot, on: (v) => (dot = v) },
      cmpToggle(cmp, (v) => (cmp = v)),
    ] as Control[],
  };
}
```

## 관련 기술
- 같은 방법으로 만든 효과 예시: [하프톤 점 · 리소 인쇄](https://ai-techstudio.web.app/ai/t/i113.md) `i113`
- 먼저 알면 좋은 기술: [스케치 선 (넘침 · 두 번 긋기)](https://ai-techstudio.web.app/ai/t/i324.md) `i324` · [글자 테 · 그러데이션 글자](https://ai-techstudio.web.app/ai/t/u64.md) `u64`
- 다음에 해 볼 기술: [목판화 · 판화 (깎은 선)](https://ai-techstudio.web.app/ai/t/i336.md) `i336`
