# AI 꾸러미 — 2D 시야 다각형 (빛 그림자) — Visibility polygon (2D field of view)
> 벽 꼭짓점마다 광선 3개를 쏘아 맞은 점을 각도 순으로 이어 「보이는 다각형」을 만들고, 그 안만 밝혀 벽 뒤는 어둠 속에 숨긴다.  
> 견본: https://ai-techstudio.web.app/#t/i235

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

## 주문서

### 만들어 줘: 2D 시야 다각형 (빛 그림자) — Visibility polygon (2D field of view)

#### 1. 목표
순찰하는 경비원 시야의 시야를 2D 시야 다각형으로 그려 줘 — 벽 꼭짓점마다 광선 3개를 쏘아 맞은 점을 각도 순으로 잇고, 그 안만 밝힌다. 화면은 어둠 속 따뜻한 빛.

#### 2. 핵심 기술 용어
- **Visibility polygon (2D field of view)** — 한 점에서 보이는 영역 다각형
- **Ray casting to segment endpoints** — 벽 끝점마다 광선 쏘기 (±0.0001 rad 둘 더)
- **Ray–segment intersection** — 광선과 선분이 만나는 점
- **Clip path (ctx.clip)** — 다각형 안에만 그리기

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

#### 4. 조건
- 끝점마다 광선 3개 (a − ε, a, a + ε, ε = 0.0001)
- 바깥 테두리도 벽 선분에 넣기 (광선이 무한히 나가지 않게)
- 맞은 점은 각도 순으로 정렬해서 잇기 (360° 일 때 −π 부터, 부채꼴은 시작 각도부터)
- 밝은 바닥은 다각형 clip 안에서만 그리기
- 광선 보기 켬/끔 · 시야각 슬라이더

#### 5. 완성 기준 (이게 보이면 성공)
- 경비원이 돌아다니면 벽 뒤는 어둡고 보이는 곳만 밝다
- 「광선 보기」를 켜면 꼭짓점마다 붉은 광선이 보인다
- 시야각을 360° → 60° 로 줄이면 부채꼴이 되고 방향을 따라 돈다
- 보이는 보석만 반짝이고 가려진 보석은 어둡다

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

## 원리
- 벽은 선분 목록. 보이는 영역의 모서리는 늘 어떤 벽의 끝점 방향에서만 꺾인다.
- 그래서 끝점마다 각도 a 로 광선을 쏘되, a − 0.0001 · a · a + 0.0001 셋을 쏜다 — 모서리를 스쳐 뒤 벽까지 가는 광선을 잡으려고.
- 광선마다 모든 선분과 만나는 점 중 가장 가까운 것(t 가 가장 작은 것)이 맞은 점.
- 맞은 점들을 각도 순으로 정렬해 이으면 보이는 다각형. 그 안에만 clip 하고 바닥 · 빛을 그린다. 시야각을 줄이면 부채꼴 밖 각도는 버리고 경계 각도 17개를 더한다.
- 보석 같은 물체는 그쪽으로 광선을 쏘아 벽보다 먼저 닿으면 「보임」.

## 핵심 코드 — 끝점마다 광선 3개 → 각도 순 다각형
(발췌: demos/demosLook2.ts makeVisibility() 의 cast · 각도 모으기를 정리)
```ts
type Seg = [number, number, number, number];
// 광선 (ox,oy) + t·(dx,dy) 와 선분이 만나는 가장 가까운 점
function cast(segs: Seg[], ox: number, oy: number, dx: number, dy: number): [number, number] {
  let best = 1e9;
  for (const [x1, y1, x2, y2] of segs) {
    const sx = x2 - x1, sy = y2 - y1;
    const den = dx * sy - dy * sx;
    if (Math.abs(den) < 1e-9) continue; // 나란함
    const t = ((x1 - ox) * sy - (y1 - oy) * sx) / den; // 광선 쪽 거리
    const s = ((x1 - ox) * dy - (y1 - oy) * dx) / den; // 선분 쪽 0~1
    if (t > 0 && s >= 0 && s <= 1 && t < best) best = t;
  }
  return [ox + dx * best, oy + dy * best];
}

function visibility(segs: Seg[], px: number, py: number): [number, number][] {
  const angs: number[] = [];
  for (const [x1, y1, x2, y2] of segs)
    for (const [x, y] of [[x1, y1], [x2, y2]] as const) {
      const a = Math.atan2(y - py, x - px);
      angs.push(a - 1e-4, a, a + 1e-4); // 모서리를 스쳐 지나가는 광선까지
    }
  angs.sort((a, b) => a - b);
  return angs.map((a) => cast(segs, px, py, Math.cos(a), Math.sin(a)));
}

// 그리기: 다각형 안에만 밝은 바닥
const pts = visibility(segs, px, py);
g.save();
g.beginPath();
pts.forEach(([x, y], i) => (i ? g.lineTo(x, y) : g.moveTo(x, y)));
g.closePath();
g.clip();
const gr = g.createRadialGradient(px, py, 4, px, py, 190);
gr.addColorStop(0, '#fff3c4');
gr.addColorStop(0.35, '#e8c27a');
gr.addColorStop(1, '#4a3a2a');
g.fillStyle = gr;
g.fillRect(0, 0, 280, 175);
g.restore();
```

## 흔한 실수 · 확인 목록
- [ ] **끝점 정확히 한 방향으로만 쏘면 모서리 뒤로 새는 빛이 빠진다** — ±0.0001 rad 광선 두 개를 더 쏜다.
- [ ] **바깥 테두리 벽이 없으면 광선이 끝없이 나가 다각형이 깨진다** — 화면 네 변도 선분으로 넣는다.
- [ ] **부채꼴 시야에서 각도를 −π 부터 정렬하면 다각형이 꼬인다** — 시야 시작 각도(방향 − 반각)로부터의 거리로 정렬하고, 경비원 자리에서 시작해 닫는다.
- [ ] **벽이 많아지면 프레임이 떨어진다** — 광선 × 선분이 늘어나니, 경비원 둘레 상자 안 벽만 후보로 골라 검사한다 (쿼드트리 i263).

## 완성 기준 체크리스트
- [ ] 경비원이 돌아다니면 벽 뒤는 어둡고 보이는 곳만 밝다
- [ ] 「광선 보기」를 켜면 꼭짓점마다 붉은 광선이 보인다
- [ ] 시야각을 360° → 60° 로 줄이면 부채꼴이 되고 방향을 따라 돈다
- [ ] 보이는 보석만 반짝이고 가려진 보석은 어둡다

## 이 기술 정보
- id: `i235` · 분류: 2D · 화면 › 2D 그림 효과 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 광선 수 = 끝점 × 3, 광선마다 선분 전부 검사. 견본(선분 34개 → 광선 약 200개 × 34) 은 프레임당 수천 번 곱셈으로 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i235
- 쓰면 좋을 때: 경비원 · 적의 시야, 숨바꼭질 게임 / 빛이 벽에 가려지는 2D 장면 / 각도 · 반사 퍼즐
- 쓰지 말 때: 벽 선분이 수천 개 — 광선 × 선분 계산이 많아진다. 대신 격자 그림자 투사나 공간 나누기(i263)로 후보 벽만 / 벽이 둥근 곡선 — 선분으로 잘게 나눠야 한다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### makeVisibility — `src/demos/demosLook2.ts:2757`
```ts
function makeVisibility() {
  // 설계 좌표 280 × 175
  const rects: [number, number, number, number][] = [
    [52, 34, 26, 26],
    [150, 26, 16, 52],
    [200, 104, 40, 16],
    [96, 112, 18, 36],
    [230, 40, 20, 20],
    [36, 118, 22, 14],
  ];
  const segs: Seg[] = [
    [6, 6, 274, 6],
    [274, 6, 274, 169],
    [274, 169, 6, 169],
    [6, 169, 6, 6],
  ];
  for (const [x, y, w, h] of rects) segs.push([x, y, x + w, y], [x + w, y, x + w, y + h], [x + w, y + h, x, y + h], [x, y + h, x, y]);
  // L 벽
  segs.push([120, 80, 180, 80], [180, 80, 180, 86], [180, 86, 126, 86], [126, 86, 126, 104], [126, 104, 120, 104], [120, 104, 120, 80]);
  const gems: [number, number][] = [
    [30, 24],
    [258, 150],
    [170, 50],
    [70, 156],
    [250, 22],
    [140, 140],
    [212, 72],
  ];
  const cast = (ox: number, oy: number, dx: number, dy: number): [number, number] => {
    let best = 1e9;
    for (const [x1, y1, x2, y2] of segs) {
      const sx = x2 - x1;
      const sy = y2 - y1;
      const den = dx * sy - dy * sx;
      if (Math.abs(den) < 1e-9) continue;
      const t = ((x1 - ox) * sy - (y1 - oy) * sx) / den;
      const s = ((x1 - ox) * dy - (y1 - oy) * dx) / den;
      if (t > 0 && s >= 0 && s <= 1 && t < best) best = t;
    }
    return [ox + dx * best, oy + dy * best];
  };
  let showRays = true;
  let fov = 360;
  return {
    draw(g: CanvasRenderingContext2D, w: number, h: number, t: number) {
      const u = Math.min(w / 280, h / 175);
      const ox0 = (w - 280 * u) / 2;
      const oy0 = (h - 175 * u) / 2;
      g.globalAlpha = 1;
      g.globalCompositeOperation = 'source-over';
      g.fillStyle = '#05060c';
      g.fillRect(0, 0, w, h);
      g.save();
      g.translate(ox0, oy0);
      g.scale(u, u);
      // 경비원 순찰 (8자)
      const px = 140 + Math.sin(t * 0.45) * 108;
      const py = 92 + Math.sin(t * 0.9) * 60;
      const vx = Math.cos(t * 0.45) * 108 * 0.45;
      const vy = Math.cos(t * 0.9) * 60 * 0.9;
      const dir = Math.atan2(vy, vx);
      const half = (fov / 2) * (Math.PI / 180);
      const angs: number[] = [];
      const cone = fov < 359;
      const inCone = (a: number) => {
        let d = a - dir;
        d = Math.atan2(Math.sin(d), Math.cos(d));
        return Math.abs(d) <= half;
      };
      for (const [x1, y1, x2, y2] of segs)
        for (const [x, y] of [
          [x1, y1],
          [x2, y2],
        ] as const) {
          const a = Math.atan2(y - py, x - px);
          for (const e of [-1e-4, 0, 1e-4]) if (!cone || inCone(a + e)) angs.push(a + e);
        }
      if (cone) {
        for (let k = 0; k <= 16; k++) angs.push(dir - half + (2 * half * k) / 16);
      }
      const rel = (a: number) => {
        let d = a - (cone ? dir - half : -Math.PI);
        d = ((d % TAU) + TAU) % TAU;
        return d;
      };
      angs.sort((a, b) => rel(a) - rel(b));
      const pts = angs.map((a) => cast(px, py, Math.cos(a), Math.sin(a)));
      const poly = () => {
        g.beginPath();
        if (cone) g.moveTo(px, py);
        pts.forEach(([x, y], i) => (i || cone ? g.lineTo(x, y) : g.moveTo(x, y)));
        g.closePath();
      };
      // 밝은 바닥 (다각형 안만)
      g.save();
      poly();
      g.clip();
      const gr = g.createRadialGradient(px, py, 4, px, py, 190);
      gr.addColorStop(0, '#fff3c4');
      gr.addColorStop(0.35, '#e8c27a');
      gr.addColorStop(1, '#4a3a2a');
      g.fillStyle = gr;
      g.fillRect(0, 0, 280, 175);
      g.strokeStyle = 'rgba(80,50,20,0.25)';
      g.lineWidth = 0.6;
      for (let x = 6; x < 274; x += 12) {
        g.beginPath();
        g.moveTo(x, 6);
        g.lineTo(x, 169);
        g.stroke();
      }
      for (let y = 6; y < 169; y += 12) {
        g.beginPath();
        g.moveTo(6, y);
        g.lineTo(274, y);
        g.stroke();
      }
      g.restore();
      // 보석: 보이면 반짝
      for (const [gx, gy] of gems) {
        const [hx, hy] = cast(px, py, gx - px, gy - py);
        const vis = Math.hypot(hx - px, hy - py) >= Math.hypot(gx - px, gy - py) - 0.5 && (!cone || inCone(Math.atan2(gy - py, gx - px)));
        g.fillStyle = vis ? '#3ff0ff' : 'rgba(60,80,120,0.35)';
        g.beginPath();
        g.moveTo(gx, gy - 4.5);
        g.lineTo(gx + 3.5, gy);
        g.lineTo(gx, gy + 4.5);
        g.lineTo(gx - 3.5, gy);
        g.closePath();
        g.fill();
        if (vis) {
          g.fillStyle = 'rgba(120,250,255,0.25)';
          g.beginPath();
          g.arc(gx, gy, 8 + Math.sin(t * 6) * 1.5, 0, TAU);
          g.fill();
        }
      }
      // 광선
      if (showRays) {
        g.strokeStyle = 'rgba(255,90,90,0.45)';
        g.lineWidth = 0.5;
        for (const [x, y] of pts) {
          g.beginPath();
          g.moveTo(px, py);
          g.lineTo(x, y);
          g.stroke();
        }
        g.fillStyle = '#ff5a5a';
        for (const [x, y] of pts) g.fillRect(x - 1, y - 1, 2, 2);
      }
      // 벽
      g.fillStyle = '#5a6c9a';
      g.strokeStyle = '#a8b8e8';
      g.lineWidth = 1;
      for (const [x, y, rw, rh] of rects) {
        g.fillRect(x, y, rw, rh);
        g.strokeRect(x, y, rw, rh);
      }
      g.beginPath();
      g.moveTo(120, 80);
      g.lineTo(180, 80);
      g.lineTo(180, 86);
      g.lineTo(126, 86);
      g.lineTo(126, 104);
      g.lineTo(120, 104);
      g.closePath();
      g.fill();
      g.stroke();
      g.strokeStyle = '#a8b8e8';
      g.lineWidth = 2;
      g.strokeRect(6, 6, 268, 163);
      // 경비원
      g.fillStyle = '#ff5a5a';
      g.beginPath();
      g.arc(px, py, 5, 0, TAU);
      g.fill();
      g.strokeStyle = '#fff';
      g.lineWidth = 1.5;
      g.stroke();
      g.beginPath();
      g.moveTo(px + Math.cos(dir) * 5, py + Math.sin(dir) * 5);
      g.lineTo(px + Math.cos(dir) * 10, py + Math.sin(dir) * 10);
      g.stroke();
      g.restore();
      // 글
      g.font = `700 ${Math.max(10, 9 * u)}px ${F}`;
      g.fillStyle = 'rgba(0,0,0,0.6)';
      const txt = `광선 ${pts.length}개 → 보이는 다각형`;
      const tw = g.measureText(txt).width;
      g.beginPath();
      g.roundRect(ox0 + 10 * u, oy0 + 150 * u, tw + 14 * u, 14 * u, 7 * u);
      g.fill();
      g.fillStyle = '#fff';
      g.textBaseline = 'middle';
      g.fillText(txt, ox0 + 17 * u, oy0 + 157.5 * u);
    },
    controls: [
      { type: 'toggle', label: '광선 보기 (꼭짓점마다 3개)', value: true, on: (v: boolean) => (showRays = v) },
      { type: 'range', label: '시야각 (°)', min: 30, max: 360, step: 5, value: 360, on: (v: number) => (fov = v) },
    ] as Control[],
  };
}
```

### i235 견본 항목 — `src/demos/demosLook2.ts:4519`
```ts
  i235: { kind: '2d', caption: '벽 꼭짓점마다 광선 3개 → 각도 순으로 이어 보이는 다각형 — 경비원 시야 밖 보석은 어둠 속', make: () => makeVisibility() }
```

## 관련 기술
- 먼저 알면 좋은 기술: [2D 조명 흉내 (Light2D)](https://ai-techstudio.web.app/ai/t/u19.md) `u19`
- 다음에 해 볼 기술: [광선 투사 미로 (1인칭 옛 방식)](https://ai-techstudio.web.app/ai/t/i306.md) `i306` · [공간 나누기 (쿼드트리)](https://ai-techstudio.web.app/ai/t/i263.md) `i263`
- 참고 문서: [Wikipedia — Visibility polygon](https://en.wikipedia.org/wiki/Visibility_polygon)
