# AI 꾸러미 — 반짝 단추 그림 (glossy) — Glossy button (layered canvas painting)
> 두께 → 몸통 그러데이션 → 흰 테 → 윗빛 → 글자를 한 겹씩 캔버스에 쌓아 반질반질한 게임 단추를 굽고, 색 세 개만 바꿔 여러 벌 만든다.  
> 견본: https://ai-techstudio.web.app/#t/u60

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

## 주문서

### 만들어 줘: 반짝 단추 그림 (glossy) — Glossy button (layered canvas painting)

#### 1. 목표
「시작!」 같은 큰 단추를 반질반질한 게임 단추로 구워 줘 — 바닥 두께 · 몸통 그러데이션 · 흰 테 · 윗빛 띠 · 테두리 글자를 차례로 쌓고, 색 세 개(위 · 아래 · 두께)만 바꿔 여러 벌. 느낌은 캐주얼 모바일 게임.

#### 2. 핵심 기술 용어
- **Glossy button (layered canvas painting)** — 반짝 단추 — 겹을 쌓아 그리기
- **Linear gradient body** — 위 밝고 아래 진한 몸통
- **Gloss highlight band** — 윗부분 흰 빛 띠
- **Texture baking (canvas → texture)** — 한 번 구워 그림처럼 재사용

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

#### 4. 조건
- 겹 순서: 두께 → 몸통 → (무늬) → 흰 테 → 윗빛 → 글자
- 색은 [위, 아래, 두께] 세 개로만 정하고 이름 붙인 세트로 (blue · orange · correct · wrong …)
- 텍스처는 해상도 2배로 굽고 1/2 로 줄여 놓기 (또렷하게)
- 같은 열쇠(종류-색-크기)면 다시 굽지 않기
- 눌림은 몸통만 두께만큼 내려가게

#### 5. 완성 기준 (이게 보이면 성공)
- 단계를 하나씩 켜 보면 두께 · 몸통 · 흰 테 · 윗빛 · 글자가 차례로 쌓인다
- 색 세트를 바꾸면 같은 모양의 다른 색 단추가 나온다
- 누르면 몸통이 두께만큼 내려가고 바닥 두께는 그대로다
- 확대해도 테두리 · 글자가 흐리지 않다

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

## 원리
- 바닥 두께: 몸통보다 edge(높이의 16%)만큼 아래에 진한 색 둥근 네모를 먼저 그린다 — 눌리면 몸통이 그만큼 내려간다.
- 몸통: 위 밝은 색 → 아래 색 세로 그러데이션.
- 흰 테: 몸통 안쪽으로 반 두께 들인 둥근 네모를 흰 선으로.
- 윗빛: 위쪽 36% 높이에 흰색 0.75 → 0.08 그러데이션 띠, 글자는 두께 색 테두리 + 흰 채움. 색 세트는 [위, 아래, 두께] 세 개뿐이라 옷 갈아입히기가 쉽다.

## 핵심 코드 — 반짝 단추 한 장 굽기 (두께 · 몸통 · 흰 테 · 윗빛)
(발췌: src/game/ui/glossy.ts paintGlossy() 를 캔버스만 쓰게 정리)
```ts
const SKINS = {
  blue: ['#7fbcff', '#2f74e8', '#1d4fb8'],
  orange: ['#ffcb7a', '#ff8a1f', '#c95e00'],
  correct: ['#7cf0b4', '#1fb868', '#138248'],
} as const; // [위 밝은 색, 아래 색, 두께 · 글자 테 색]
const RES = 2, PAD = 6;

function paintGlossy(skin: keyof typeof SKINS, w: number, h: number, edge = 9, radius = 30, border = 5) {
  const cv = document.createElement('canvas');
  cv.width = Math.ceil((w + PAD * 2) * RES);
  cv.height = Math.ceil((h + edge + PAD * 2) * RES);
  const ctx = cv.getContext('2d')!;
  ctx.scale(RES, RES);
  const [top, bottom, edgeColor] = SKINS[skin];
  const x = PAD, y = PAD, r = Math.min(radius, w / 2, h / 2);
  // 1) 두께 (바닥)
  ctx.fillStyle = edgeColor;
  ctx.beginPath(); ctx.roundRect(x, y + edge, w, h, r); ctx.fill();
  // 2) 몸통
  const face = ctx.createLinearGradient(0, y, 0, y + h);
  face.addColorStop(0, top);
  face.addColorStop(1, bottom);
  ctx.fillStyle = face;
  ctx.beginPath(); ctx.roundRect(x, y, w, h, r); ctx.fill();
  // 3) 흰 테
  ctx.lineWidth = border;
  ctx.strokeStyle = 'rgba(255,255,255,0.95)';
  ctx.beginPath(); ctx.roundRect(x + border / 2, y + border / 2, w - border, h - border, Math.max(4, r - border / 2)); ctx.stroke();
  // 4) 윗빛 띠 + 반짝 점
  const band = ctx.createLinearGradient(0, y + 8, 0, y + h * 0.5);
  band.addColorStop(0, 'rgba(255,255,255,0.55)');
  band.addColorStop(1, 'rgba(255,255,255,0.05)');
  ctx.fillStyle = band;
  const inset = Math.min(12, w * 0.05);
  ctx.beginPath(); ctx.roundRect(x + inset, y + 9, w - inset * 2, h * 0.42, [Math.max(4, r - 12), Math.max(4, r - 12), 14, 14]); ctx.fill();
  ctx.fillStyle = 'rgba(255,255,255,0.9)';
  ctx.beginPath(); ctx.ellipse(x + Math.min(34, w * 0.14), y + Math.min(26, h * 0.16), 9, 5, -0.5, 0, Math.PI * 2); ctx.fill();
  return cv; // 그릴 땐 1/RES 로 줄여서, 본체 중심은 y + edge/2 아래
}
```

## 흔한 실수 · 확인 목록
- [ ] **Phaser Graphics 로 그리면 그러데이션이 안 된다** — Canvas 2D 로 굽고 텍스처로 등록한다 (glossy.ts 가 이렇게 한다).
- [ ] **해상도 1배로 구우면 폰 고해상도 화면에서 흐리다** — 2배(RES 2)로 굽고 1/2 로 줄여 놓는다.
- [ ] **내용을 올릴 흰 카드에도 윗빛 띠를 넣으면 글자가 안 읽힌다** — 흰 카드(paper)는 gloss 를 끄고 테두리도 옅은 파랑으로.
- [ ] **이미지를 그냥 가운데에 두면 두께 때문에 단추가 아래로 치우친다** — 본체 중심이 맞도록 y + edge/2 에 놓는다 (glossyImage).

## 완성 기준 체크리스트
- [ ] 단계를 하나씩 켜 보면 두께 · 몸통 · 흰 테 · 윗빛 · 글자가 차례로 쌓인다
- [ ] 색 세트를 바꾸면 같은 모양의 다른 색 단추가 나온다
- [ ] 누르면 몸통이 두께만큼 내려가고 바닥 두께는 그대로다
- [ ] 확대해도 테두리 · 글자가 흐리지 않다

## 이 기술 정보
- id: `u60` · 분류: 2D · 화면 › 2D 그리기 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 크기 · 색마다 한 번 굽고(해상도 2배) 계속 재사용. 그리기 비용은 그림 한 장.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/u60
- 쓰면 좋을 때: 아이들 게임의 큰 단추 · 카드를 그림 파일 없이 만들 때 / Phaser 처럼 그러데이션을 직접 못 그리는 엔진에 쓸 텍스처
- 쓰지 말 때: 납작한(flat) 디자인 화면 — 어울리지 않는다 / 크기가 매 프레임 바뀌는 단추 — 그때마다 굽게 된다. 대신 크기별로 한 번 굽고 확대만

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### u60 견본 항목 — `src/demos/demosDraw2d.ts:1123`
```ts
  u60: {
    kind: '2d',
    caption: '반짝 단추를 한 겹씩 쌓아 굽기: 두께 → 몸통 그러데이션 → 흰 테 → 윗빛 → 글자 (색만 바꿔 여러 벌)',
    make() {
      const STEPS = ['두께', '몸통', '흰 테', '윗빛', '글자'];
      let hold = -1;
      return {
        draw(g, w, h, t) {
          const u = scaleOf(w, h);
          bg(g, w, h, '#2a3570', '#121735');
          const cyc = 7;
          const tt = t % cyc;
          const stage = hold >= 0 ? hold : Math.min(5, Math.floor(tt / 1.0));
          const sk = GLOSS[Math.floor(t / cyc) % GLOSS.length]!;
          // 왼쪽 단계 목록
          const lx = w * 0.06;
          for (let i = 0; i < 5; i++) {
            const y = h * 0.2 + i * h * 0.15;
            const done = stage > i;
            const now = stage === i;
            g.fillStyle = now ? 'rgba(255,255,255,.16)' : 'rgba(255,255,255,0)';
            rr(g, lx - 4 * u, y - 9 * u, w * 0.3, 18 * u, 6 * u);
            g.fill();
            g.fillStyle = done || now ? sk[1] : 'rgba(255,255,255,.18)';
            g.beginPath();
            g.arc(lx + 6 * u, y, 6.5 * u, 0, Math.PI * 2);
            g.fill();
            txt(g, done ? '✓' : String(i + 1), lx + 6 * u, y + 0.5, 7.5 * u, '#fff', 'center', F, 900);
            txt(g, STEPS[i]!, lx + 17 * u, y, 9 * u, done || now ? '#fff' : '#8e97c7', 'left');
          }
          // 단추
          const bw = w * 0.42;
          const bh = h * 0.3;
          const edge = bh * 0.16;
          const bx = w * 0.72 - bw / 2;
          const press = stage >= 5 ? Math.max(0, Math.sin(((tt - 5) / 2) * Math.PI * 3)) : 0;
          const by = h * 0.42 - bh / 2 + press * edge * 0.8;
          const rad = bh * 0.38;
          // 바닥 그림자
          g.fillStyle = 'rgba(0,0,0,.3)';
          g.beginPath();
          g.ellipse(bx + bw / 2, h * 0.42 + bh / 2 + edge * 1.3, bw * 0.48, edge * 0.7, 0, 0, Math.PI * 2);
          g.fill();
          if (stage >= 0) {
            rr(g, bx, h * 0.42 - bh / 2 + edge, bw, bh, rad);
            g.fillStyle = sk[2];
            g.fill();
          }
          if (stage >= 1) {
            const gr = g.createLinearGradient(0, by, 0, by + bh);
            gr.addColorStop(0, sk[0]);
            gr.addColorStop(1, sk[1]);
            rr(g, bx, by, bw, bh, rad);
            g.fillStyle = gr;
            g.fill();
          } else {
            g.setLineDash([4 * u, 3 * u]);
            g.strokeStyle = 'rgba(255,255,255,.4)';
            g.lineWidth = 1.5 * u;
            rr(g, bx, by, bw, bh, rad);
            g.stroke();
            g.setLineDash([]);
          }
          if (stage >= 2) {
            g.strokeStyle = '#fff';
            g.lineWidth = bh * 0.06;
            rr(g, bx + bh * 0.03, by + bh * 0.03, bw - bh * 0.06, bh - bh * 0.06, rad - bh * 0.03);
            g.stroke();
          }
          if (stage >= 3) {
            const gl = g.createLinearGradient(0, by, 0, by + bh * 0.5);
            gl.addColorStop(0, 'rgba(255,255,255,.75)');
            gl.addColorStop(1, 'rgba(255,255,255,.08)');
            rr(g, bx + bh * 0.14, by + bh * 0.1, bw - bh * 0.28, bh * 0.36, bh * 0.18);
            g.fillStyle = gl;
            g.fill();
          }
          if (stage >= 4) {
            g.font = `${bh * 0.42}px ${TF}`;
            g.textAlign = 'center';
            g.textBaseline = 'middle';
            g.lineJoin = 'round';
            g.lineWidth = bh * 0.1;
            g.strokeStyle = sk[2];
            g.strokeText('시작!', bx + bw / 2, by + bh * 0.54);
            g.fillStyle = '#fff';
            g.fillText('시작!', bx + bw / 2, by + bh * 0.54);
          }
          if (stage >= 5) {
            const sh = ((tt - 5) / 2) % 1;
            g.save();
            rr(g, bx, by, bw, bh, rad);
            g.clip();
            const x = bx - bw * 0.3 + sh * bw * 1.6;
            const gs = g.createLinearGradient(x - bw * 0.15, 0, x + bw * 0.15, 0);
            gs.addColorStop(0, 'rgba(255,255,255,0)');
            gs.addColorStop(0.5, 'rgba(255,255,255,.45)');
            gs.addColorStop(1, 'rgba(255,255,255,0)');
            g.fillStyle = gs;
            g.fillRect(bx, by, bw, bh);
            g.restore();
          }
          txt(g, stage >= 5 ? `완성 · ${sk[3]} 옷` : `${STEPS[Math.min(stage, 4)]} 굽는 중`, bx + bw / 2, h * 0.84, 9.5 * u, '#ffe28a');
        },
        controls: [{ type: 'range', label: '단계 멈추기 (-1 = 자동)', min: -1, max: 5, step: 1, value: -1, on: (v) => (hold = v) }] as Control[],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [캔버스 그러데이션 · 흐림 · 빛 섞기](https://ai-techstudio.web.app/ai/t/u52.md) `u52`
- 다음에 해 볼 기술: [2D 빛 반사 · 반짝 쓸기 (sheen)](https://ai-techstudio.web.app/ai/t/i301.md) `i301` · [9분할 그림 (캔버스로 한 장)](https://ai-techstudio.web.app/ai/t/u56.md) `u56`
- 참고 문서: [MDN — CanvasRenderingContext2D.roundRect()](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/roundRect)
