# AI 꾸러미 — 시야 · 가림 (그림자 던지기 FOV) — Field of view — recursive shadowcasting
> 칸 지도에서 여덟 조각으로 그림자 던지기를 해 벽 뒤는 안 보이게 하고, 횃불 반경 안만 밝게 · 이미 본 칸은 흐리게 기억해 보여 준다.  
> 견본: https://ai-techstudio.web.app/#t/i268

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

## 주문서

### 만들어 줘: 시야 · 가림 (그림자 던지기 FOV) — Field of view — recursive shadowcasting

#### 1. 목표
던전 탐험 지도에 시야 · 가림을 넣어 줘 — 캐릭터 둘레 횃불 반경 안에서 벽 뒤는 그림자, 지나온 칸은 흐린 기억으로 남게. 분위기는 어두운 던전 + 횃불 빛.

#### 2. 핵심 기술 용어
- **Field of view — recursive shadowcasting** — 팔분면마다 기울기 범위로 가림 계산
- **Fog of war (explored memory)** — 안 본 곳은 검게 · 본 곳은 흐리게
- **Octant transform** — 한 조각 계산을 여덟 방향으로 돌려 쓰기

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

#### 4. 조건
- 시야는 캐릭터가 다른 칸으로 옮겼을 때만 다시 계산
- 판 밖 칸은 벽(불투명)으로 친다
- 밝은 지도 · 기억 지도(흑백 · 어둡게)는 화면 밖 캔버스에 한 번 굽고, 매 프레임엔 clip 으로 보이는 칸만 밝게
- 횃불 반경 · 기억하기 켬/끔을 바꿀 수 있게, 본 칸 % 를 보이기

#### 5. 완성 기준 (이게 보이면 성공)
- 캐릭터 둘레만 밝고, 벽 뒤 · 기둥 뒤로 그림자 쐐기가 생긴다
- 지나온 방은 흑백으로 흐리게 남고, 한 번도 안 본 곳은 새까맣다
- 횃불 반경을 3 으로 줄이면 좁게, 14 로 늘리면 방 전체가 보인다
- 기억하기를 끄면 지금 보이는 곳 말고는 모두 검다

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

## 원리
- 캐릭터 둘레를 45° 조각 여덟 개(팔분면)로 나누고, 한 조각 계산을 좌표 변환 표로 여덟 번 돌려 쓴다.
- 조각마다 가까운 줄부터 먼 줄로 훑으며, 보이는 범위를 기울기(start ~ end)로 들고 간다.
- 벽 칸을 만나면 그 벽 앞까지의 범위로 다음 줄을 재귀로 훑고, 벽 뒤 범위는 좁아진다 — 그래서 벽 뒤가 그림자가 된다.
- 반경 안(dx² + dy² < r²)인 칸만 보이는 칸으로 친다. 보인 칸은 seen 배열에 쌓아 기억한다.
- 그리기: 안 본 칸은 검게, 본 칸은 흑백 · 어둡게 구운 그림, 지금 보이는 칸만 밝은 그림을 clip 해서 그리고 횃불 빛 원을 얹는다.

## 핵심 코드 — 재귀 그림자 던지기 (팔분면 8개)
(발췌: demos/demosMapB.ts shadowcast 를 정리)
```ts
function shadowcast(C: number, R: number, opaque: (x: number, y: number) => boolean,
                    ox: number, oy: number, radius: number, vis: Uint8Array) {
  vis.fill(0);
  vis[oy * C + ox] = 1;
  // 팔분면 8개의 좌표 변환 (xx, xy, yx, yy)
  const M = [[1, 0, 0, -1, -1, 0, 0, 1], [0, 1, -1, 0, 0, -1, 1, 0], [0, 1, 1, 0, 0, -1, -1, 0], [1, 0, 0, 1, -1, 0, 0, -1]];
  const cast = (row: number, start: number, end: number, xx: number, xy: number, yx: number, yy: number): void => {
    if (start < end) return;
    let newStart = 0;
    for (let j = row; j <= radius; j++) {
      let dx = -j - 1, blocked = false;
      const dy = -j;
      while (dx <= 0) {
        dx++;
        const X = ox + dx * xx + dy * xy, Y = oy + dx * yx + dy * yy;
        const lS = (dx - 0.5) / (dy + 0.5), rS = (dx + 0.5) / (dy - 0.5); // 이 칸의 왼쪽 · 오른쪽 기울기
        if (start < rS) continue;
        else if (end > lS) break;
        const inb = X >= 0 && Y >= 0 && X < C && Y < R;
        if (inb && dx * dx + dy * dy < radius * radius) vis[Y * C + X] = 1;
        const op = !inb || opaque(X, Y);
        if (blocked) {
          if (op) { newStart = rS; continue; }
          blocked = false; start = newStart;
        } else if (op && j < radius) {
          blocked = true;
          cast(j + 1, start, lS, xx, xy, yx, yy); // 벽 앞까지 범위로 다음 줄
          newStart = rS;
        }
      }
      if (blocked) break;
    }
  };
  for (let o = 0; o < 8; o++) cast(1, 1, 0, M[0][o], M[1][o], M[2][o], M[3][o]);
}
// 칸을 옮겼을 때만: shadowcast(...); for (i) if (vis[i]) seen[i] = 1;
```

## 흔한 실수 · 확인 목록
- [ ] **매 프레임 시야를 다시 계산하면 칸이 많을 때 낭비다** — 캐릭터가 다른 칸으로 옮겼을 때만 계산한다.
- [ ] **판 밖을 빈 칸으로 치면 가장자리 너머로 시야가 새어 배열 밖을 읽는다** — 판 밖은 불투명으로 친다.
- [ ] **안 보이는 칸을 하나씩 어둡게 칠하면 칸 사이에 가는 틈이 보인다** — 견본은 칸을 0.3px 씩 넓혀 사각형을 한 경로에 모아 한 번에 칠한다.

## 완성 기준 체크리스트
- [ ] 캐릭터 둘레만 밝고, 벽 뒤 · 기둥 뒤로 그림자 쐐기가 생긴다
- [ ] 지나온 방은 흑백으로 흐리게 남고, 한 번도 안 본 곳은 새까맣다
- [ ] 횃불 반경을 3 으로 줄이면 좁게, 14 로 늘리면 방 전체가 보인다
- [ ] 기억하기를 끄면 지금 보이는 곳 말고는 모두 검다

## 이 기술 정보
- id: `i268` · 분류: 지도 · 길찾기 › 길찾기 · 이동 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 반경 8 이면 칸 수백 개만 훑는다. 캐릭터가 칸을 옮길 때만 다시 계산하면 더 가볍다 (견본은 lastCell 로 거름).
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i268
- 쓰면 좋을 때: 탐험 · 로그라이크에서 벽 뒤를 감출 때 / 전략 게임 안개 지도 / 「보이는 곳 · 안 보이는 곳」 기하 체험
- 쓰지 말 때: 칸이 없는 자유 공간 — 대신 광선 · 다각형 시야(가시 다각형) / 3D 장면 — 대신 그림자 지도나 깊이 비교

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i268 견본 항목 — `src/demos/demosMapB.ts:2076`
```ts
  i268: {
    kind: '2d',
    caption: '횃불 든 탐험가 — 벽 뒤는 그림자로 가려지고, 한 번 본 칸은 푸르스름하게 기억, 아직 못 본 곳은 까맣게',
    make() {
      const C = 44;
      const R = 27;
      let seed = 5;
      let D = makeDungeon(C, R, seed);
      const vis = new Uint8Array(C * R);
      let seen = new Uint8Array(C * R);
      let radius = 8;
      let memory = true;
      let speed = 1;
      let path: number[] = [];
      let pi = 0;
      let frac = 0;
      let lastCell = -1;
      let roomI = 0;
      let born = 0;
      let nowT = 0;
      const lit = new Layer();
      const mem = new Layer();
      const opaque = (x: number, y: number): boolean => D.wall[y * C + x] === 1;
      const reset2 = (): void => {
        D = makeDungeon(C, R, seed);
        seen = new Uint8Array(C * R);
        roomI = 0;
        const [x, y] = D.rooms[0]!;
        path = [y * C + x];
        pi = 0;
        frac = 0;
        lastCell = -1;
        born = nowT;
      };
      reset2();
      const nextTarget = (): void => {
        const cur = path[path.length - 1]!;
        const r = rng(seed * 50 + roomI);
        let best: number[] = [];
        for (let k = 0; k < 6; k++) {
          const [x, y] = D.rooms[Math.floor(r() * D.rooms.length)]!;
          const p = bfsPath(C, R, (j) => D.wall[j] === 1, cur, y * C + x);
          let unseen = 0;
          for (const c of p) if (!seen[c]) unseen++;
          if (p.length > 1 && (best.length === 0 || unseen > 0)) best = p;
          if (unseen > 4) break;
        }
        roomI++;
        path = best.length ? best : [cur];
        pi = 0;
        frac = 0;
      };
      const paintDungeon = (lg: G, fit: Fit): void => {
        const { s, ox, oy } = fit;
        lg.fillStyle = '#07070c';
        lg.fillRect(ox, oy, C * s, R * s);
        for (let i = 0; i < C * R; i++) {
          const x = ox + (i % C) * s;
          const y = oy + ((i / C) | 0) * s;
          if (!D.wall[i]) {
            const v = 0.85 + 0.25 * hash2(i, 7, D.id);
            lg.fillStyle = css([118 * v, 98 * v, 80 * v]);
            lg.fillRect(x, y, s, s);
            lg.fillStyle = 'rgba(40,28,20,0.5)';
            lg.fillRect(x, y + s - Math.max(0.6, s * 0.08), s, Math.max(0.6, s * 0.08));
            lg.fillRect(x + s - Math.max(0.6, s * 0.08), y, Math.max(0.6, s * 0.08), s);
            if (hash2(i, 9, D.id) < 0.12) {
              lg.fillStyle = 'rgba(60,45,35,0.6)';
              lg.beginPath();
              lg.arc(x + s * 0.4, y + s * 0.5, s * 0.12, 0, TAU);
              lg.fill();
            }
          }
        }
        for (let i = 0; i < C * R; i++) {
          if (!D.wall[i]) continue;
          const cx = i % C;
          const cy = (i / C) | 0;
          let nearFloor = false;
          for (let d = 0; d < 8; d++) {
            const nx = cx + DX[d]!;
            const ny = cy + DY[d]!;
            if (nx >= 0 && ny >= 0 && nx < C && ny < R && !D.wall[ny * C + nx]) nearFloor = true;
          }
          if (!nearFloor) continue;
          const x = ox + cx * s;
          const y = oy + cy * s;
          lg.fillStyle = '#3b3442';
          lg.fillRect(x, y, s, s);
          lg.fillStyle = '#5d5468';
          lg.fillRect(x, y, s, s * 0.62);
          lg.fillStyle = 'rgba(255,255,255,0.12)';
          lg.fillRect(x, y, s, Math.max(0.6, s * 0.1));
          lg.strokeStyle = 'rgba(0,0,0,0.35)';
          lg.lineWidth = Math.max(0.5, s * 0.06);
          lg.beginPath();
          lg.moveTo(x + s * (cy % 2 ? 0.3 : 0.7), y);
          lg.lineTo(x + s * (cy % 2 ? 0.3 : 0.7), y + s * 0.62);
          lg.stroke();
        }
      };
      return {
        draw(g, w, h, t, dt) {
          reset(g);
          nowT = t;
          const u = scaleOf(w, h);
          const dpr = dprOf(g);
          // 움직이기
          frac += Math.min(dt, 0.1) * speed * 7;
          while (frac >= 1) {
            frac -= 1;
            pi++;
            if (pi >= path.length - 1) nextTarget();
          }
          const ci = path[Math.min(pi, path.length - 1)]!;
          const ni = path[Math.min(pi + 1, path.length - 1)]!;
          if (ci !== lastCell) {
            shadowcast(C, R, opaque, ci % C, (ci / C) | 0, radius, vis);
            for (let i = 0; i < C * R; i++) if (vis[i]) seen[i] = 1;
            lastCell = ci;
          }
          let floorN = 0;
          let seenN = 0;
          for (let i = 0; i < C * R; i++)
            if (!D.wall[i]) {
              floorN++;
              if (seen[i]) seenN++;
            }
          if (seenN / floorN > 0.9 || t - born > 30 || t < born) {
            seed++;
            reset2();
          }
          g.fillStyle = '#050508';
          g.fillRect(0, 0, w, h);
          const headH = 16 * u;
          const fit = fitGrid(C, R, 4 * u, headH, w - 8 * u, h - headH - 4 * u);
          const { s, ox, oy } = fit;
          const key = `${D.id}|${w}|${h}|${dpr}`;
          const LL = lit.get(key, w, h, dpr, (lg) => paintDungeon(lg, fit));
          const ML = mem.get(key, w, h, dpr, (lg) => {
            lg.filter = 'grayscale(0.85) brightness(0.42)';
            lg.drawImage(LL, 0, 0, w, h);
            lg.filter = 'none';
            lg.globalCompositeOperation = 'source-atop';
            lg.fillStyle = 'rgba(40,70,140,0.28)';
            lg.fillRect(0, 0, w, h);
            lg.globalCompositeOperation = 'source-over';
          });
          if (memory) {
            g.drawImage(ML, 0, 0, w, h);
            g.fillStyle = '#050508';
            g.beginPath();
            for (let i = 0; i < C * R; i++) if (!seen[i]) g.rect(ox + (i % C) * s - 0.3, oy + ((i / C) | 0) * s - 0.3, s + 0.6, s + 0.6);
            g.fill();
          }
          // 보이는 칸
          const hx = ox + (lerp(ci % C, ni % C, frac) + 0.5) * s;
          const hy = oy + (lerp((ci / C) | 0, (ni / C) | 0, frac) + 0.5) * s;
          g.save();
          g.beginPath();
          for (let i = 0; i < C * R; i++) if (vis[i]) g.rect(ox + (i % C) * s - 0.3, oy + ((i / C) | 0) * s - 0.3, s + 0.6, s + 0.6);
          g.clip();
          g.drawImage(LL, 0, 0, w, h);
          const fl = 1 + 0.04 * Math.sin(t * 17) + 0.03 * Math.sin(t * 29);
          const R0 = radius * s * fl;
          const gr = g.createRadialGradient(hx, hy, 0, hx, hy, R0);
          gr.addColorStop(0, 'rgba(255,200,120,0.28)');
          gr.addColorStop(0.45, 'rgba(255,150,60,0.08)');
          gr.addColorStop(0.8, 'rgba(10,8,20,0.45)');
          gr.addColorStop(1, 'rgba(5,5,10,0.75)');
          g.fillStyle = gr;
          g.fillRect(hx - R0, hy - R0, R0 * 2, R0 * 2);
          g.restore();
          g.globalCompositeOperation = 'lighter';
          const glow = g.createRadialGradient(hx, hy, 0, hx, hy, s * 2.2);
          glow.addColorStop(0, 'rgba(255,190,90,0.55)');
          glow.addColorStop(1, 'rgba(255,120,40,0)');
          g.fillStyle = glow;
          g.fillRect(hx - s * 2.2, hy - s * 2.2, s * 4.4, s * 4.4);
          g.globalCompositeOperation = 'source-over';
          const ang = Math.atan2(((ni / C) | 0) - ((ci / C) | 0), (ni % C) - (ci % C));
          drawHero(g, hx, hy, s * 1.25, ang, '#7fd3ff');
          // 횃불
          g.fillStyle = '#ffdf7a';
          g.beginPath();
          g.arc(hx + s * 0.45, hy - s * 0.45 + Math.sin(t * 20) * s * 0.03, s * 0.2, 0, TAU);
          g.fill();
          txt(g, '시야 · 가림', 7 * u, headH / 2 + 1 * u, 10.5 * u, '#ffd38a', 'left', 900);
          pill(g, `본 칸 ${Math.round((seenN / floorN) * 100)}% · 반경 ${radius}`, w - 6 * u, headH / 2 + 1 * u, 7.5 * u, 'rgba(255,255,255,0.12)', '#e8ecff', 'right');
        },
        controls: [
          { type: 'range', label: '횃불 반경', min: 3, max: 14, step: 1, value: 8, on: (v) => ((radius = v), (lastCell = -1)) },
          { type: 'toggle', label: '본 칸 기억하기', value: true, on: (v) => (memory = v) },
          { type: 'range', label: '속도', min: 0.3, max: 3, step: 0.1, value: 1, on: (v) => (speed = v) },
          { type: 'button', label: '새 던전', on: () => (seed++, reset2()) },
        ] as Control[],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [방 + 복도 던전 (BSP)](https://ai-techstudio.web.app/ai/t/i247.md) `i247` · [동굴 (셀룰러 오토마타)](https://ai-techstudio.web.app/ai/t/i248.md) `i248`
- 다음에 해 볼 기술: [미니맵 (작은 지도 + 시야 틀)](https://ai-techstudio.web.app/ai/t/i271.md) `i271`
- 참고 문서: [RogueBasin — FOV using recursive shadowcasting](https://www.roguebasin.com/index.php/FOV_using_recursive_shadowcasting)
