# AI 꾸러미 — 그림자 · 반사 (2D 바닥) — Projected sprite shadow (skew + squash)
> 같은 그림을 납작하게 눌러 해 반대쪽으로 기울이면 바닥 그림자, 거꾸로 뒤집어 줄마다 흔들면 물 반사가 되어 2D 장면에 깊이가 생긴다.  
> 견본: https://ai-techstudio.web.app/#t/i297

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

## 주문서

### 만들어 줘: 그림자 · 반사 (2D 바닥) — Projected sprite shadow (skew + squash)

#### 1. 목표
물가에 선 캐릭터 · 버섯에 2D 바닥 그림자와 물 반사를 넣어 줘 — 그림을 눌러 해 반대쪽으로 기울인 흐린 그림자, 물에는 거꾸로 뒤집어 줄마다 흔든 반사. 분위기는 노을 진 물가.

#### 2. 핵심 기술 용어
- **Projected sprite shadow (skew + squash)** — 눌러 기울인 그림자
- **Water reflection (flip + scanline wave)** — 거꾸로 뒤집고 줄마다 흔든 반사
- **ctx.transform(a, b, c, d, e, f)** — 캔버스 기울이기 · 누르기 행렬
- **Silhouette tint (source-atop)** — 그림 모양 그대로 한 색으로 칠하기

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

#### 4. 조건
- 그림자는 원래 그림을 한 색 + 흐림으로 미리 구워 두기 (매 프레임 흐림 금지)
- 그림자 기울기는 해 위치를 따라가게
- 반사는 물 영역 clip 안에서만, 줄마다 다른 위상으로 흔들기
- 반사는 아래로 갈수록 옅게
- 그림자 · 반사 켬/끔 · 물결 세기 조절

#### 5. 완성 기준 (이게 보이면 성공)
- 해가 왼쪽에서 오른쪽으로 가면 그림자가 반대쪽으로 기운다
- 친구가 뛰어오르면 그림자가 옅고 작아진다
- 물에 거꾸로 비친 그림이 줄마다 일렁인다
- 물결 세기 0 이면 반사가 거울처럼 또렷하다

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

## 원리
- 그림자: 그림을 어두운 보라 한 색으로 칠하고(source-atop) 3px 흐리게 구워 둔다.
- 발 자리에서 transform(1, 0, skew, 0.32, 0, 0) — 세로를 0.32 로 누르고 위쪽을 옆으로 밀어 해 반대쪽으로 눕힌다. 해가 움직이면 skew 가 따라 바뀐다.
- 뛰어오르면 그림자를 옅게(0.45 → 최대 60% 줄임) · 작게 한다.
- 반사: 물 아래만 clip 하고 그림을 22줄로 잘라 줄마다 위아래 뒤집어 그린다. 줄마다 sin(3t + 0.7·i) × 물결 세기만큼 옆으로 밀고, 아래로 갈수록 옅게.

## 핵심 코드 — 눌러 기울인 그림자 + 줄마다 흔든 물 반사
(발췌: demos/demos2dLook.ts mkI297() putObj() 를 정리)
```ts
const S = 128; // 원래 그림 크기
function tinted(src: HTMLCanvasElement, col: string) {
  const c = document.createElement('canvas');
  c.width = src.width; c.height = src.height;
  const x = c.getContext('2d')!;
  x.drawImage(src, 0, 0);
  x.globalCompositeOperation = 'source-atop'; // 그림 모양 안에만 칠하기
  x.fillStyle = col;
  x.fillRect(0, 0, c.width, c.height);
  return c;
}
function shadowOf(src: HTMLCanvasElement) {
  const c = document.createElement('canvas');
  c.width = c.height = S + 24;
  const x = c.getContext('2d')!;
  x.filter = 'blur(3px)';
  x.drawImage(tinted(src, '#1a1030'), 12, 12);
  return c;
}

function putObj(g: CanvasRenderingContext2D, img: HTMLCanvasElement, sh: HTMLCanvasElement, x: number, D: number, hop: number, t: number, skew: number, GROUND = 112, WATER = 118, wave = 1) {
  const k = D / S;
  // 그림자: 세로 0.32 로 누르고 skew 만큼 눕히기
  g.save();
  g.translate(x, GROUND);
  g.transform(1, 0, skew, 0.32, 0, 0);
  const up = Math.min(1, hop / 40);
  g.globalAlpha = 0.45 * (1 - up * 0.6);
  const sc = 1 - up * 0.2;
  g.drawImage(sh, (-(S + 24) / 2) * k * sc, -(12 + S * 0.9) * k * sc, (S + 24) * k * sc, (S + 24) * k * sc);
  g.restore();
  // 반사: 물 아래만, 22줄로 잘라 뒤집고 줄마다 흔들기
  g.save();
  g.beginPath(); g.rect(x - D, WATER, D * 2, 200); g.clip();
  const strips = 22;
  for (let i = 0; i < strips; i++) {
    const sh2 = (S * 0.9) / strips;
    const sy0 = S * 0.9 - sh2 * (i + 1);
    const dy = GROUND + hop + (S * 0.9 - sy0 - sh2) * k;
    const off = Math.sin(t * 3 + i * 0.7) * wave * (0.4 + i * 0.06);
    g.globalAlpha = 0.5 * (1 - i / strips) + 0.1;
    g.save();
    g.translate(x - D / 2 + off, dy + sh2 * k);
    g.scale(1, -1);
    g.drawImage(img, 0, sy0, S, sh2, 0, 0, D, sh2 * k + 0.6);
    g.restore();
  }
  g.restore();
  g.globalAlpha = 1;
  g.drawImage(img, x - D / 2, GROUND - hop - S * 0.9 * k, D, D); // 원래 그림
}
// skew = Math.sin(t * 0.35) * 1.4 — 해와 반대로
```

## 흔한 실수 · 확인 목록
- [ ] **매 프레임 filter blur 로 그림자를 흐리면 폰에서 느리다** — 한 색 + 흐림으로 한 번 구워 두고 drawImage 로 찍는다.
- [ ] **반사를 통째로 한 장 뒤집으면 물결이 안 생긴다** — 줄로 잘라 줄마다 다른 위상으로 옆으로 민다.
- [ ] **줄 사이에 가는 틈이 보인다** — 줄 높이를 0.6px 더 크게 그려 겹친다.
- [ ] **반사가 물 밖 땅까지 그려진다** — 물 영역을 clip 한 뒤에 그린다.

## 완성 기준 체크리스트
- [ ] 해가 왼쪽에서 오른쪽으로 가면 그림자가 반대쪽으로 기운다
- [ ] 친구가 뛰어오르면 그림자가 옅고 작아진다
- [ ] 물에 거꾸로 비친 그림이 줄마다 일렁인다
- [ ] 물결 세기 0 이면 반사가 거울처럼 또렷하다

## 이 기술 정보
- id: `i297` · 분류: 2D · 화면 › 2D 그림 효과 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 그림자는 미리 구운 그림 한 장, 반사는 물체마다 drawImage 22번. 물체 몇 개는 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i297
- 쓰면 좋을 때: 위에서 비스듬히 보는 2D 장면에 바닥감 · 깊이감을 줄 때 / 물가 장면에 반사를 넣을 때
- 쓰지 말 때: 위에서 바로 내려다보는 판 — 기울인 그림자보다 발밑 동그란 그림자가 맞다 / 반사할 물체가 아주 많을 때 — 22줄 × 물체 수만큼 drawImage. 대신 장면을 한 번 그린 캔버스를 통째로 뒤집기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### mkI297 — `src/demos/demos2dLook.ts:833`
```ts
function mkI297() {
  const S = 128;
  const bud = buddy(S, 'lemon');
  const mush = mkCanvas(S, S);
  drawMushroom(c2(mush), S);
  const shadowOf = (src: HTMLCanvasElement): HTMLCanvasElement => {
    const c = mkCanvas(S + 24, S + 24);
    const x = c2(c);
    x.filter = 'blur(3px)';
    x.drawImage(tinted(src, '#1a1030'), 12, 12);
    return c;
  };
  const shB = shadowOf(bud);
  const shM = shadowOf(mush);
  let doShadow = true;
  let doRefl = true;
  let wave = 1;
  const controls: Control[] = [
    { type: 'toggle', label: '그림자', value: doShadow, on: (v) => (doShadow = v) },
    { type: 'toggle', label: '물 반사', value: doRefl, on: (v) => (doRefl = v) },
    { type: 'range', label: '물결 세기', min: 0, max: 3, step: 0.1, value: wave, on: (v) => (wave = v) },
  ];
  const GROUND = 112;
  const WATER = 118;
  const putObj = (g: G, img: HTMLCanvasElement, sh: HTMLCanvasElement, x: number, D: number, hop: number, t: number, skew: number): void => {
    const fy = GROUND;
    if (doShadow) {
      g.save();
      g.translate(x, fy);
      g.transform(1, 0, skew, 0.32, 0, 0);
      g.globalAlpha = 0.45 * (1 - clamp01(hop / 40) * 0.6);
      const k = D / S;
      const sc = 1 - clamp01(hop / 40) * 0.2;
      g.drawImage(sh, (-(S + 24) / 2) * k * sc, -(12 + S * 0.9) * k * sc, (S + 24) * k * sc, (S + 24) * k * sc);
      g.restore();
    }
    if (doRefl) {
      g.save();
      g.beginPath();
      g.rect(x - D, WATER, D * 2, 200);
      g.clip();
      const strips = 22;
      const k = D / S;
      for (let i = 0; i < strips; i++) {
        const sy0 = S * 0.9 - (S * 0.9 * (i + 1)) / strips;
        const sh2 = (S * 0.9) / strips;
        const dy = fy + hop + (S * 0.9 - sy0 - sh2) * k;
        const off = Math.sin(t * 3 + i * 0.7) * wave * (0.4 + i * 0.06);
        g.globalAlpha = 0.5 * (1 - i / strips) + 0.1;
        g.save();
        g.translate(x - D / 2 + off, dy + sh2 * k);
        g.scale(1, -1);
        g.drawImage(img, 0, sy0, S, sh2, 0, 0, D, sh2 * k + 0.6);
        g.restore();
      }
      g.restore();
      g.globalAlpha = 1;
    }
    g.drawImage(img, x - D / 2, fy - hop - S * 0.9 * (D / S), D, D);
  };
  return {
    controls,
    draw(g: G, w: number, h: number, t: number) {
      reset(g);
      const f = frame(g, w, h);
      // 노을 하늘
      const sky = g.createLinearGradient(0, f.T, 0, GROUND);
      sky.addColorStop(0, '#3b2a6b');
      sky.addColorStop(0.55, '#e0789a');
      sky.addColorStop(1, '#ffc98a');
      g.fillStyle = sky;
      g.fillRect(f.L, f.T, f.R - f.L, GROUND - f.T);
      const sunX = 140 - Math.sin(t * 0.35) * 90;
      spot(g, sunX, 72, 60, [255, 240, 190], 0.6);
      g.fillStyle = '#fff1c4';
      g.beginPath();
      g.arc(sunX, 72, 11, 0, TAU);
      g.fill();
      // 먼 언덕
      g.fillStyle = '#9a5c8f';
      g.beginPath();
      g.moveTo(f.L, GROUND);
      for (let x = f.L; x <= f.R; x += 8) g.lineTo(x, 92 + Math.sin(x * 0.03) * 6 + Math.sin(x * 0.07) * 3);
      g.lineTo(f.R, GROUND);
      g.fill();
      // 둑
      g.fillStyle = '#6fbf5a';
      g.fillRect(f.L, 98, f.R - f.L, WATER - 98);
      g.fillStyle = '#4e9a45';
      g.fillRect(f.L, WATER - 3, f.R - f.L, 3);
      // 물
      const wg = g.createLinearGradient(0, WATER, 0, f.B);
      wg.addColorStop(0, '#5a7fd0');
      wg.addColorStop(1, '#203a7a');
      g.fillStyle = wg;
      g.fillRect(f.L, WATER, f.R - f.L, f.B - WATER);
      // 해 반사
      g.globalCompositeOperation = 'lighter';
      for (let i = 0; i < 10; i++) {
        const y = WATER + 4 + i * 5;
        const ww = 18 - i + Math.sin(t * 4 + i) * 4;
        g.fillStyle = `rgba(255,220,160,${0.25 - i * 0.02})`;
        g.fillRect(sunX - ww / 2 + Math.sin(t * 3 + i) * 2 * wave, y, ww, 1.4);
      }
      g.globalCompositeOperation = 'source-over';
      const skew = Math.sin(t * 0.35) * 1.4;
      // 버섯
      putObj(g, mush, shM, 205, 54, 0, t, skew);
      // 친구 — 좌우로 폴짝
      const ph = (t * 0.9) % 2;
      const bx = 60 + (ph < 1 ? ph : 2 - ph) * 80;
      const hop = Math.abs(Math.sin(t * 5.6)) * 14;
      putObj(g, bud, shB, bx, 62, hop, t, skew);
      // 물결 줄
      g.strokeStyle = 'rgba(255,255,255,.25)';
      g.lineWidth = 0.8;
      for (let i = 0; i < 7; i++) {
        const y = WATER + 10 + i * 8;
        const x0 = ((i * 53 + t * 8 * (i % 2 ? 1 : -1)) % 300) - 20;
        g.beginPath();
        g.moveTo(x0, y);
        g.lineTo(x0 + 14 + (i % 3) * 6, y);
        g.stroke();
      }
      pill(g, '그림자 = 눌러 기울인 그림 · 반사 = 거꾸로 + 줄마다 흔들기', 140, 12, 7.5, 'rgba(30,15,60,.45)', '#fff');
      g.restore();
    },
  };
}
```

### i297 견본 항목 — `src/demos/demos2dLook.ts:3780`
```ts
  i297: { kind: '2d', caption: '같은 그림을 납작하게 눌러 해 쪽 반대로 기울인 그림자 + 거꾸로 뒤집어 줄마다 흔든 물 반사', make: mkI297 }
```

## 관련 기술
- 먼저 알면 좋은 기술: [캔버스 그러데이션 · 흐림 · 빛 섞기](https://ai-techstudio.web.app/ai/t/u52.md) `u52`
- 다음에 해 볼 기술: [2D 잔상 꼬리 · 대시 잔상](https://ai-techstudio.web.app/ai/t/i298.md) `i298`
- 참고 문서: [MDN — CanvasRenderingContext2D.transform()](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/transform)
