# AI 꾸러미 — 모드 7 바닥 (원근 평면) — Mode 7 (perspective floor)
> 평면 지도를 화면 아래 줄마다 「거리 = 높이 × 초점 ÷ 줄 번호」로 늘려 찍어, 옛 레이싱 게임처럼 깊이 있는 원근 바닥을 2D 로 만든다.  
> 견본: https://ai-techstudio.web.app/#t/i305

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

## 주문서

### 만들어 줘: 모드 7 바닥 (원근 평면) — Mode 7 (perspective floor)

#### 1. 목표
카트 경주 지도에 모드 7 원근 바닥을 만들어 줘 — 평면 지도를 줄마다 거리 계산으로 늘려 찍고, 멀수록 안개. 분위기는 옛 도트 게임.

#### 2. 핵심 기술 용어
- **Mode 7 (perspective floor)** — 모드 7 — 평면 지도를 원근으로 눕히기
- **Scanline floor casting** — 줄마다 거리 하나 — 그 줄을 지도에서 쓸어 읽기
- **ImageData · Uint32Array pixel buffer** — 픽셀을 32비트 정수로 직접 쓰기
- **Distance fog** — 멀수록 하늘색으로 섞기

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

#### 4. 조건
- 작은 버퍼(폭 160)에서 계산하고 imageSmoothingEnabled = false 로 늘려 그리기
- 픽셀은 Uint32Array 로 한 번에 쓰고 putImageData 한 번 (픽셀마다 fillRect 금지)
- 줄마다 시작점 · 한 칸 걸음을 미리 구해 픽셀 안에서는 더하기만
- 지도 밖은 바둑판 같은 반복 무늬로 채우기 (검게 비지 않게)

#### 5. 완성 기준 (이게 보이면 성공)
- 화면 아래쪽 바닥이 원근으로 누워 멀어질수록 촘촘해지고, 수평선 근처는 하늘색으로 흐려진다
- 카메라 높이 슬라이더를 올리면 하늘에서 내려다보듯 지도가 넓게 보인다
- 「평면 지도 보기」를 켜면 오른쪽 위에 원래 지도와 지금 보는 시야 부채꼴이 보인다
- 폰에서도 끊김 없이 돈다

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

## 원리
- 수평선 아래 줄 번호 sy (1, 2, 3 …) 마다 그 줄이 비추는 바닥까지의 거리 z = 카메라 높이 × 초점 ÷ sy. 가까운 줄일수록 sy 가 커서 z 가 작다.
- 그 줄 왼쪽 끝 = 카메라 앞 z 지점에서 옆으로 반 화면만큼. 한 픽셀 옆으로 갈 때 지도 위로 z ÷ 초점 만큼 옆 방향으로 이동한다.
- 줄마다 두 번 곱셈으로 시작점 · 걸음을 구하고, 픽셀마다 더하기만 하며 지도 색을 읽는다.
- 작은 화면(폭 160)에서 계산해 크게 늘려 그리면 도트 느낌도 살고 빠르다. 거리 z 로 하늘색 안개를 섞는다.

## 핵심 코드 — 줄마다 거리를 구해 지도를 쓸어 읽기
(발췌: demos/demos2dLook.ts mkI305() draw 의 바닥 부분을 정리)
```ts
const TS = 256;                           // 지도 크기 (tex = Uint32Array(TS*TS))
const LW = 160, focal = 70;              // 작은 화면 폭 · 초점 거리
function drawFloor(buf: Uint32Array, rows: number, tex: Uint32Array,
                   cx: number, cy: number, fx: number, fy: number, camH: number) {
  const rx = -fy, ry = fx;                // 옆 방향 (앞 방향을 90° 돌림)
  for (let row = 0; row < rows; row++) {
    const sy = row + 1;                   // 수평선에서 몇 줄 아래
    const z = (camH * focal) / sy;        // 이 줄이 비추는 거리
    const step = z / focal;               // 한 픽셀 = 지도 몇 칸
    let wx = cx + fx * z + rx * (-LW / 2) * step;
    let wy = cy + fy * z + ry * (-LW / 2) * step;
    const dx = rx * step, dy = ry * step;
    for (let x = 0; x < LW; x++) {
      const ix = Math.floor(wx), iy = Math.floor(wy);
      buf[row * LW + x] = ix >= 0 && iy >= 0 && ix < TS && iy < TS
        ? tex[iy * TS + ix]
        : ((ix >> 4) + (iy >> 4)) & 1 ? 0xff5ac86e : 0xff4eb65f; // 지도 밖 = 바둑판 풀
      wx += dx; wy += dy;                 // 픽셀 안은 더하기만
    }
  }
}
// 그린 뒤: lx.putImageData(im, 0, horizon + 1);
// g.imageSmoothingEnabled = false; g.drawImage(lowCanvas, 0, 0, w, h);
```

## 흔한 실수 · 확인 목록
- [ ] **큰 화면 해상도로 픽셀을 계산하면 폰에서 멈춘다** — 폭 160 작은 버퍼에서 계산해 늘려 그린다. 높이는 화면 비율에 맞춰 60 ~ 150 으로.
- [ ] **수평선 바로 아래 줄은 거리가 무한대처럼 커져 지글거린다** — 그 근처는 안개로 하늘색에 녹인다 (견본: z 40 ~ 260 사이에서 섞음).
- [ ] **Uint32Array 색을 RGBA 순서로 넣으면 색이 뒤집힌다** — 보통 컴퓨터는 리틀 엔디안이라 0xAABBGGRR 순서. pack(r, g, b) 같은 함수를 한 곳에 둔다.

## 완성 기준 체크리스트
- [ ] 화면 아래쪽 바닥이 원근으로 누워 멀어질수록 촘촘해지고, 수평선 근처는 하늘색으로 흐려진다
- [ ] 카메라 높이 슬라이더를 올리면 하늘에서 내려다보듯 지도가 넓게 보인다
- [ ] 「평면 지도 보기」를 켜면 오른쪽 위에 원래 지도와 지금 보는 시야 부채꼴이 보인다
- [ ] 폰에서도 끊김 없이 돈다

## 이 기술 정보
- id: `i305` · 분류: 2D · 화면 › 2D 의사 3D · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 160 × 약 64줄 = 1만 픽셀을 프레임마다 직접 씀. 큰 화면 해상도로 하면 수십 배 — 꼭 작은 버퍼에서.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i305
- 쓰면 좋을 때: 위에서 본 평면 지도를 달리는 느낌으로 바꾸고 싶을 때 / 3D 없이 가벼운 원근 바닥이 필요할 때
- 쓰지 말 때: 언덕 · 벽 같은 높이가 있는 땅 — 모드 7 은 평평한 바닥만. 대신 광선 투사(i306)나 의사 3D 도로(i307) / 이미 three.js 장면이 있을 때 — 바닥 평면 + 카메라가 더 간단하다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### mkI305 — `src/demos/demos2dLook.ts:2284`
```ts
function mkI305() {
  const TS = 256;
  const tex = new Uint32Array(TS * TS);
  const A = 92;
  const Bb = 80;
  const r = rng(7);
  const flowers: [number, number, number][] = [];
  for (let i = 0; i < 260; i++) flowers.push([Math.floor(r() * TS), Math.floor(r() * TS), Math.floor(r() * 3)]);
  for (let y = 0; y < TS; y++)
    for (let x = 0; x < TS; x++) {
      const dx = (x - 128) / A;
      const dy = (y - 128) / Bb;
      const d = Math.pow(dx ** 4 + dy ** 4, 0.25);
      const e = (d - 1) * 86;
      const ang = Math.atan2(dy, dx);
      const nz = hash2(x, y, 3) * 14 - 7;
      let c: number;
      if (Math.abs(e) < 11) {
        const v = 112 + nz;
        c = pack(v, v + 4, v + 16);
        if (Math.abs(e) < 0.9 && Math.floor((ang + Math.PI) * 18) % 2 === 0) c = pack(255, 255, 240);
        if (Math.abs(ang) < 0.06) c = (Math.floor(e / 3) + Math.floor(ang * 120)) & 1 ? pack(250, 250, 250) : pack(30, 30, 40);
      } else if (Math.abs(e) < 14.5) {
        c = Math.floor((ang + Math.PI) * 36) % 2 ? pack(240, 70, 80) : pack(250, 250, 250);
      } else {
        const dc = Math.hypot(x - 128, y - 128);
        if (dc < 30) {
          const wv = Math.sin(dc * 0.6) * 10;
          c = pack(70 + wv, 150 + wv, 235);
        } else if (dc < 34) c = pack(240, 220, 160);
        else {
          const ch = ((x >> 4) + (y >> 4)) & 1;
          const v = nz;
          c = ch ? pack(110 + v, 200 + v, 90 + v) : pack(95 + v, 182 + v, 78 + v);
        }
      }
      tex[y * TS + x] = c;
    }
  const FC = [pack(255, 120, 170), pack(255, 230, 90), pack(255, 255, 255)];
  for (const [fx, fy, k] of flowers) {
    const i = fy * TS + fx;
    const dx = (fx - 128) / A;
    const dy = (fy - 128) / Bb;
    const e = (Math.pow(dx ** 4 + dy ** 4, 0.25) - 1) * 86;
    if (Math.abs(e) > 18 && Math.hypot(fx - 128, fy - 128) > 36) {
      tex[i] = FC[k]!;
      if (fx + 1 < TS) tex[i + 1] = FC[k]!;
    }
  }
  const texC = mkCanvas(TS, TS);
  {
    const x = c2(texC);
    const im = x.createImageData(TS, TS);
    new Uint32Array(im.data.buffer).set(tex);
    x.putImageData(im, 0, 0);
  }
  const LW = 160;
  let LH = 100;
  let lr = mkCanvas(LW, LH);
  let lx = c2(lr);
  let im: ImageData | null = null;
  let buf: Uint32Array | null = null;
  let camH = 16;
  let mini = true;
  let spd = 1;
  let th = 0;
  const controls: Control[] = [
    { type: 'range', label: '카메라 높이', min: 5, max: 45, step: 1, value: camH, on: (v) => (camH = v) },
    { type: 'range', label: '속도', min: 0, max: 2.5, step: 0.1, value: spd, on: (v) => (spd = v) },
    { type: 'toggle', label: '평면 지도 보기', value: mini, on: (v) => (mini = v) },
  ];
  const posAt = (a: number): [number, number] => {
    const c = Math.cos(a);
    const s = Math.sin(a);
    return [128 + A * Math.sign(c) * Math.sqrt(Math.abs(c)), 128 + Bb * Math.sign(s) * Math.sqrt(Math.abs(s))];
  };
  const SKY = [150, 210, 255] as V3;
  const mtn = mkCanvas(320, 40);
  {
    const x = c2(mtn);
    for (let layer = 0; layer < 2; layer++) {
      x.fillStyle = layer ? '#7aa0c8' : '#a8c4e2';
      x.beginPath();
      x.moveTo(0, 40);
      for (let i = 0; i <= 320; i += 4) {
        const yy = 22 - layer * -6 - (Math.sin((i / 320) * TAU * 3 + layer) * 6 + Math.sin((i / 320) * TAU * 7 + layer * 2) * 3 + (layer ? 0 : 6));
        x.lineTo(i, yy);
      }
      x.lineTo(320, 40);
      x.fill();
    }
  }
  return {
    controls,
    draw(g: G, w: number, h: number, _t: number, dt: number) {
      reset(g);
      const want = clamp(Math.round((LW * h) / w), 60, 150);
      if (want !== LH || !im) {
        LH = want;
        lr = mkCanvas(LW, LH);
        lx = c2(lr);
        im = null;
      }
      const hz = Math.round(LH * 0.36);
      if (!im) {
        im = lx.createImageData(LW, LH - hz - 1);
        buf = new Uint32Array(im.data.buffer);
      }
      th += dt * 0.32 * spd;
      const p0 = posAt(th);
      const p1 = posAt(th + 0.02);
      let fx = p1[0] - p0[0];
      let fy = p1[1] - p0[1];
      const fl = Math.hypot(fx, fy) || 1;
      fx /= fl;
      fy /= fl;
      const rx = -fy;
      const ry = fx;
      const cx = p0[0] - fx * 16;
      const cy = p0[1] - fy * 16;
      const heading = Math.atan2(fy, fx);
      // 하늘
      const sg = lx.createLinearGradient(0, 0, 0, hz);
      sg.addColorStop(0, '#5aa8ff');
      sg.addColorStop(1, '#cfeaff');
      lx.fillStyle = sg;
      lx.fillRect(0, 0, LW, hz + 1);
      const off = ((((heading / TAU) * 320) % 320) + 320) % 320;
      lx.drawImage(mtn, -off, hz - 38);
      lx.drawImage(mtn, 320 - off, hz - 38);
      lx.fillStyle = 'rgba(255,255,255,.9)';
      for (let i = 0; i < 4; i++) {
        const x = ((((i * 90 - off * 0.6) % 360) + 360) % 360) - 30;
        lx.beginPath();
        lx.ellipse(x, 8 + (i % 2) * 7, 10, 3, 0, 0, TAU);
        lx.fill();
      }
      const focal = 70;
      const b = buf!;
      const rows = LH - hz - 1;
      for (let row = 0; row < rows; row++) {
        const sy = row + 1;
        const z = (camH * focal) / sy;
        const fog = clamp01((z - 40) / 220);
        const step = z / focal;
        let wx = cx + fx * z + rx * (-LW / 2) * step;
        let wy = cy + fy * z + ry * (-LW / 2) * step;
        const dx = rx * step;
        const dy = ry * step;
        const o = row * LW;
        for (let x = 0; x < LW; x++) {
          const ix = Math.floor(wx);
          const iy = Math.floor(wy);
          let c: number;
          if (ix >= 0 && iy >= 0 && ix < TS && iy < TS) c = tex[iy * TS + ix]!;
          else c = ((ix >> 4) + (iy >> 4)) & 1 ? pack(110, 200, 90) : pack(95, 182, 78);
          if (fog > 0) {
            const R0 = c & 255;
            const G0 = (c >> 8) & 255;
            const B0 = (c >> 16) & 255;
            c = pack(R0 + (SKY[0] - R0) * fog, G0 + (SKY[1] - G0) * fog, B0 + (SKY[2] - B0) * fog);
          }
          b[o + x] = c;
          wx += dx;
          wy += dy;
        }
      }
      lx.putImageData(im!, 0, hz + 1);
      g.imageSmoothingEnabled = false;
      g.drawImage(lr, 0, 0, w, h);
      g.imageSmoothingEnabled = true;
      const f = frame(g, w, h);
      // 카트
      const kx = 140;
      const ky = f.B - 26;
      const bob = Math.sin(_t * 18) * 0.6;
      g.fillStyle = 'rgba(0,0,0,.3)';
      g.beginPath();
      g.ellipse(kx, ky + 15, 30, 5, 0, 0, TAU);
      g.fill();
      g.fillStyle = '#2b2b3a';
      rr(g, kx - 30, ky + 2, 13, 14, 4);
      g.fill();
      rr(g, kx + 17, ky + 2, 13, 14, 4);
      g.fill();
      rr(g, kx - 24, ky - 6 + bob, 48, 18, 7);
      g.fillStyle = '#ff4f5e';
      g.fill();
      g.lineWidth = 1.5;
      g.strokeStyle = '#7a1622';
      g.stroke();
      g.drawImage(buddy(64, 'mint'), kx - 18, ky - 36 + bob, 36, 36);
      g.fillStyle = '#ffd84d';
      rr(g, kx - 10, ky + 4 + bob, 20, 5, 2);
      g.fill();
      if (mini) {
        const M = 56;
        const mx = 216;
        const my = 6;
        rr(g, mx - 2, my - 2, M + 4, M + 4, 4);
        g.fillStyle = 'rgba(0,0,0,.45)';
        g.fill();
        g.drawImage(texC, mx, my, M, M);
        const k = M / TS;
        const pcx = mx + cx * k;
        const pcy = my + cy * k;
        const far = 220;
        const half = (LW / 2 / focal) * far;
        g.fillStyle = 'rgba(255,240,120,.35)';
        g.beginPath();
        g.moveTo(pcx, pcy);
        g.lineTo(pcx + (fx * far - rx * half) * k, pcy + (fy * far - ry * half) * k);
        g.lineTo(pcx + (fx * far + rx * half) * k, pcy + (fy * far + ry * half) * k);
        g.closePath();
        g.save();
        g.clip();
        g.fillRect(mx, my, M, M);
        g.restore();
        g.fillStyle = '#ff4f5e';
        g.beginPath();
        g.arc(pcx, pcy, 2.2, 0, TAU);
        g.fill();
        txt(g, '평면 지도', mx + M / 2, my + M + 7, 6.5, '#fff');
      }
      pill(g, `줄마다 거리 z = 높이 × 초점 ÷ 줄 번호 → 지도를 늘려 찍음 (${LW}×${LH})`, 104, 12, 7, 'rgba(0,30,70,.5)', '#fff');
      g.restore();
    },
  };
}
```

### i305 견본 항목 — `src/demos/demos2dLook.ts:3788`
```ts
  i305: { kind: '2d', caption: '평면 경주 지도(오른쪽 위)를 줄마다 원근으로 늘려 깊이 있는 바닥 — 160폭 작은 화면에서 계산', make: mkI305 }
```

## 관련 기술
- 다음에 해 볼 기술: [광선 투사 미로 (1인칭 옛 방식)](https://ai-techstudio.web.app/ai/t/i306.md) `i306` · [의사 3D 도로 (줄마다 커브 누적)](https://ai-techstudio.web.app/ai/t/i307.md) `i307`
- 참고 문서: [Wikipedia — Mode 7](https://en.wikipedia.org/wiki/Mode_7)
