# AI 꾸러미 — 2D 빛 반사 · 반짝 쓸기 (sheen) — Sheen sweep (shine highlight)
> 동전 · 카드 · 금 글씨 위로 대각 빛 띠를 지나가게 하되 source-atop 으로 그림이 있는 화소에만 칠해, 보상이 반짝 빛나 보이게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i301

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

## 주문서

### 만들어 줘: 2D 빛 반사 · 반짝 쓸기 (sheen) — Sheen sweep (shine highlight)

#### 1. 목표
금화 · 보상 아이콘 위로 대각 빛 띠가 지나가게 해 줘 — 그림을 작은 캔버스에 복사하고 source-atop 으로 그림 화소에만 흰 띠를 칠한다. 느낌은 짧고 또렷한 흰 빛.

#### 2. 핵심 기술 용어
- **Sheen sweep (shine highlight)** — 반짝 쓸기 — 그림 위를 지나가는 빛 띠
- **globalCompositeOperation 'source-atop'** — 이미 그려진 화소 위에만 칠하기
- **Diagonal linear gradient band** — 대각 선형 그러데이션 띠
- **Glint sparkle** — 띠가 지날 때 번쩍이는 네 갈래 반짝

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

#### 4. 조건
- 띠는 source-atop 으로 — 그림 바깥(투명한 곳)에는 칠해지지 않게
- 원래 그림은 그대로 두고 복사본(scr)에 칠하기
- 물체마다 시작 위상(ph)을 달리해 동시에 빛나지 않게
- 띠 폭 · 속도 조절, 켬/끔 비교

#### 5. 완성 기준 (이게 보이면 성공)
- 동전 · 카드 · 금 글씨 · 단추 모양 그대로 빛 띠가 지나가고, 바깥에는 빛이 새지 않는다
- 띠가 끝날 무렵 오른쪽 위에 반짝이 번쩍인다
- 띠 폭 0.05 ~ 0.4 · 속도 0.3 ~ 3 슬라이더
- 네 물체가 차례로 엇갈려 빛난다

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

## 원리
- 물체마다 같은 크기 작은 캔버스(scr)를 하나 두고, 매 프레임 원래 그림을 복사한다.
- 그 위에 source-atop 으로 띠를 칠하면 그림이 있는 화소에만 칠해지고 투명한 곳은 그대로다 — 동전 모양 그대로 빛이 지나간다.
- 띠는 대각 선형 그러데이션: 0 → 0.42 옅게 → 0.5 거의 흰색(0.95) → 0.58 → 1 투명. 앞쪽에 가는 띠를 하나 더 두면 두 줄 반짝.
- 2.4초 주기 중 앞 55% 동안만 띠가 W + H 길이를 가로지르고, 지나가는 끝 무렵 오른쪽 위에 네 갈래 반짝을 그린다.

## 핵심 코드 — 그림 화소에만 대각 빛 띠 (source-atop)
(발췌: demos/demos2dLook.ts mkI301() draw() 를 정리)
```ts
// img: 원래 그림 · scr: 같은 크기 작업 캔버스 · ph: 물체마다 다른 시작 위상
function sheen(img: HTMLCanvasElement, scr: HTMLCanvasElement, t: number, ph: number, bw = 0.18, spd = 1) {
  const x = scr.getContext('2d')!;
  x.globalCompositeOperation = 'source-over';
  x.clearRect(0, 0, scr.width, scr.height);
  x.drawImage(img, 0, 0);
  const cyc = ((t * spd) / 2.4 + ph) % 1;
  if (cyc < 0.55) {
    const W = scr.width, H = scr.height;
    const k = cyc / 0.55;
    const c = -H * 0.6 + k * (W + H + H * 0.6); // 띠 가운데가 왼쪽 위 → 오른쪽 아래로
    const half = bw * Math.max(W, H);
    x.globalCompositeOperation = 'source-atop'; // 그림 있는 화소에만
    const gr = x.createLinearGradient(c - half, 0, c + half, half * 0.9);
    gr.addColorStop(0, 'rgba(255,255,255,0)');
    gr.addColorStop(0.42, 'rgba(255,255,255,.25)');
    gr.addColorStop(0.5, 'rgba(255,255,255,.95)');
    gr.addColorStop(0.58, 'rgba(255,255,255,.25)');
    gr.addColorStop(1, 'rgba(255,255,255,0)');
    x.fillStyle = gr;
    x.fillRect(0, 0, W, H);
    const gr2 = x.createLinearGradient(c - half * 1.9, 0, c - half * 1.5, half * 0.4); // 앞의 가는 띠
    gr2.addColorStop(0, 'rgba(255,255,255,0)');
    gr2.addColorStop(0.5, 'rgba(255,255,255,.5)');
    gr2.addColorStop(1, 'rgba(255,255,255,0)');
    x.fillStyle = gr2;
    x.fillRect(0, 0, W, H);
    x.globalCompositeOperation = 'source-over';
  }
  return cyc; // 0.45 ~ 0.62 사이면 오른쪽 위에 반짝 그리기
}
// 그리기: g.drawImage(scr, o.x, o.y, o.w, o.h)
```

## 흔한 실수 · 확인 목록
- [ ] **화면 캔버스에 바로 띠를 칠하면 물체 바깥 배경까지 하얘진다** — 작은 작업 캔버스에 그림을 복사하고 source-atop 으로 칠한 뒤 붙인다.
- [ ] **원래 그림 캔버스에 칠하면 빛이 누적돼 점점 하얘진다** — 원래 그림은 그대로 두고 매 프레임 복사본에만.
- [ ] **모든 물체가 같은 순간 빛나면 산만하다** — 물체마다 위상(ph 0 · 0.25 · 0.5 · 0.7)을 다르게.
- [ ] **작업 캔버스를 매 프레임 새로 만들면 메모리가 늘어난다** — 물체마다 하나를 만들어 계속 쓴다.

## 완성 기준 체크리스트
- [ ] 동전 · 카드 · 금 글씨 · 단추 모양 그대로 빛 띠가 지나가고, 바깥에는 빛이 새지 않는다
- [ ] 띠가 끝날 무렵 오른쪽 위에 반짝이 번쩍인다
- [ ] 띠 폭 0.05 ~ 0.4 · 속도 0.3 ~ 3 슬라이더
- [ ] 네 물체가 차례로 엇갈려 빛난다

## 이 기술 정보
- id: `i301` · 분류: 2D · 화면 › 2D 그림 효과 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 물체마다 작은 캔버스 복사 + 그러데이션 칠하기 한 번. 수십 개도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i301
- 쓰면 좋을 때: 보상 · 금화 · 레어 카드처럼 「좋은 것」을 눈에 띄게 할 때 / 누를 단추를 은근히 알릴 때
- 쓰지 말 때: 화면 가득 많은 물체에 동시에 — 다 같이 빛나면 오히려 안 보인다. 대신 하나씩 엇갈리게(ph) / 글자 많은 판 — 띠가 지날 때 읽기 어렵다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### mkI301 — `src/demos/demos2dLook.ts:1563`
```ts
function mkI301() {
  const Q = 3;
  interface Obj {
    x: number;
    y: number;
    w: number;
    h: number;
    img: HTMLCanvasElement;
    scr: HTMLCanvasElement;
    ph: number;
  }
  const mk = (w: number, h: number, fn: (g: G, w: number, h: number) => void): HTMLCanvasElement => {
    const c = mkCanvas(w * Q, h * Q);
    const x = c2(c);
    x.scale(Q, Q);
    fn(x, w, h);
    return c;
  };
  const coin = mk(70, 70, (g) => {
    const gr = g.createRadialGradient(28, 26, 4, 35, 35, 34);
    gr.addColorStop(0, '#fff3a8');
    gr.addColorStop(0.5, '#ffc83a');
    gr.addColorStop(1, '#c9850e');
    g.fillStyle = '#9a5f05';
    g.beginPath();
    g.arc(35, 36.5, 32, 0, TAU);
    g.fill();
    g.fillStyle = gr;
    g.beginPath();
    g.arc(35, 35, 32, 0, TAU);
    g.fill();
    g.strokeStyle = '#a86b08';
    g.lineWidth = 2;
    g.beginPath();
    g.arc(35, 35, 25, 0, TAU);
    g.stroke();
    g.strokeStyle = 'rgba(255,250,210,.7)';
    g.lineWidth = 1;
    g.beginPath();
    g.arc(35, 35, 23.5, Math.PI * 1.1, Math.PI * 1.7);
    g.stroke();
    const star = (ox: number, oy: number): void => {
      g.beginPath();
      for (let i = 0; i < 10; i++) {
        const a = -Math.PI / 2 + (i * Math.PI) / 5;
        const r = i % 2 ? 6.5 : 15;
        g.lineTo(ox + Math.cos(a) * r, oy + Math.sin(a) * r);
      }
      g.closePath();
    };
    star(35, 37.5);
    g.fillStyle = '#b07208';
    g.fill();
    star(35, 36);
    g.fillStyle = '#ffd95a';
    g.fill();
  });
  const card = mk(64, 88, (g) => {
    rr(g, 2, 2, 60, 84, 8);
    const gr = g.createLinearGradient(0, 0, 64, 88);
    gr.addColorStop(0, '#7a4cff');
    gr.addColorStop(1, '#3a1d9a');
    g.fillStyle = gr;
    g.fill();
    g.lineWidth = 3;
    g.strokeStyle = '#ffcf4d';
    g.stroke();
    rr(g, 8, 8, 48, 50, 5);
    g.fillStyle = 'rgba(255,255,255,.12)';
    g.fill();
    g.save();
    g.translate(32, 33);
    g.rotate(Math.PI / 4);
    g.fillStyle = '#5ef0ff';
    g.fillRect(-11, -11, 22, 22);
    g.fillStyle = '#c4fbff';
    g.fillRect(-11, -11, 10, 10);
    g.strokeStyle = '#1b6a8a';
    g.lineWidth = 1.5;
    g.strokeRect(-11, -11, 22, 22);
    g.restore();
    txt(g, '레어', 32, 71, 11, '#ffe08a', 'center', 900);
  });
  const word = mk(96, 40, (g) => {
    g.font = `900 30px ${F}`;
    g.textAlign = 'center';
    g.textBaseline = 'middle';
    g.lineJoin = 'round';
    g.lineWidth = 6;
    g.strokeStyle = '#6b3a00';
    g.strokeText('보상!', 48, 21);
    const gr = g.createLinearGradient(0, 6, 0, 36);
    gr.addColorStop(0, '#fff6b0');
    gr.addColorStop(0.5, '#ffc93a');
    gr.addColorStop(0.52, '#f0a020');
    gr.addColorStop(1, '#ffd65a');
    g.fillStyle = gr;
    g.fillText('보상!', 48, 21);
  });
  const btn = mk(86, 34, (g) => {
    rr(g, 2, 4, 82, 28, 14);
    g.fillStyle = '#2d8a2a';
    g.fill();
    rr(g, 2, 2, 82, 27, 14);
    const gr = g.createLinearGradient(0, 2, 0, 29);
    gr.addColorStop(0, '#8ff07a');
    gr.addColorStop(1, '#3cb33a');
    g.fillStyle = gr;
    g.fill();
    g.lineWidth = 2;
    g.strokeStyle = '#1f6a1d';
    g.stroke();
    txt(g, '받기', 43, 16, 13, '#fff', 'center', 900);
  });
  const objs: Obj[] = [
    { x: 18, y: 50, w: 70, h: 70, img: coin, scr: mkCanvas(coin.width, coin.height), ph: 0 },
    { x: 104, y: 40, w: 64, h: 88, img: card, scr: mkCanvas(card.width, card.height), ph: 0.25 },
    { x: 182, y: 44, w: 96, h: 40, img: word, scr: mkCanvas(word.width, word.height), ph: 0.5 },
    { x: 187, y: 100, w: 86, h: 34, img: btn, scr: mkCanvas(btn.width, btn.height), ph: 0.7 },
  ];
  let on = true;
  let bw = 0.18;
  let spd = 1;
  const controls: Control[] = [
    { type: 'toggle', label: '빛 띠', value: on, on: (v) => (on = v) },
    { type: 'range', label: '띠 폭', min: 0.05, max: 0.4, step: 0.01, value: bw, on: (v) => (bw = v) },
    { type: 'range', label: '속도', min: 0.3, max: 3, step: 0.1, value: spd, on: (v) => (spd = v) },
  ];
  return {
    controls,
    draw(g: G, w: number, h: number, t: number) {
      reset(g);
      const f = frame(g, w, h);
      vbg(g, f, '#2b1a55', '#140b2c');
      // 뒤 빛살
      g.save();
      g.translate(140, 90);
      g.rotate(t * 0.15);
      g.fillStyle = 'rgba(255,220,120,.05)';
      for (let i = 0; i < 12; i++) {
        g.rotate(TAU / 12);
        g.beginPath();
        g.moveTo(0, 0);
        g.lineTo(220, -18);
        g.lineTo(220, 18);
        g.fill();
      }
      g.restore();
      for (const o of objs) {
        const fl = Math.sin(t * 2 + o.ph * 6) * 2;
        const cyc = ((t * spd) / 2.4 + o.ph) % 1;
        const x = c2(o.scr);
        x.globalCompositeOperation = 'source-over';
        x.clearRect(0, 0, o.scr.width, o.scr.height);
        x.drawImage(o.img, 0, 0);
        if (on && cyc < 0.55) {
          const W = o.scr.width;
          const H = o.scr.height;
          const k = cyc / 0.55;
          const span = W + H;
          const c = -H * 0.6 + k * (span + H * 0.6);
          const half = bw * Math.max(W, H);
          x.globalCompositeOperation = 'source-atop';
          const gr = x.createLinearGradient(c - half, 0, c + half, half * 0.9);
          gr.addColorStop(0, 'rgba(255,255,255,0)');
          gr.addColorStop(0.42, 'rgba(255,255,255,.25)');
          gr.addColorStop(0.5, 'rgba(255,255,255,.95)');
          gr.addColorStop(0.58, 'rgba(255,255,255,.25)');
          gr.addColorStop(1, 'rgba(255,255,255,0)');
          x.fillStyle = gr;
          x.fillRect(0, 0, W, H);
          const gr2 = x.createLinearGradient(c - half * 1.9, 0, c - half * 1.5, half * 0.4);
          gr2.addColorStop(0, 'rgba(255,255,255,0)');
          gr2.addColorStop(0.5, 'rgba(255,255,255,.5)');
          gr2.addColorStop(1, 'rgba(255,255,255,0)');
          x.fillStyle = gr2;
          x.fillRect(0, 0, W, H);
        }
        g.fillStyle = 'rgba(0,0,0,.3)';
        g.beginPath();
        g.ellipse(o.x + o.w / 2, o.y + o.h + 8, o.w * 0.35, 3, 0, 0, TAU);
        g.fill();
        g.drawImage(o.scr, o.x, o.y + fl, o.w, o.h);
        if (on && cyc > 0.45 && cyc < 0.62) {
          const k = (cyc - 0.45) / 0.17;
          const sx = o.x + o.w * 0.82;
          const sy = o.y + o.h * 0.22 + fl;
          const s = Math.sin(k * Math.PI) * 7;
          g.fillStyle = '#fff';
          g.beginPath();
          g.moveTo(sx, sy - s);
          g.quadraticCurveTo(sx, sy, sx + s, sy);
          g.quadraticCurveTo(sx, sy, sx, sy + s);
          g.quadraticCurveTo(sx, sy, sx - s, sy);
          g.quadraticCurveTo(sx, sy, sx, sy - s);
          g.fill();
        }
      }
      pill(g, '그림 위에만(source-atop) 대각 빛 띠를 지나가게', 140, 14, 8, 'rgba(255,220,120,.16)', '#fff4d6');
      g.restore();
    },
  };
}
```

### i301 견본 항목 — `src/demos/demos2dLook.ts:3784`
```ts
  i301: { kind: '2d', caption: '동전 · 카드 · 금 글씨 · 단추 위로 대각 빛 띠가 지나감 — 그림이 있는 화소에만 덧칠', make: mkI301 }
```

## 관련 기술
- 먼저 알면 좋은 기술: [반짝 단추 그림 (glossy)](https://ai-techstudio.web.app/ai/t/u60.md) `u60` · [스프라이트 외곽선 · 빛 테 (알파 넓히기)](https://ai-techstudio.web.app/ai/t/i294.md) `i294`
- 다음에 해 볼 기술: [캔버스 입자 엔진 (2D)](https://ai-techstudio.web.app/ai/t/i302.md) `i302`
- 참고 문서: [MDN — globalCompositeOperation (source-atop)](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation)
