# AI 꾸러미 — 2.5D 시차 깊이 (층층 무대) — Parallax scrolling (2.5D layers)
> 먼 산 · 언덕 · 마을 · 풀 · 앞 잎 다섯 층을 깊이만큼 다른 빠르기(0.12 ~ 1.7배)로 흘리고, 먼 층엔 안개를 덮어 납작한 그림을 깊이 있는 무대로 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i309

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

## 주문서

### 만들어 줘: 2.5D 시차 깊이 (층층 무대) — Parallax scrolling (2.5D layers)

#### 1. 목표
이야기 장면 배경을 2.5D 시차 무대로 만들어 줘 — 먼 산부터 앞 잎까지 층을 깊이만큼 다른 빠르기로, 먼 층은 안개. 분위기는 분홍빛 노을 그림책.

#### 2. 핵심 기술 용어
- **Parallax scrolling (2.5D layers)** — 시차 스크롤 — 층마다 다른 빠르기
- **Atmospheric perspective** — 먼 것은 옅고 하늘색으로
- **Seamless tiling layer** — 이어 붙여도 이음새 없는 반복 층
- **source-atop tint** — 그린 곳에만 색 덮기

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

#### 4. 조건
- 층 그림은 화면 크기가 바뀔 때만 다시 굽기 (매 프레임 그리기 금지)
- 층 그림 무늬는 폭 W 에 주기를 맞춰 이음새 없게 + 두 번 찍어 이어 붙이기
- 오프셋은 (cam × k) mod W 를 양수로 맞춰서 (음수 나머지 주의)
- 먼 층은 옅고 푸르게 — 대비 차이가 깊이감의 반
- 시차 끔/켬 비교 토글

#### 5. 완성 기준 (이게 보이면 성공)
- 카메라가 옆으로 흐를 때 먼 산은 느리게, 풀 · 앞 잎은 빠르게 지나가 깊이가 느껴진다
- 「시차」를 끄면 한 장 그림처럼 납작해진다
- 깊이 차이 슬라이더로 0(납작) ~ 2(과장)를 고를 수 있다
- 층 이음새가 보이지 않는다

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

## 원리
- 카메라가 cam 만큼 움직이면 층은 cam × k 만큼 흐른다. k 가 작으면 멀리, 크면 가까이 — 먼 산 0.12 · 언덕 0.3 · 마을 0.6 · 풀 1 · 앞 잎 1.7.
- 「깊이 차이」 d 로 k 를 1 + (k − 1) × d 로 바꾸면 0 일 때 모두 같은 빠르기(납작), 2 일 때 깊이가 과장된다.
- 층 그림은 화면 폭 W 로 한 장 그리되 사인 주기를 W 에 맞춰 이음새가 없게 하고, −off 와 W − off 두 번 찍어 끝없이 이어 간다.
- 먼 층일수록 source-atop 으로 하늘빛을 덮어 옅게 한다. 캐릭터는 가운데 층과 함께 움직인다.

## 핵심 코드 — 층마다 다른 빠르기로 이어 그리기
(발췌: demos/demos2dLook.ts mkI309() draw 의 층 그리기를 정리)
```ts
interface Layer { img: HTMLCanvasElement; k: number } // k = 카메라 대비 빠르기
// 먼 산 0.12 · 언덕 0.3 · 마을 0.6 · 풀 1 · 앞 잎 1.7 — 그림은 화면 크기 바뀔 때만 다시 굽는다
function drawLayers(g: CanvasRenderingContext2D, lays: Layer[], cam: number,
                    W: number, H: number, depth = 1, parallax = true) {
  for (const L of lays) {
    const k = parallax ? 1 + (L.k - 1) * depth : 1;  // depth 0 = 납작, 2 = 과장
    const off = ((((cam * k) % W) + W) % W) | 0;      // 음수 나머지도 양수로
    g.drawImage(L.img, -off, 0, W, H);                // 두 번 찍어 끝없이
    g.drawImage(L.img, W - off, 0, W, H);
  }
}
// 이음새 없는 언덕 선: 사인 주기를 폭 W 에 맞춘다
const per = (x: number, W: number, n: number, ph: number) => Math.sin((x / W) * Math.PI * 2 * n + ph);
// 먼 층 안개: 다 그린 뒤 g.globalCompositeOperation = 'source-atop' 으로 하늘빛 그라데이션을 덮는다
// 카메라: const cam = t * 0.09 * w + Math.sin(t * 0.6) * w * 0.05;
```

## 흔한 실수 · 확인 목록
- [ ] **층 그림을 매 프레임 새로 그리면 폰에서 느려진다** — 화면 크기(W×H)가 바뀔 때만 굽고, 매 프레임은 drawImage 두 번씩만.
- [ ] **반복 경계에 이음새 선이 보인다** — 언덕 · 산 사인 주기를 폭 W 의 정수배로 맞추고, 소품은 x − W · x + W 에도 함께 그린다.
- [ ] **앞 층 흐림 · 비네트로 깊이를 얼버무리면 이 사이트에선 거절된다** — 견본의 앞 잎 흐림은 고를 수 있는 표현일 뿐. 실제 게임은 빠르기 차이와 먼 층 옅게 하기로 깊이를 낸다 (사용자 결정).

## 완성 기준 체크리스트
- [ ] 카메라가 옆으로 흐를 때 먼 산은 느리게, 풀 · 앞 잎은 빠르게 지나가 깊이가 느껴진다
- [ ] 「시차」를 끄면 한 장 그림처럼 납작해진다
- [ ] 깊이 차이 슬라이더로 0(납작) ~ 2(과장)를 고를 수 있다
- [ ] 층 이음새가 보이지 않는다

## 이 기술 정보
- id: `i309` · 분류: 2D · 화면 › 2D 의사 3D · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 층 다섯 장 × 2번 drawImage. 층 그림은 화면 크기가 바뀔 때만 다시 굽는다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i309
- 쓰면 좋을 때: 정지 그림 배경에 깊이와 움직임을 주고 싶을 때 / 옆으로 걷는 이야기 장면 · 메뉴 배경
- 쓰지 말 때: 카메라가 위아래 · 앞뒤로 크게 움직이는 장면 — 층이 판으로 드러난다. 대신 three.js 진짜 3D / 층이 10겹 넘게 많을 때 — 큰 그림 drawImage 가 쌓여 폰에서 무겁다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### mkI309 — `src/demos/demos2dLook.ts:3202`
```ts
function mkI309() {
  let par = true;
  let depth = 1;
  let fx = true;
  let built = '';
  interface Lay {
    plain: HTMLCanvasElement;
    fx: HTMLCanvasElement;
    k: number;
  }
  let lays: Lay[] = [];
  const build = (W: number, H: number): void => {
    const per = (x: number, n: number, ph: number): number => Math.sin((x / W) * TAU * n + ph);
    const mk = (fn: (g: G, isFx: boolean) => void): { plain: HTMLCanvasElement; fx: HTMLCanvasElement } => {
      const a = mkCanvas(W, H);
      const b = mkCanvas(W, H);
      fn(c2(a), false);
      fn(c2(b), true);
      return { plain: a, fx: b };
    };
    const rnd = rng(5);
    const mtnSeed = [rnd() * 6, rnd() * 6, rnd() * 6];
    const far = mk((g, isFx) => {
      g.fillStyle = '#8f8fd8';
      g.beginPath();
      g.moveTo(0, H);
      for (let x = 0; x <= W; x += 3) g.lineTo(x, H * 0.48 - (per(x, 3, mtnSeed[0]!) * 0.5 + per(x, 7, mtnSeed[1]!) * 0.25 + per(x, 13, 1) * 0.08 + 0.4) * H * 0.18);
      g.lineTo(W, H);
      g.fill();
      if (isFx) {
        const gr = g.createLinearGradient(0, H * 0.3, 0, H * 0.7);
        gr.addColorStop(0, 'rgba(255,220,235,.55)');
        gr.addColorStop(1, 'rgba(255,220,235,.75)');
        g.globalCompositeOperation = 'source-atop';
        g.fillStyle = gr;
        g.fillRect(0, 0, W, H);
      }
    });
    const hill = mk((g, isFx) => {
      g.fillStyle = '#5fae8a';
      g.beginPath();
      g.moveTo(0, H);
      const hy = (x: number): number => H * 0.62 - (per(x, 2, 2) * 0.5 + per(x, 5, 1) * 0.3 + 0.5) * H * 0.1;
      for (let x = 0; x <= W; x += 3) g.lineTo(x, hy(x));
      g.lineTo(W, H);
      g.fill();
      const n = Math.max(6, Math.round(W / 40));
      for (let i = 0; i < n; i++) {
        const x = (i / n) * W + ((i * 37) % 17);
        const y = hy(x) + 4;
        const s = H * 0.035;
        for (const xx of [x, x - W, x + W]) {
          g.fillStyle = '#3f8f6a';
          g.beginPath();
          g.arc(xx, y - s, s, 0, TAU);
          g.fill();
        }
      }
      if (isFx) {
        g.globalCompositeOperation = 'source-atop';
        g.fillStyle = 'rgba(255,225,235,.32)';
        g.fillRect(0, 0, W, H);
      }
    });
    const mid = mk((g) => {
      const gy = H * 0.74;
      g.fillStyle = '#86d36a';
      g.fillRect(0, gy, W, H - gy);
      g.fillStyle = '#e8c98e';
      g.beginPath();
      g.moveTo(0, gy + H * 0.05);
      for (let x = 0; x <= W; x += 6) g.lineTo(x, gy + H * 0.05 + per(x, 3, 0) * H * 0.012);
      for (let x = W; x >= 0; x -= 6) g.lineTo(x, gy + H * 0.1 + per(x, 3, 0) * H * 0.012);
      g.fill();
      const n = Math.max(3, Math.round(W / 140));
      for (let i = 0; i < n; i++) {
        const x = ((i + 0.3) / n) * W;
        const s = H * 0.12;
        // 작은 집
        g.fillStyle = '#fff1d6';
        g.fillRect(x - s * 0.5, gy - s * 0.7, s, s * 0.7);
        g.fillStyle = i % 2 ? '#ff7a59' : '#5b8cff';
        g.beginPath();
        g.moveTo(x - s * 0.62, gy - s * 0.68);
        g.lineTo(x, gy - s * 1.15);
        g.lineTo(x + s * 0.62, gy - s * 0.68);
        g.fill();
        g.fillStyle = '#7fd0ff';
        g.fillRect(x - s * 0.3, gy - s * 0.5, s * 0.2, s * 0.18);
        g.fillStyle = '#8a5a3a';
        g.fillRect(x + s * 0.08, gy - s * 0.38, s * 0.2, s * 0.38);
        // 울타리
        g.fillStyle = '#fff';
        for (let k = 0; k < 6; k++) g.fillRect(x + s * 0.8 + k * s * 0.18, gy - s * 0.22, s * 0.06, s * 0.22);
        g.fillRect(x + s * 0.78, gy - s * 0.16, s * 1.05, s * 0.04);
      }
    });
    const near = mk((g) => {
      const gy = H * 0.9;
      g.fillStyle = '#4f9d3f';
      g.beginPath();
      g.moveTo(0, H);
      for (let x = 0; x <= W; x += 4) g.lineTo(x, gy + per(x, 6, 1) * H * 0.012);
      g.lineTo(W, H);
      g.fill();
      const n = Math.round(W / 10);
      for (let i = 0; i < n; i++) {
        const x = (i / n) * W;
        const hh = H * (0.03 + hash2(i, 1, 2) * 0.04);
        g.strokeStyle = '#3d8a33';
        g.lineWidth = 2;
        g.beginPath();
        g.moveTo(x, gy + 4);
        g.quadraticCurveTo(x + 2, gy - hh * 0.5, x + 4, gy - hh);
        g.stroke();
        if (i % 5 === 0) {
          g.fillStyle = ['#ffe14d', '#ff8fb8', '#ffffff'][i % 3]!;
          g.beginPath();
          g.arc(x + 4, gy - hh, H * 0.012, 0, TAU);
          g.fill();
        }
      }
    });
    const fore = mk((g, isFx) => {
      if (isFx) g.filter = `blur(${Math.max(2, H * 0.012)}px)`;
      const n = 3;
      for (let i = 0; i < n; i++) {
        const x = ((i + 0.15) / n) * W;
        g.fillStyle = '#1f4a2a';
        for (const xx of [x, x - W, x + W]) {
          for (let k = 0; k < 5; k++) {
            g.save();
            g.translate(xx + k * H * 0.04, H + H * 0.02);
            g.rotate(-0.9 + k * 0.4);
            g.beginPath();
            g.ellipse(0, -H * 0.16, H * 0.05, H * 0.17, 0, 0, TAU);
            g.fill();
            g.restore();
          }
        }
      }
    });
    lays = [
      { ...far, k: 0.12 },
      { ...hill, k: 0.3 },
      { ...mid, k: 0.6 },
      { ...near, k: 1.0 },
      { ...fore, k: 1.7 },
    ];
  };
  const controls: Control[] = [
    { type: 'toggle', label: '시차 (층마다 다른 속도)', value: par, on: (v) => (par = v) },
    { type: 'range', label: '깊이 차이', min: 0, max: 2, step: 0.1, value: depth, on: (v) => (depth = v) },
    { type: 'toggle', label: '안개 · 앞 흐림', value: fx, on: (v) => (fx = v) },
  ];
  return {
    controls,
    draw(g: G, w: number, h: number, t: number) {
      reset(g);
      const W = Math.max(64, Math.round(w));
      const H = Math.max(64, Math.round(h));
      const key = W + 'x' + H;
      if (key !== built) {
        build(W, H);
        built = key;
      }
      const sky = g.createLinearGradient(0, 0, 0, h);
      sky.addColorStop(0, '#7db4ff');
      sky.addColorStop(0.55, '#ffd0e0');
      sky.addColorStop(1, '#ffe9c8');
      g.fillStyle = sky;
      g.fillRect(0, 0, w, h);
      g.fillStyle = 'rgba(255,250,220,.95)';
      g.beginPath();
      g.arc(w * 0.78, h * 0.2, h * 0.07, 0, TAU);
      g.fill();
      const cam = t * 0.09 * w + Math.sin(t * 0.6) * w * 0.05;
      for (let i = 0; i < 3; i++) {
        const cx = ((((i * w * 0.4 - cam * 0.05 * (par ? 1 : 12)) % (w * 1.3)) + w * 1.3) % (w * 1.3)) - w * 0.15;
        g.fillStyle = 'rgba(255,255,255,.85)';
        g.beginPath();
        g.ellipse(cx, h * (0.12 + i * 0.06), w * 0.06, h * 0.025, 0, 0, TAU);
        g.ellipse(cx + w * 0.03, h * (0.1 + i * 0.06), w * 0.04, h * 0.03, 0, 0, TAU);
        g.fill();
      }
      for (let i = 0; i < lays.length; i++) {
        const L = lays[i]!;
        const k = par ? 1 + (L.k - 1) * depth : 1;
        const off = ((((cam * k) % W) + W) % W) | 0;
        const img = fx ? L.fx : L.plain;
        g.drawImage(img, -off, 0, W, H);
        g.drawImage(img, W - off, 0, W, H);
        if (i === 2) {
          // 걷는 친구 (가운데 층과 함께)
          const b = buddy(96, 'pink');
          const s = h * 0.16;
          const hop = Math.abs(Math.sin(t * 7)) * h * 0.02;
          g.fillStyle = 'rgba(0,0,0,.2)';
          g.beginPath();
          g.ellipse(w * 0.42, h * 0.835, s * 0.3, s * 0.06, 0, 0, TAU);
          g.fill();
          g.drawImage(b, w * 0.42 - s / 2, h * 0.835 - s * 0.9 - hop, s, s);
        }
      }
      const f = frame(g, w, h);
      pill(g, par ? '먼 층 0.12 · 언덕 0.3 · 마을 0.6 · 풀 1 · 앞 잎 1.7 배 속도' : '시차 끔 → 한 장 그림처럼 납작', 140, 12, 7.5, 'rgba(60,40,90,.4)', '#fff');
      g.restore();
      void f;
    },
  };
}
```

### i309 견본 항목 — `src/demos/demos2dLook.ts:3792`
```ts
  i309: { kind: '2d', caption: '먼 산 · 언덕 · 마을 · 풀 · 앞 잎 층을 깊이만큼 다른 속도로 — 먼 층은 안개, 앞 잎은 흐림', make: mkI309 }
```

## 관련 기술
- 먼저 알면 좋은 기술: [의사 3D 도로 (줄마다 커브 누적)](https://ai-techstudio.web.app/ai/t/i307.md) `i307`
- 다음에 해 볼 기술: [등각 깊이 정렬 (큰 물체 · 겹침)](https://ai-techstudio.web.app/ai/t/i310.md) `i310`
