# AI 꾸러미 — 2D 카드 뒤집기 (스케일 흉내) — scaleX
> scaleX 를 1 → 0 → −1 로 + 그늘 — 3D 없이 뒤집히는 카드  
> 견본: https://ai-techstudio.web.app/#t/i311

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

## 주문서

### 만들어 줘: 2D 카드 뒤집기 (스케일 흉내) — scaleX

#### 1. 목표
기억 카드에 「2D 카드 뒤집기 (스케일 흉내)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — scaleX 를 1 → 0 → −1 로 + 그늘 — 3D 없이 뒤집히는 카드

#### 2. 핵심 기술 용어
- **scaleX**

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

#### 4. 조건
- devicePixelRatio 를 맞춰 또렷하게
- 매 프레임 다시 그릴 필요 없는 그림은 화면 밖 캔버스에 미리 구워 두기
- 한 프레임 16ms 안 (폰 포함)

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「scaleX 를 1 → 0 → −1 로 + 그늘 — 3D 없이 뒤집히는 카드」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- scaleX 를 1 → 0 → −1 로 + 그늘 — 3D 없이 뒤집히는 카드


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「scaleX 를 1 → 0 → −1 로 + 그늘 — 3D 없이 뒤집히는 카드」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i311` · 분류: 2D · 화면 › 2D 의사 3D · 2D · 난이도 쉬움 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i311
- 쓰면 좋을 때: 쓰면 좋을 곳 — 기억 카드 · 정답 공개

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### mkI311 — `src/demos/demos2dLook.ts:3626`
```ts
function mkI311() {
  const CW = 120;
  const CH = 168;
  const faceC = (n: string, col: string): HTMLCanvasElement => {
    const c = mkCanvas(CW, CH);
    const g = c2(c);
    rr(g, 3, 3, CW - 6, CH - 6, 14);
    g.fillStyle = '#fffaf0';
    g.fill();
    g.lineWidth = 5;
    g.strokeStyle = col;
    g.stroke();
    g.fillStyle = col;
    g.beginPath();
    for (let i = 0; i < 10; i++) {
      const a = -Math.PI / 2 + (i * Math.PI) / 5;
      const r = i % 2 ? 9 : 20;
      g.lineTo(60 + Math.cos(a) * r, 120 + Math.sin(a) * r);
    }
    g.closePath();
    g.fill();
    txt(g, n, 60, 62, 60, col, 'center', 900);
    txt(g, n, 18, 20, 16, col, 'center', 900);
    return c;
  };
  const back = mkCanvas(CW, CH);
  {
    const g = c2(back);
    rr(g, 3, 3, CW - 6, CH - 6, 14);
    const gr = g.createLinearGradient(0, 0, CW, CH);
    gr.addColorStop(0, '#7b5cff');
    gr.addColorStop(1, '#4a2fc0');
    g.fillStyle = gr;
    g.fill();
    g.save();
    g.clip();
    g.strokeStyle = 'rgba(255,255,255,.18)';
    g.lineWidth = 2;
    for (let i = -CH; i < CW + CH; i += 14) {
      g.beginPath();
      g.moveTo(i, 0);
      g.lineTo(i + CH, CH);
      g.moveTo(i + CH, 0);
      g.lineTo(i, CH);
      g.stroke();
    }
    g.restore();
    rr(g, 3, 3, CW - 6, CH - 6, 14);
    g.lineWidth = 5;
    g.strokeStyle = '#ffd84d';
    g.stroke();
    g.fillStyle = '#ffd84d';
    g.beginPath();
    g.arc(60, 84, 20, 0, TAU);
    g.fill();
    txt(g, '?', 60, 86, 28, '#4a2fc0', 'center', 900);
  }
  const faces = [faceC('3', '#ff5a7a'), faceC('7', '#2f9e8a'), faceC('5', '#ff9a3c')];
  let shade = true;
  let persp = true;
  let spd = 1;
  const controls: Control[] = [
    { type: 'toggle', label: '그늘 · 들림', value: shade, on: (v) => (shade = v) },
    { type: 'toggle', label: '원근 흉내 (세로 조각)', value: persp, on: (v) => (persp = v) },
    { type: 'range', label: '속도', min: 0.3, max: 2, step: 0.1, value: spd, on: (v) => (spd = v) },
  ];
  let tt = 0;
  return {
    controls,
    draw(g: G, w: number, h: number, _t: number, dt: number) {
      reset(g);
      const f = frame(g, w, h);
      tt += dt * spd;
      vbg(g, f, '#1d3b3a', '#0e1f22');
      // 펠트 무늬
      g.fillStyle = 'rgba(255,255,255,.03)';
      for (let y = 0; y < 175; y += 6) for (let x = (y / 6) % 2 ? 0 : 3; x < 280; x += 6) g.fillRect(x, y, 1.2, 1.2);
      let meter = 1;
      for (let i = 0; i < 3; i++) {
        const cyc = (tt / 3.2 + i * 0.18) % 1;
        // 0~0.2 뒤집기 → 앞, 0.5~0.7 뒤집기 → 뒤
        let a: number;
        if (cyc < 0.2) a = smooth(cyc / 0.2) * Math.PI;
        else if (cyc < 0.5) a = Math.PI;
        else if (cyc < 0.7) a = Math.PI + smooth((cyc - 0.5) / 0.2) * Math.PI;
        else a = 0;
        const sx = Math.cos(a);
        if (i === 1) meter = sx;
        const showFace = sx < 0;
        const img = showFace ? faces[i]! : back;
        const lift = shade ? Math.abs(Math.sin(a)) : 0;
        const cx = 60 + i * 80;
        const cy = 82 - lift * 8;
        const dw = 56;
        const dh = 78;
        // 그림자
        g.fillStyle = `rgba(0,0,0,${0.35 - lift * 0.15})`;
        g.beginPath();
        g.ellipse(cx + lift * 5, 124 + lift * 2, (dw / 2) * Math.max(0.15, Math.abs(sx)) + lift * 6, 5 + lift * 2, 0, 0, TAU);
        g.fill();
        const k = Math.abs(sx);
        if (persp) {
          const n = 16;
          const sa = Math.sin(a);
          for (let s2 = 0; s2 < n; s2++) {
            const u0 = s2 / n;
            const u1 = (s2 + 1) / n;
            const xl = (u0 - 0.5) * 2;
            const xr = (u1 - 0.5) * 2;
            const zl = xl * sa * (showFace ? -1 : 1);
            const zr = xr * sa * (showFace ? -1 : 1);
            const pl = 1 / (1 + zl * 0.18);
            const pr = 1 / (1 + zr * 0.18);
            const X0 = cx + xl * (dw / 2) * k * (showFace ? -1 : 1) * pl * (showFace ? -1 : 1);
            const X1 = cx + xr * (dw / 2) * k * pr;
            const hh = dh * (pl + pr) * 0.5;
            const srcU = u0;
            g.drawImage(img, srcU * CW, 0, CW / n + 0.5, CH, Math.min(X0, X1), cy - hh / 2, Math.abs(X1 - X0) + 0.4, hh);
          }
        } else {
          g.drawImage(img, cx - (dw / 2) * k, cy - dh / 2, dw * k, dh);
        }
        if (shade && k < 0.98) {
          g.fillStyle = `rgba(0,0,0,${(1 - k) * 0.45})`;
          rr(g, cx - (dw / 2) * k, cy - dh / 2, dw * k, dh, 6 * k);
          g.fill();
          if (k > 0.05) {
            g.fillStyle = `rgba(255,255,255,${(1 - k) * 0.25})`;
            g.fillRect(cx - (dw / 2) * k, cy - dh / 2, Math.max(1, dw * k * 0.15), dh);
          }
        }
      }
      // scaleX 계기
      const mx = 140;
      rr(g, mx - 70, 142, 140, 14, 7);
      g.fillStyle = 'rgba(255,255,255,.08)';
      g.fill();
      g.fillStyle = meter >= 0 ? '#7b5cff' : '#2f9e8a';
      const bw = 66 * Math.abs(meter);
      if (meter >= 0) g.fillRect(mx, 145, bw, 8);
      else g.fillRect(mx - bw, 145, bw, 8);
      g.fillStyle = '#fff';
      g.fillRect(mx - 0.5, 143, 1, 12);
      txt(g, `가운데 카드 scaleX = ${meter.toFixed(2)}  (0 을 지나면 앞면으로 바꿔 그림)`, mx, 166, 7.5, '#cfeee8');
      pill(g, '3D 없이 가로 크기만 1 → 0 → −1', 140, 13, 8, 'rgba(255,255,255,.1)', '#e8fffb');
      g.restore();
    },
  };
}
```

### i311 견본 항목 — `src/demos/demos2dLook.ts:3794`
```ts
  i311: { kind: '2d', caption: '가로 크기만 1 → 0 → −1, 0 을 지날 때 앞면으로 바꾸고 그늘 · 들림 · 세로 조각 원근을 더함', make: mkI311 }
```

## 관련 기술
- 이 효과를 만든 기술: [CSS 3D (카드 뒤집기)](https://ai-techstudio.web.app/ai/t/u66.md) `u66`
