# AI 꾸러미 — 스프라이트 외곽선 · 빛 테 (알파 넓히기) — Sprite outline (alpha dilation)
> 그림 알파 둘레로 거리 지도를 만들어 n칸 넓힌 테와 멀리 옅게 퍼지는 빛 무리를 구워, 고른 캐릭터만 테두리가 빛나게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i294

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

## 주문서

### 만들어 줘: 스프라이트 외곽선 · 빛 테 (알파 넓히기) — Sprite outline (alpha dilation)

#### 1. 목표
고른 캐릭터 · 친구에 고르면 빛나는 테두리를 넣어 줘 — 그림 알파로 거리 지도를 만들어 n칸 넓힌 테 + 멀리 옅게 퍼지는 빛 무리. 색은 노란 빛 테.

#### 2. 핵심 기술 용어
- **Sprite outline (alpha dilation)** — 알파 넓히기로 만든 그림 테두리
- **Distance transform (chamfer 1 · √2)** — 각 화소에서 가장 가까운 그림까지 거리 — 두 번 훑기
- **Glow halo exp(−d/k)** — 거리 따라 옅어지는 빛 무리
- **Selection highlight** — 고른 것 표시

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

#### 4. 조건
- 테 두께는 거리 지도로 — 그림을 여러 방향으로 밀어 찍는 꼼수는 두꺼우면 울퉁불퉁하다
- 여백(PAD)을 테 + 빛 무리보다 넉넉히 (견본 26)
- 테 · 빛 무리는 두께 · 색 열쇠로 캐시
- 그리는 순서: 빛 무리(lighter) → 테 → 원래 그림
- 고른 것만 표시 — 나머지는 원래 그림만

#### 5. 완성 기준 (이게 보이면 성공)
- 고른 친구 둘레에 고른 두께의 테가 생기고, 다른 친구는 테가 없다
- 테 두께 1 ~ 12 슬라이더로 두께가 바뀌어도 모서리가 둥글고 고르다
- 빛 무리를 켜면 테 바깥으로 옅은 빛이 깜빡인다
- 테 색 바꾸기 단추로 노랑 · 하늘 · 분홍

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

## 원리
- 그림을 둘레 여백(PAD 26)을 둔 캔버스에 놓고, 알파가 110 넘는 화소는 거리 0, 나머지는 아주 큰 값으로 시작한다.
- 위→아래 · 왼→오른 한 번, 반대로 한 번 훑으며 이웃 거리 + 1(옆) · +√2(대각)로 줄여 나가면 「가장 가까운 그림까지 거리」 d 가 된다.
- 테: 알파 = clamp(r + 0.5 − d) — r칸 안쪽은 꽉 차고 경계는 반 칸 부드럽게. 빛 무리: 알파 = exp(−d/7)·0.95.
- 테 · 빛 무리는 두께 · 색마다 한 번 구워 캐시하고, 빛 무리는 lighter 로 깜빡이게 겹친 뒤 → 테 → 원래 그림 순으로 그린다.

## 핵심 코드 — 알파 → 거리 지도(두 번 훑기) → 테 · 빛 무리
(발췌: demos/demos2dLook.ts distField() · fieldCanvas() · mkI294() 를 정리)
```ts
function distField(src: HTMLCanvasElement, pad: number) {
  const W = src.width + pad * 2, H = src.height + pad * 2;
  const c = document.createElement('canvas');
  c.width = W; c.height = H;
  const x = c.getContext('2d', { willReadFrequently: true })!;
  x.drawImage(src, pad, pad);
  const a = x.getImageData(0, 0, W, H).data;
  const d = new Float32Array(W * H);
  for (let i = 0; i < W * H; i++) d[i] = a[i * 4 + 3]! > 110 ? 0 : 1e6;
  const D = Math.SQRT2;
  for (let y = 0; y < H; y++) for (let xx = 0; xx < W; xx++) { // 앞으로 훑기
    const i = y * W + xx; let v = d[i]!;
    if (xx > 0) v = Math.min(v, d[i - 1]! + 1);
    if (y > 0) { v = Math.min(v, d[i - W]! + 1); if (xx > 0) v = Math.min(v, d[i - W - 1]! + D); if (xx < W - 1) v = Math.min(v, d[i - W + 1]! + D); }
    d[i] = v;
  }
  for (let y = H - 1; y >= 0; y--) for (let xx = W - 1; xx >= 0; xx--) { // 거꾸로 훑기
    const i = y * W + xx; let v = d[i]!;
    if (xx < W - 1) v = Math.min(v, d[i + 1]! + 1);
    if (y < H - 1) { v = Math.min(v, d[i + W]! + 1); if (xx < W - 1) v = Math.min(v, d[i + W + 1]! + D); if (xx > 0) v = Math.min(v, d[i + W - 1]! + D); }
    d[i] = v;
  }
  return { d, W, H, c }; // c = 여백 둔 원래 그림
}
function fieldCanvas(f: { d: Float32Array; W: number; H: number }, fn: (d: number) => number, col: [number, number, number]) {
  const c = document.createElement('canvas');
  c.width = f.W; c.height = f.H;
  const x = c.getContext('2d')!;
  const im = x.createImageData(f.W, f.H);
  for (let i = 0; i < f.W * f.H; i++) {
    const a = fn(f.d[i]!);
    if (a <= 0) continue;
    im.data.set([col[0], col[1], col[2], Math.round(Math.min(1, a) * 255)], i * 4);
  }
  x.putImageData(im, 0, 0);
  return c;
}
const f = distField(buddyCanvas, 26);
const ring = fieldCanvas(f, (d) => (d <= 0 ? 1 : Math.max(0, Math.min(1, 5 + 0.5 - d))), [255, 243, 166]); // 5칸 테
const halo = fieldCanvas(f, (d) => Math.exp(-d / 7) * 0.95, [255, 243, 166]);
// 그리기: 'lighter' 로 halo (알파 0.45 + 0.3·sin(6t)) → ring → f.c
```

## 흔한 실수 · 확인 목록
- [ ] **그림을 8방향으로 밀어 찍어 테를 만들면 두꺼울 때 모서리가 각진다** — 거리 지도로 만들면 어떤 두께든 둥글고 고르다.
- [ ] **여백 없이 구우면 테가 캔버스 끝에서 잘린다** — 테 + 빛 무리 길이보다 넉넉한 여백(26)을 두고, 그릴 때 그만큼 바깥으로 놓는다.
- [ ] **반투명 가장자리(안티앨리어스)까지 그림으로 치면 테가 뚱뚱해진다** — 알파 문턱(110)을 넘는 화소만 그림으로 친다.
- [ ] **두께를 바꿀 때마다 매 프레임 다시 구우면 끊긴다** — 두께 · 색을 열쇠로 캐시한다.

## 완성 기준 체크리스트
- [ ] 고른 친구 둘레에 고른 두께의 테가 생기고, 다른 친구는 테가 없다
- [ ] 테 두께 1 ~ 12 슬라이더로 두께가 바뀌어도 모서리가 둥글고 고르다
- [ ] 빛 무리를 켜면 테 바깥으로 옅은 빛이 깜빡인다
- [ ] 테 색 바꾸기 단추로 노랑 · 하늘 · 분홍

## 이 기술 정보
- id: `i294` · 분류: 2D · 화면 › 2D 그림 효과 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 거리 지도는 그림마다 한 번 (180×180 화소 두 번 훑기). 테 · 빛 무리는 두께 · 색마다 한 번 굽고 drawImage 만.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i294
- 쓰면 좋을 때: 고른 것 · 누를 수 있는 것을 또렷하게 표시할 때 / 모양이 제각각인 그림에 고른 두께의 테두리가 필요할 때
- 쓰지 말 때: 네모 카드 · 단추 — CSS outline · box-shadow 가 더 간단하다 / 매 프레임 모양이 바뀌는 그림 — 거리 지도를 계속 다시 구워야 한다. 대신 셰이더로 둘레 알파 읽기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### mkI294 — `src/demos/demos2dLook.ts:382`
```ts
function mkI294() {
  const S = 128;
  const PAD = 26;
  const names = ['mint', 'pink', 'sky'];
  const fields = names.map((n) => distField(buddy(S, n), PAD));
  const COLS = ['#fff3a6', '#7df3ff', '#ff9be0'];
  let thick = 5;
  let glow = true;
  let ci = 0;
  const cache = new Map<string, HTMLCanvasElement>();
  const ring = (i: number): HTMLCanvasElement => {
    const k = `r${i}-${thick}-${ci}`;
    let c = cache.get(k);
    if (!c) {
      const r = thick;
      c = fieldCanvas(fields[i]!, (d) => (d <= 0 ? 1 : clamp01(r + 0.5 - d)), hexRgb(COLS[ci]!));
      cache.set(k, c);
    }
    return c;
  };
  const halo = (i: number): HTMLCanvasElement => {
    const k = `g${i}-${ci}`;
    let c = cache.get(k);
    if (!c) {
      c = fieldCanvas(fields[i]!, (d) => Math.exp(-d / 7) * 0.95, hexRgb(COLS[ci]!));
      cache.set(k, c);
    }
    return c;
  };
  const controls: Control[] = [
    { type: 'range', label: '테 두께 (넓히는 칸)', min: 1, max: 12, step: 1, value: thick, on: (v) => (thick = v) },
    { type: 'toggle', label: '빛 무리', value: glow, on: (v) => (glow = v) },
    { type: 'button', label: '테 색 바꾸기', on: () => (ci = (ci + 1) % COLS.length) },
  ];
  return {
    controls,
    draw(g: G, w: number, h: number, t: number) {
      reset(g);
      const f = frame(g, w, h);
      vbg(g, f, '#241a4a', '#120d27');
      spot(g, 140, 70, 170, [120, 90, 255], 0.18);
      // 바닥
      g.fillStyle = '#2e2560';
      g.beginPath();
      g.ellipse(140, 168, 190, 42, 0, 0, TAU);
      g.fill();
      const sel = Math.floor(t / 1.6) % 3;
      const D = 176 * (78 / 128);
      for (let i = 0; i < 3; i++) {
        const x = 55 + i * 85;
        const on = i === sel;
        const lt = (t % 1.6) / 1.6;
        const hop = on ? Math.abs(Math.sin(Math.min(1, lt * 2.2) * Math.PI)) * 9 * (1 - lt * 0.6) : 0;
        const base = 142;
        g.fillStyle = 'rgba(0,0,0,.35)';
        g.beginPath();
        g.ellipse(x, base, 26 - hop * 0.8, 6, 0, 0, TAU);
        g.fill();
        if (on) spot(g, x, base, 40, hexRgb(COLS[ci]!), 0.25);
        const dx = x - D / 2;
        const dy = base - hop - (PAD + S * 0.9) * (D / (S + PAD * 2));
        if (on && glow) {
          g.globalCompositeOperation = 'lighter';
          g.globalAlpha = 0.45 + 0.3 * Math.sin(t * 6);
          const gs = D * 1.06;
          g.drawImage(halo(i), x - gs / 2, dy - (gs - D) / 2, gs, gs);
          g.globalAlpha = 1;
          g.globalCompositeOperation = 'source-over';
        }
        if (on) g.drawImage(ring(i), dx, dy, D, D);
        g.drawImage(fields[i]!.c, dx, dy, D, D);
        if (on) {
          const ay = 34 + Math.sin(t * 7) * 3;
          g.fillStyle = COLS[ci]!;
          g.beginPath();
          g.moveTo(x - 7, ay - 6);
          g.lineTo(x + 7, ay - 6);
          g.lineTo(x, ay + 3);
          g.closePath();
          g.fill();
        }
      }
      pill(g, '고른 친구 = 알파를 ' + thick + '칸 넓힌 테' + (glow ? ' + 빛' : ''), 140, 14, 8.5, 'rgba(255,255,255,.12)', '#f3edff');
      g.restore();
    },
  };
}
```

### i294 견본 항목 — `src/demos/demos2dLook.ts:3777`
```ts
  i294: { kind: '2d', caption: '그림 둘레를 거리 지도로 n칸 넓혀 테를 만들고, 더 멀리 옅게 퍼뜨려 빛 무리 — 고른 친구만 빛남', make: mkI294 }
```

## 관련 기술
- 먼저 알면 좋은 기술: [캔버스 그러데이션 · 흐림 · 빛 섞기](https://ai-techstudio.web.app/ai/t/u52.md) `u52`
- 다음에 해 볼 기술: [2D 빛 반사 · 반짝 쓸기 (sheen)](https://ai-techstudio.web.app/ai/t/i301.md) `i301` · [색 바꾸기 · 팔레트 교체 (2D)](https://ai-techstudio.web.app/ai/t/i296.md) `i296`
- 참고 문서: [Wikipedia — Distance transform](https://en.wikipedia.org/wiki/Distance_transform)
