# AI 꾸러미 — 광선 투사 미로 (1인칭 옛 방식) — Raycasting (Wolfenstein-style)
> 화면 세로줄마다 광선 하나를 칸 지도에 쏘아 벽까지의 거리로 벽 높이 · 어둡기 · 무늬 줄을 정해, 옛 1인칭 미로를 2D 계산만으로 그린다.  
> 견본: https://ai-techstudio.web.app/#t/i306

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

## 주문서

### 만들어 줘: 광선 투사 미로 (1인칭 옛 방식) — Raycasting (Wolfenstein-style)

#### 1. 목표
1인칭 미로를 광선 투사 1인칭 화면으로 만들어 줘 — 세로줄마다 광선 하나, 벽까지 거리로 높이 · 어둡기, 위에 미니 지도. 분위기는 따뜻한 벽돌 · 나무 미로.

#### 2. 핵심 기술 용어
- **Raycasting (Wolfenstein-style)** — 광선 투사 — 세로줄마다 광선 하나
- **DDA grid traversal** — 칸 경계를 하나씩 건너며 벽 찾기
- **Fisheye correction (perpendicular distance)** — 어안 보정 — 곧은 거리 대신 수직 거리
- **Floor casting** — 바닥도 줄마다 거리 계산

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

#### 4. 조건
- 벽 높이는 수직 거리로 (어안 보정) — 곧은 거리 쓰면 벽이 휜다
- DDA 반복에 상한(64칸) — 지도 밖으로 나가도 멈추게, 지도 밖은 벽으로
- 한쪽 면(side 1)을 더 어둡게, 거리로 안개
- 작은 버퍼(폭 160)에서 계산하고 imageSmoothingEnabled = false 로 늘리기
- 미니 지도에 광선이 닿은 점들을 부채꼴로 함께

#### 5. 완성 기준 (이게 보이면 성공)
- 복도를 걸어가는 1인칭 화면에서 가까운 벽은 크고 먼 벽은 작고 어둡다
- 「어안 보정」을 끄면 곧은 벽이 둥글게 휘어 보이고, 켜면 반듯해진다
- 시야각 40° ~ 110° 를 바꾸면 망원 ↔ 넓은 렌즈처럼 바뀐다
- 미니 지도에 지금 자리와 광선 부채꼴이 보인다

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

## 원리
- 화면 x 를 −1 ~ 1 로 바꾸고 광선 방향 = 앞 방향 + 옆(평면) 벡터 × x. 옆 벡터 길이 = tan(시야각 ÷ 2).
- DDA: 다음 세로 경계까지 거리(sdx)와 다음 가로 경계까지 거리(sdy) 중 짧은 쪽으로 한 칸씩 건너며, 벽 칸을 만나면 멈춘다.
- 수직 거리 perp = 마지막으로 늘린 쪽 거리 − 한 칸 거리. 벽 높이 = 화면 높이 ÷ perp. 곧은 거리를 쓰면 벽이 둥글게 휜다(어안).
- 세로 경계에 닿았나 가로 경계에 닿았나(side)로 한쪽 면을 0.72배 어둡게 — 그것만으로 입체감이 생긴다. 벽 위 어디에 닿았나로 무늬 열(tx)을 고른다.

## 핵심 코드 — 세로줄 하나의 DDA 광선
(발췌: demos/demos2dLook.ts mkI306() draw 의 벽 광선 부분을 정리)
```ts
/** 한 세로줄 x 의 벽까지 수직 거리 · 닿은 면 · 칸 번호 */
function castColumn(x: number, LW: number, MAP: number[][], px: number, py: number,
                    dirX: number, dirY: number, plX: number, plY: number) {
  const camX = (2 * x) / LW - 1;               // -1 ~ 1
  const rdx = dirX + plX * camX, rdy = dirY + plY * camX;
  let mx = Math.floor(px), my = Math.floor(py);
  const ddx = Math.abs(1 / rdx), ddy = Math.abs(1 / rdy); // 한 칸 건너는 거리
  const stx = rdx < 0 ? -1 : 1, sty = rdy < 0 ? -1 : 1;
  let sdx = rdx < 0 ? (px - mx) * ddx : (mx + 1 - px) * ddx;
  let sdy = rdy < 0 ? (py - my) * ddy : (my + 1 - py) * ddy;
  let side = 0, cell = 0;
  for (let k = 0; k < 64; k++) {               // 상한 — 지도 밖에서 멈추게
    if (sdx < sdy) { sdx += ddx; mx += stx; side = 0; }
    else { sdy += ddy; my += sty; side = 1; }
    cell = MAP[my]?.[mx] ?? 1;                  // 지도 밖 = 벽
    if (cell) break;
  }
  const perp = side === 0 ? sdx - ddx : sdy - ddy; // 수직 거리 = 어안 보정
  let wallX = side === 0 ? py + perp * rdy : px + perp * rdx;
  wallX -= Math.floor(wallX);                   // 벽 위 0 ~ 1 → 무늬 열
  return { perp, side, cell, wallX };
}
// 시야각 fov: const pl = Math.tan(fov / 2 * Math.PI / 180); plX = -dirY * pl; plY = dirX * pl;
// 벽 높이 = LH / Math.max(0.05, perp), 면 어둡기 = side ? 0.72 : 1
```

## 흔한 실수 · 확인 목록
- [ ] **광선 길이를 그대로 쓰면 곧은 벽이 둥글게 휜다** — 견본처럼 수직 거리(perp)를 쓴다. 「어안 보정」 끔/켬으로 차이를 보여 줄 수 있다.
- [ ] **광선 방향이 정확히 축과 같으면 1/0 이 된다** — JS 에선 Infinity 가 되어 그 축으로는 건너지 않을 뿐 동작한다. 다른 언어에선 아주 큰 수로 바꾼다.
- [ ] **벽 무늬가 한쪽 면에서 거울처럼 뒤집힌다** — 광선이 +x 쪽 면 · −y 쪽 면에 닿으면 tx = T − 1 − tx 로 뒤집는다.
- [ ] **벽에 바짝 붙으면 높이가 무한대가 된다** — 거리 최소값(0.05)을 둔다.

## 완성 기준 체크리스트
- [ ] 복도를 걸어가는 1인칭 화면에서 가까운 벽은 크고 먼 벽은 작고 어둡다
- [ ] 「어안 보정」을 끄면 곧은 벽이 둥글게 휘어 보이고, 켜면 반듯해진다
- [ ] 시야각 40° ~ 110° 를 바꾸면 망원 ↔ 넓은 렌즈처럼 바뀐다
- [ ] 미니 지도에 지금 자리와 광선 부채꼴이 보인다

## 이 기술 정보
- id: `i306` · 분류: 2D · 화면 › 2D 의사 3D · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 광선 160개 × 최대 64칸 + 바닥 픽셀. 작은 버퍼(160 폭)에서 계산하면 폰도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i306
- 쓰면 좋을 때: 가벼운 1인칭 미로 · 탐험 / 「거리가 두 배면 크기가 절반」 원근을 보여 줄 때
- 쓰지 말 때: 위아래로 보기 · 층이 있는 맵 — 광선 투사는 평평한 한 층만. 대신 three.js 진짜 3D / 비스듬한 벽 · 둥근 방 — 칸 지도로는 안 된다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### mkI306 — `src/demos/demos2dLook.ts:2515`
```ts
function mkI306() {
  const MAP = [
    '1111222211114441',
    '1..............1',
    '1..............1',
    '1..222...3.....2',
    '1..2.....3.....2',
    '1..2..4..3.4...2',
    '3..............1',
    '3..............1',
    '3..............1',
    '1..33.....222..1',
    '1...3.....2....4',
    '1...3.4...2..3.4',
    '1.........2....4',
    '1..............1',
    '1..............1',
    '1111333311112221',
  ].map((r) => r.split('').map((c) => (c === '.' ? 0 : +c)));
  const MW = 16;
  const T = 32;
  const texs: Uint32Array[] = [];
  const mkTex = (fn: (x: number, y: number) => V3): Uint32Array => {
    const a = new Uint32Array(T * T);
    for (let y = 0; y < T; y++)
      for (let x = 0; x < T; x++) {
        const c = fn(x, y);
        a[y * T + x] = pack(c[0], c[1], c[2]);
      }
    return a;
  };
  texs[1] = mkTex((x, y) => {
    const row = Math.floor(y / 8);
    const bx = (x + (row % 2) * 8) % 16;
    const mortar = y % 8 === 0 || bx === 0;
    const n = hash2(x >> 1, y >> 1, 1) * 30;
    return mortar ? [235, 215, 200] : [222 - n, 112 - n * 0.5, 92 - n * 0.5];
  });
  texs[2] = mkTex((x, y) => {
    const n = vnoise(x * 0.25, y * 0.25, 4) * 40;
    const edge = x % 16 === 0 || y % 16 === 0;
    const moss = vnoise(x * 0.2, y * 0.3, 9) > 0.62 && y > 14;
    if (edge) return [70, 90, 130];
    return moss ? [110, 180, 110] : [140 + n, 168 + n, 210 + n * 0.5];
  });
  texs[3] = mkTex((x, y) => {
    const plank = x % 8 === 0;
    const n = vnoise(x * 0.6, y * 0.08, 2) * 40;
    if (plank) return [120, 70, 40];
    if ((y === 6 || y === 25) && x % 8 === 4) return [90, 60, 40];
    return [214 - n, 160 - n * 0.7, 102 - n * 0.5];
  });
  texs[4] = mkTex((x, y) => {
    const fx = (x % 16) - 8;
    const fy = (y % 16) - 8;
    const d = Math.hypot(fx, fy);
    if (d < 2) return [255, 220, 90];
    const a = Math.atan2(fy, fx);
    if (d < 5 + Math.cos(a * 5) * 1.5) return [255, 140, 180];
    return [150, 225, 190];
  });
  const path: [number, number][] = [
    [2, 2],
    [13.3, 2],
    [13.3, 7.5],
    [7.5, 7.5],
    [7.5, 13.3],
    [2, 13.3],
  ];
  const lens: number[] = [];
  let totalLen = 0;
  for (let i = 0; i < path.length; i++) {
    const a = path[i]!;
    const b = path[(i + 1) % path.length]!;
    const l = Math.hypot(b[0] - a[0], b[1] - a[1]);
    lens.push(l);
    totalLen += l;
  }
  const at = (s: number): [number, number] => {
    s = ((s % totalLen) + totalLen) % totalLen;
    for (let i = 0; i < path.length; i++) {
      if (s <= lens[i]!) {
        const a = path[i]!;
        const b = path[(i + 1) % path.length]!;
        const k = s / lens[i]!;
        return [lerp(a[0], b[0], k), lerp(a[1], b[1], k)];
      }
      s -= lens[i]!;
    }
    return path[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 mini = true;
  let fishFix = true;
  let fov = 66;
  let s = 0;
  const hits = new Float32Array(LW * 2);
  const controls: Control[] = [
    { type: 'toggle', label: '미니 지도', value: mini, on: (v) => (mini = v) },
    { type: 'toggle', label: '어안 보정 (수직 거리)', value: fishFix, on: (v) => (fishFix = v) },
    { type: 'range', label: '시야각 (도)', min: 40, max: 110, step: 1, value: fov, on: (v) => (fov = v) },
  ];
  const FOG: V3 = [26, 22, 48];
  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 = lx.createImageData(LW, LH);
        buf = new Uint32Array(im.data.buffer);
      }
      const b = buf!;
      s += dt * 1.6;
      const pos = at(s);
      const ahead = at(s + 1.1);
      const ang = Math.atan2(ahead[1] - pos[1], ahead[0] - pos[0]);
      const px = pos[0];
      const py = pos[1];
      const dirX = Math.cos(ang);
      const dirY = Math.sin(ang);
      const pl = Math.tan(((fov / 2) * Math.PI) / 180);
      const plX = -dirY * pl;
      const plY = dirX * pl;
      const H2 = LH / 2;
      // 천장 · 바닥
      for (let y = 0; y < LH; y++) {
        if (y < H2) {
          const k = y / H2;
          const c = pack(lerp(40, 70, k), lerp(30, 60, k), lerp(70, 110, k));
          b.fill(c, y * LW, y * LW + LW);
        } else {
          const p = y - H2 + 0.5;
          const rowD = (0.5 * LH) / p;
          const r0x = dirX - plX;
          const r0y = dirY - plY;
          let fxw = px + rowD * r0x;
          let fyw = py + rowD * r0y;
          const sx = (rowD * 2 * plX) / LW;
          const sy = (rowD * 2 * plY) / LW;
          const fog = clamp01(rowD / 10);
          for (let x = 0; x < LW; x++) {
            const cx = Math.floor(fxw);
            const cy = Math.floor(fyw);
            const fx2 = fxw - cx;
            const fy2 = fyw - cy;
            const chk = (cx + cy) & 1;
            let c: V3 = chk ? [236, 196, 150] : [214, 168, 124];
            if (fx2 < 0.05 || fy2 < 0.05) c = [180, 136, 100];
            b[y * LW + x] = pack(lerp(c[0], FOG[0], fog), lerp(c[1], FOG[1], fog), lerp(c[2], FOG[2], fog));
            fxw += sx;
            fyw += sy;
          }
        }
      }
      for (let x = 0; x < LW; x++) {
        const camX = (2 * x) / LW - 1;
        const rdx = dirX + plX * camX;
        const rdy = dirY + plY * camX;
        let mx = Math.floor(px);
        let my = Math.floor(py);
        const ddx = Math.abs(1 / rdx);
        const ddy = Math.abs(1 / rdy);
        let stx: number;
        let sty: number;
        let sdx: number;
        let sdy: number;
        if (rdx < 0) {
          stx = -1;
          sdx = (px - mx) * ddx;
        } else {
          stx = 1;
          sdx = (mx + 1 - px) * ddx;
        }
        if (rdy < 0) {
          sty = -1;
          sdy = (py - my) * ddy;
        } else {
          sty = 1;
          sdy = (my + 1 - py) * ddy;
        }
        let side = 0;
        let cell = 0;
        for (let k = 0; k < 64; k++) {
          if (sdx < sdy) {
            sdx += ddx;
            mx += stx;
            side = 0;
          } else {
            sdy += ddy;
            my += sty;
            side = 1;
          }
          cell = MAP[my]?.[mx] ?? 1;
          if (cell) break;
        }
        const perp = side === 0 ? sdx - ddx : sdy - ddy;
        const dist = fishFix ? perp : perp * Math.hypot(rdx, rdy);
        hits[x * 2] = px + rdx * perp;
        hits[x * 2 + 1] = py + rdy * perp;
        const lineH = LH / Math.max(0.05, dist);
        const y0 = Math.floor(H2 - lineH / 2);
        const y1 = Math.floor(H2 + lineH / 2);
        let wallX = side === 0 ? py + perp * rdy : px + perp * rdx;
        wallX -= Math.floor(wallX);
        let tx = Math.floor(wallX * T);
        if ((side === 0 && rdx > 0) || (side === 1 && rdy < 0)) tx = T - 1 - tx;
        const tex = texs[cell] ?? texs[1]!;
        const shade = side ? 0.72 : 1;
        const fog = clamp01(dist / 11);
        for (let y = Math.max(0, y0); y < Math.min(LH, y1); y++) {
          const ty = Math.floor(((y - y0) / (y1 - y0)) * T) & (T - 1);
          const c = tex[ty * T + tx]!;
          const R0 = (c & 255) * shade;
          const G0 = ((c >> 8) & 255) * shade;
          const B0 = ((c >> 16) & 255) * shade;
          b[y * LW + x] = pack(lerp(R0, FOG[0], fog), lerp(G0, FOG[1], fog), lerp(B0, FOG[2], fog));
        }
      }
      lx.putImageData(im!, 0, 0);
      g.imageSmoothingEnabled = false;
      g.drawImage(lr, 0, 0, w, h);
      g.imageSmoothingEnabled = true;
      const f = frame(g, w, h);
      // 비네트
      const vg = g.createRadialGradient(140, 88, 60, 140, 88, 190);
      vg.addColorStop(0, 'rgba(0,0,0,0)');
      vg.addColorStop(1, 'rgba(0,0,0,.45)');
      g.fillStyle = vg;
      g.fillRect(f.L, f.T, f.R - f.L, f.B - f.T);
      if (mini) {
        const cs = 4;
        const ox = 6;
        const oy = 6;
        rr(g, ox - 2, oy - 2, MW * cs + 4, MW * cs + 4, 3);
        g.fillStyle = 'rgba(10,8,25,.75)';
        g.fill();
        const MC = ['', '#e07a5f', '#8fa9d8', '#d4a373', '#86e0b3'];
        for (let y = 0; y < MW; y++)
          for (let x = 0; x < MW; x++) {
            const c = MAP[y]![x]!;
            if (c) {
              g.fillStyle = MC[c]!;
              g.fillRect(ox + x * cs, oy + y * cs, cs, cs);
            }
          }
        g.fillStyle = 'rgba(255,240,120,.35)';
        g.beginPath();
        g.moveTo(ox + px * cs, oy + py * cs);
        for (let x = 0; x < LW; x += 4) g.lineTo(ox + hits[x * 2]! * cs, oy + hits[x * 2 + 1]! * cs);
        g.lineTo(ox + hits[(LW - 1) * 2]! * cs, oy + hits[(LW - 1) * 2 + 1]! * cs);
        g.closePath();
        g.fill();
        g.fillStyle = '#ff4f6e';
        g.beginPath();
        g.arc(ox + px * cs, oy + py * cs, 2, 0, TAU);
        g.fill();
      }
      pill(g, fishFix ? `세로줄 ${LW}개 = 광선 ${LW}개 · 벽 높이 ∝ 1/거리` : '어안 보정 끔 → 벽이 둥글게 휨', 186, 12, 7, 'rgba(0,0,0,.5)', '#fff');
      g.restore();
    },
  };
}
```

### i306 견본 항목 — `src/demos/demos2dLook.ts:3789`
```ts
  i306: { kind: '2d', caption: '세로줄마다 광선 하나 — 벽까지 거리로 높이 · 어둡기 · 무늬 줄, 바닥도 줄마다 거리 계산 · 미니 지도', make: mkI306 }
```

## 관련 기술
- 먼저 알면 좋은 기술: [모드 7 바닥 (원근 평면)](https://ai-techstudio.web.app/ai/t/i305.md) `i305`
- 다음에 해 볼 기술: [의사 3D 도로 (줄마다 커브 누적)](https://ai-techstudio.web.app/ai/t/i307.md) `i307`
- 참고 문서: [Lode Vandevenne — Raycasting tutorial](https://lodev.org/cgtutor/raycasting.html)
