# AI 꾸러미 — 의사 3D 도로 (줄마다 커브 누적) — Pseudo-3D road (segment projection)
> 도로를 짧은 조각 수백 개로 나누고 조각마다 커브를 누적 · 높이를 투영해, 굽이치고 오르내리는 옛 아케이드식 도로를 2D 사다리꼴로 그린다.  
> 견본: https://ai-techstudio.web.app/#t/i307

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

## 주문서

### 만들어 줘: 의사 3D 도로 (줄마다 커브 누적) — Pseudo-3D road (segment projection)

#### 1. 목표
달리기 · 경주 게임에 의사 3D 도로를 만들어 줘 — 조각마다 커브를 누적하고 높이를 투영해 굽이 · 언덕 · 줄무늬 연석 · 나무 스프라이트. 분위기는 맑은 낮 아케이드.

#### 2. 핵심 기술 용어
- **Pseudo-3D road (segment projection)** — 의사 3D 도로 — 조각마다 투영한 사다리꼴
- **Curve accumulation (dx += curve)** — 커브를 더하고 또 더해 굽이 만들기
- **Perspective projection scale = depth / z** — 멀수록 작아지는 크기 비율
- **Painter's algorithm (far to near sprites)** — 먼 나무부터 그리기

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

#### 4. 조건
- 커브는 x 를 바로 바꾸지 말고 dx 에 더하기 (누적의 누적 — 그래야 매끈하다)
- 가까운 조각부터 투영하고, 지금까지 가장 위의 y(maxy)보다 아래인 조각은 그리지 않기 (언덕 가림)
- 나무 · 표지는 도로를 다 그린 뒤 먼 것부터, 그 조각의 clip 높이로 잘라 그리기
- 도로 끝을 처음 높이로 이어 끝없이 돌게 (마지막 높이를 비율로 빼기)
- 멀수록 연한 하늘색으로 섞기

#### 5. 완성 기준 (이게 보이면 성공)
- 도로가 왼쪽 · 오른쪽으로 굽이치고, 언덕을 넘을 때 너머 도로가 가려졌다 드러난다
- 빨강 · 흰 연석과 차선 줄무늬가 빠르게 다가와 속도감이 난다
- 커브 세기 0 이면 곧은 길, 2 면 크게 휜다. 「언덕」을 끄면 평지
- 나무가 도로 옆에서 멀리 작게 → 가까이 크게 다가온다

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

## 원리
- 도로를 길이 200 짜리 조각으로 나누고, 조각마다 커브 값과 높이 y 를 둔다. 커브는 구간 앞뒤 1/4 에서 부드럽게 들어가고 나온다.
- 가까운 조각부터 140개를 보며 x += dx; dx += 커브. 커브를 「누적의 누적」으로 쌓으니 도로가 부드럽게 휜다.
- 크기 비율 s = 깊이 ÷ 거리 z. 화면 x = 가운데 + s × x, 화면 y = 가운데 − s × (높이 − 카메라 높이), 도로 폭 = s × 2000.
- 앞 조각보다 위로 올라온 조각만 그리고(maxy), 가려지는 조각은 건너뛴다 — 언덕 너머가 저절로 숨는다. 3조각마다 연석 빨강 · 흰 줄을 바꾼다.

## 핵심 코드 — 조각마다 커브 누적 + 투영
(발췌: demos/demos2dLook.ts mkI307() draw 의 투영 부분을 정리)
```ts
interface Seg { curve: number; y: number; i: number }
const SEGL = 200, ROADW = 2000, CAMH = 1000, DRAW = 140;
const DEPTH = 1 / Math.tan((50 * Math.PI) / 180);

function project(segs: Seg[], pos: number, camY: number, w: number, h: number, curveK = 1) {
  const N = segs.length, total = N * SEGL;
  const baseI = Math.floor(pos / SEGL) % N;
  const basePct = (pos % SEGL) / SEGL;
  let x = 0, dx = -(segs[baseI].curve * curveK * basePct);
  let maxy = h;
  const quads: { x1: number; y1: number; w1: number; x2: number; y2: number; w2: number; seg: Seg }[] = [];
  for (let n = 0; n < DRAW; n++) {
    const seg = segs[(baseI + n) % N], nxt = segs[(baseI + n + 1) % N];
    const z1 = seg.i * SEGL + (seg.i < baseI ? total : 0) - pos, z2 = z1 + SEGL;
    const s1 = DEPTH / Math.max(1e-3, z1), s2 = DEPTH / Math.max(1e-3, z2);
    const q = {
      x1: w / 2 + s1 * x * (w / 2),        y1: h / 2 - s1 * (seg.y - camY) * (h / 2), w1: s1 * ROADW * (w / 2),
      x2: w / 2 + s2 * (x + dx) * (w / 2), y2: h / 2 - s2 * (nxt.y - camY) * (h / 2), w2: s2 * ROADW * (w / 2),
      seg,
    };
    x += dx;
    dx += seg.curve * curveK;              // 커브 누적의 누적
    if (z1 <= DEPTH || q.y2 >= q.y1 || q.y2 >= maxy) continue; // 뒤 · 언덕에 가림
    quads.push(q);                          // 이 사다리꼴을 연석(1.15배) · 길 순서로 칠한다
    maxy = q.y2;
  }
  return quads;
}
```

## 흔한 실수 · 확인 목록
- [ ] **커브를 x 에 바로 더하면 도로가 꺾인 선처럼 각진다** — dx 에 커브를 더하고 x 에 dx 를 더한다 — 두 번 누적해야 부드럽다.
- [ ] **언덕 너머 도로가 앞 도로 위에 겹쳐 그려진다** — 가까운 조각부터 그리며 maxy 보다 아래인 조각은 건너뛴다.
- [ ] **나무가 언덕 뒤인데 앞에 보인다** — 나무는 도로를 다 그린 뒤 먼 것부터, 그 조각을 그릴 때의 clip 높이로 잘라 그린다.
- [ ] **한 바퀴 돌 때 높이가 툭 끊긴다** — 견본은 마지막 높이를 조각 순서 비율로 빼서 끝 = 처음 높이로 맞춘다.

## 완성 기준 체크리스트
- [ ] 도로가 왼쪽 · 오른쪽으로 굽이치고, 언덕을 넘을 때 너머 도로가 가려졌다 드러난다
- [ ] 빨강 · 흰 연석과 차선 줄무늬가 빠르게 다가와 속도감이 난다
- [ ] 커브 세기 0 이면 곧은 길, 2 면 크게 휜다. 「언덕」을 끄면 평지
- [ ] 나무가 도로 옆에서 멀리 작게 → 가까이 크게 다가온다

## 이 기술 정보
- id: `i307` · 분류: 2D · 화면 › 2D 의사 3D · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 사다리꼴 140개 × 3겹(연석 · 길 · 차선) + 나무 스프라이트. 폰에서도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i307
- 쓰면 좋을 때: 끝없이 달리는 경주 · 달리기 게임 / 가벼운 「속도감」 배경
- 쓰지 말 때: 교차로 · 갈림길 · 자유롭게 도는 길 — 한 줄 도로만 된다. 대신 three.js 3D 도로 / 위에서 보는 지도 — 모드 7(i305)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### mkI307 — `src/demos/demos2dLook.ts:2788`
```ts
function mkI307() {
  interface Seg {
    curve: number;
    y: number;
    i: number;
    tree: number;
    sign: number;
  }
  const SEGL = 200;
  const ROADW = 2000;
  const CAMH = 1000;
  const DEPTH = 1 / Math.tan((50 * Math.PI) / 180);
  const DRAW = 140;
  let segs: Seg[] = [];
  let hills = true;
  let curveK = 1;
  let spd = 1;
  const build = (): void => {
    segs = [];
    const secs: [number, number, number][] = [
      [60, 0, 0],
      [80, 3, 20],
      [50, 0, -20],
      [90, -4, 0],
      [40, 0, 30],
      [70, 2.5, -30],
      [60, -2, 10],
      [50, 0, -10],
      [70, 4, 0],
    ];
    let lastY = 0;
    const r = rng(3);
    for (const [len, curve, hill] of secs) {
      const y0 = lastY;
      const y1 = lastY + (hills ? hill * SEGL * 0.4 : 0);
      for (let n = 0; n < len; n++) {
        const k = n / len;
        const ease = k < 0.25 ? smooth(k / 0.25) : k > 0.75 ? smooth((1 - k) / 0.25) : 1;
        const y = y0 + (y1 - y0) * (0.5 - Math.cos(Math.PI * k) / 2);
        const i = segs.length;
        segs.push({ curve: curve * ease, y, i, tree: i % 7 === 0 ? (r() < 0.5 ? -1 : 1) * (1.6 + r() * 1.2) : 0, sign: curve !== 0 && ease > 0.9 && i % 9 === 0 ? (curve > 0 ? -1 : 1) : 0 });
      }
      lastY = y1;
    }
    // 끝을 처음 높이로
    const N = segs.length;
    const endY = segs[N - 1]!.y;
    for (let i = 0; i < N; i++) segs[i]!.y -= (endY * i) / N;
  };
  build();
  let pos = 0;
  let skyOff = 0;
  const controls: Control[] = [
    { type: 'range', label: '커브 세기', min: 0, max: 2, step: 0.1, value: curveK, on: (v) => (curveK = v) },
    {
      type: 'toggle',
      label: '언덕',
      value: hills,
      on: (v) => {
        hills = v;
        build();
      },
    },
    { type: 'range', label: '속도', min: 0, max: 2, step: 0.1, value: spd, on: (v) => (spd = v) },
  ];
  interface Pj {
    x1: number;
    y1: number;
    w1: number;
    x2: number;
    y2: number;
    w2: number;
    s1: number;
    clip: number;
    seg: Seg;
    fog: number;
  }
  const pj: Pj[] = [];
  const tree = (g: G, x: number, yb: number, s: number): void => {
    g.fillStyle = '#7a4a2a';
    g.fillRect(x - s * 0.08, yb - s * 0.45, s * 0.16, s * 0.45);
    g.fillStyle = '#2f9e44';
    g.beginPath();
    g.arc(x, yb - s * 0.62, s * 0.32, 0, TAU);
    g.arc(x - s * 0.22, yb - s * 0.5, s * 0.22, 0, TAU);
    g.arc(x + s * 0.22, yb - s * 0.5, s * 0.22, 0, TAU);
    g.fill();
    g.fillStyle = '#69d36e';
    g.beginPath();
    g.arc(x - s * 0.08, yb - s * 0.72, s * 0.14, 0, TAU);
    g.fill();
  };
  const mix = (a: V3, b: V3, k: number): string => rgba(mix3(a, b, k));
  const HAZE: V3 = [200, 225, 255];
  return {
    controls,
    draw(g: G, w: number, h: number, _t: number, dt: number) {
      reset(g);
      const N = segs.length;
      const total = N * SEGL;
      const speed = 5200 * spd;
      pos = (pos + speed * dt) % total;
      const baseI = Math.floor(pos / SEGL) % N;
      const base = segs[baseI]!;
      const basePct = (pos % SEGL) / SEGL;
      const playerZ = CAMH * DEPTH;
      const pI = Math.floor((pos + playerZ) / SEGL) % N;
      const pSeg = segs[pI]!;
      const pNext = segs[(pI + 1) % N]!;
      const pPct = ((pos + playerZ) % SEGL) / SEGL;
      const playerY = lerp(pSeg.y, pNext.y, pPct);
      const camY = CAMH + playerY;
      skyOff += pSeg.curve * curveK * spd * dt * 0.02;
      // 하늘
      const sg = g.createLinearGradient(0, 0, 0, h * 0.6);
      sg.addColorStop(0, '#4aa3ff');
      sg.addColorStop(1, '#d6ecff');
      g.fillStyle = sg;
      g.fillRect(0, 0, w, h);
      const sunX = w * 0.7 - skyOff * w * 0.3;
      const sx = ((sunX % (w * 1.4)) + w * 1.4) % (w * 1.4) - w * 0.2;
      g.fillStyle = 'rgba(255,250,210,.9)';
      g.beginPath();
      g.arc(sx, h * 0.18, h * 0.07, 0, TAU);
      g.fill();
      const hills2 = (k: number, col: string, amp: number, yb: number): void => {
        g.fillStyle = col;
        g.beginPath();
        g.moveTo(0, h);
        for (let x = 0; x <= w + 8; x += 8) {
          const q = (x / w + skyOff * k) * TAU;
          g.lineTo(x, h * yb - (Math.sin(q * 1.5) * 0.5 + Math.sin(q * 3.7 + 1) * 0.3 + 0.8) * h * amp);
        }
        g.lineTo(w, h);
        g.fill();
      };
      hills2(0.3, '#a9c6ea', 0.12, 0.55);
      hills2(0.6, '#7cc47a', 0.07, 0.58);
      // 투영
      pj.length = 0;
      let x = 0;
      let dx = -(base.curve * curveK * basePct);
      let maxy = h;
      for (let n = 0; n < DRAW; n++) {
        const seg = segs[(baseI + n) % N]!;
        const nxt = segs[(baseI + n + 1) % N]!;
        const looped = seg.i < baseI;
        const z1 = seg.i * SEGL + (looped ? total : 0) - pos;
        const z2 = z1 + SEGL;
        const s1 = DEPTH / Math.max(1e-3, z1);
        const s2 = DEPTH / Math.max(1e-3, z2);
        const p: Pj = {
          x1: w / 2 + s1 * x * (w / 2),
          y1: h / 2 - s1 * (seg.y - camY) * (h / 2),
          w1: s1 * ROADW * (w / 2),
          x2: w / 2 + s2 * (x + dx) * (w / 2),
          y2: h / 2 - s2 * (nxt.y - camY) * (h / 2),
          w2: s2 * ROADW * (w / 2),
          s1,
          clip: maxy,
          seg,
          fog: clamp01(n / DRAW) ** 1.5,
        };
        x += dx;
        dx += seg.curve * curveK;
        pj.push(p);
        if (z1 <= DEPTH || p.y2 >= p.y1 || p.y2 >= maxy) {
          p.w1 = -1;
          continue;
        }
        const alt = Math.floor(seg.i / 3) % 2;
        g.fillStyle = mix(alt ? [126, 217, 87] : [96, 190, 64], HAZE, p.fog);
        g.fillRect(0, p.y2, w, p.y1 - p.y2 + 1);
        const quad = (xa: number, ya: number, wa: number, xb: number, yb: number, wb: number, col: string): void => {
          g.fillStyle = col;
          g.beginPath();
          g.moveTo(xa - wa, ya);
          g.lineTo(xa + wa, ya);
          g.lineTo(xb + wb, yb);
          g.lineTo(xb - wb, yb);
          g.closePath();
          g.fill();
        };
        const r1 = p.w1 * 1.15;
        const r2 = p.w2 * 1.15;
        quad(p.x1, p.y1 + 0.5, r1, p.x2, p.y2, r2, mix(alt ? [255, 90, 95] : [255, 255, 255], HAZE, p.fog));
        quad(p.x1, p.y1 + 0.5, p.w1, p.x2, p.y2, p.w2, mix(alt ? [110, 114, 130] : [102, 106, 122], HAZE, p.fog));
        if (alt) {
          const l1 = p.w1 * 0.03;
          const l2 = p.w2 * 0.03;
          for (const off of [-1 / 3, 1 / 3]) quad(p.x1 + p.w1 * off * 2, p.y1, l1, p.x2 + p.w2 * off * 2, p.y2, l2, mix([255, 255, 240], HAZE, p.fog));
        }
        maxy = p.y2;
      }
      // 나무 · 표지 (먼 것부터)
      for (let n = pj.length - 1; n >= 0; n--) {
        const p = pj[n]!;
        const seg = p.seg;
        if ((!seg.tree && !seg.sign) || p.s1 <= 0) continue;
        const z1 = DEPTH / p.s1;
        if (z1 <= DEPTH) continue;
        g.save();
        g.beginPath();
        g.rect(0, 0, w, p.clip);
        g.clip();
        g.globalAlpha = 1 - p.fog * 0.7;
        if (seg.tree) {
          const tx = p.x1 + p.s1 * seg.tree * ROADW * (w / 2);
          tree(g, tx, p.y1, p.s1 * 2600 * (w / 2));
        }
        if (seg.sign) {
          const sx2 = p.x1 + p.s1 * seg.sign * 1.35 * ROADW * (w / 2);
          const ss = p.s1 * 700 * (w / 2);
          g.fillStyle = '#555';
          g.fillRect(sx2 - ss * 0.05, p.y1 - ss * 0.9, ss * 0.1, ss * 0.9);
          g.fillStyle = '#ffd43c';
          g.fillRect(sx2 - ss * 0.5, p.y1 - ss * 1.4, ss, ss * 0.6);
          g.fillStyle = '#d6303a';
          const dir = -seg.sign;
          g.beginPath();
          g.moveTo(sx2 - ss * 0.25 * dir, p.y1 - ss * 1.3);
          g.lineTo(sx2 + ss * 0.25 * dir, p.y1 - ss * 1.1);
          g.lineTo(sx2 - ss * 0.25 * dir, p.y1 - ss * 0.9);
          g.lineTo(sx2 - ss * 0.08 * dir, p.y1 - ss * 1.1);
          g.closePath();
          g.fill();
        }
        g.restore();
      }
      g.globalAlpha = 1;
      // 차
      const f = frame(g, w, h);
      const steer = clamp(-pSeg.curve * curveK * 0.15, -0.2, 0.2);
      const cx = 140;
      const cy = f.B - 22 + Math.sin(_t * 30) * 0.4 * spd;
      g.save();
      g.translate(cx, cy);
      g.rotate(steer * 0.4);
      g.fillStyle = 'rgba(0,0,0,.3)';
      g.beginPath();
      g.ellipse(0, 12, 36, 6, 0, 0, TAU);
      g.fill();
      g.fillStyle = '#20202c';
      rr(g, -34, 0, 14, 12, 3);
      g.fill();
      rr(g, 20, 0, 14, 12, 3);
      g.fill();
      rr(g, -32, -12, 64, 20, 8);
      const cg = g.createLinearGradient(0, -12, 0, 8);
      cg.addColorStop(0, '#ff6b78');
      cg.addColorStop(1, '#c81e3a');
      g.fillStyle = cg;
      g.fill();
      g.lineWidth = 1.5;
      g.strokeStyle = '#5a0f1a';
      g.stroke();
      rr(g, -20, -26, 40, 16, 7);
      g.fillStyle = '#ff6b78';
      g.fill();
      g.stroke();
      rr(g, -16, -23, 32, 10, 4);
      g.fillStyle = '#9fdcff';
      g.fill();
      g.fillStyle = '#ffe680';
      g.fillRect(-28, -6, 9, 4);
      g.fillRect(19, -6, 9, 4);
      g.fillStyle = '#ffffff';
      g.fillRect(-4, -12, 8, 20);
      g.restore();
      pill(g, '줄마다 커브를 누적 → 굽이 · 높이를 투영 → 언덕', 140, 12, 7.5, 'rgba(0,40,90,.45)', '#fff');
      g.restore();
    },
  };
}
```

### i307 견본 항목 — `src/demos/demos2dLook.ts:3790`
```ts
  i307: { kind: '2d', caption: '도로 조각마다 커브를 누적하고 높이를 투영 — 굽이 · 언덕 · 빨강흰 연석 · 나무 스프라이트', make: mkI307 }
```

## 관련 기술
- 먼저 알면 좋은 기술: [모드 7 바닥 (원근 평면)](https://ai-techstudio.web.app/ai/t/i305.md) `i305`
- 다음에 해 볼 기술: [2.5D 시차 깊이 (층층 무대)](https://ai-techstudio.web.app/ai/t/i309.md) `i309`
- 참고 문서: [Lou's Pseudo 3d Page](http://www.extentofthejam.com/pseudo/)
