# AI 꾸러미 — 미니맵 (작은 지도 + 시야 틀) — Minimap
> 큰 화면은 캐릭터 둘레만 확대해 따라가고, 구석 작은 지도에 섬 전체 · 내 위치 · 보이는 범위 틀 · 깜박이는 목표를 함께 그린다.  
> 견본: https://ai-techstudio.web.app/#t/i271

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

## 주문서

### 만들어 줘: 미니맵 (작은 지도 + 시야 틀) — Minimap

#### 1. 목표
큰 섬 탐험 게임에 미니맵을 넣어 줘 — 큰 화면은 캐릭터를 부드럽게 따라가고, 구석 작은 지도에 전체 · 내 위치 · 보이는 범위 틀 · 지나온 길 · 깜박이는 목표가 보이게. 분위기는 밝은 지도 색 + 둥근 테.

#### 2. 핵심 기술 용어
- **Minimap** — 구석의 작은 전체 지도
- **Viewport rectangle (camera frustum on minimap)** — 지금 큰 화면에 보이는 범위를 작은 지도에 틀로
- **Off-screen target indicator** — 화면 밖 목표를 가장자리 화살표로
- **Smooth follow camera (lerp)** — 캐릭터를 부드럽게 따라가는 카메라

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

#### 4. 조건
- 세계 지도는 한 번 구워 큰 화면 · 미니맵이 같이 쓰기 — 미니맵을 따로 다시 그리지 않기
- 보이는 범위 틀은 큰 화면 카메라의 실제 범위(sx, sy, vw, vh) × 미니맵 배율
- 미니맵 안 그림은 둥근 테로 clip, 그림자로 떠 보이게
- 화면 밖 목표는 가장자리 화살표로, 미니맵 위 목표는 깜박이기
- 미니맵 크기 · 큰 화면 확대 · 범위 틀 · 지나온 길을 조절할 수 있게

#### 5. 완성 기준 (이게 보이면 성공)
- 캐릭터가 움직이면 큰 화면이 부드럽게 따라가고, 미니맵의 흰 틀도 같이 움직인다
- 큰 화면 확대를 키우면 미니맵의 틀이 작아진다
- 목표가 화면 밖이면 가장자리 화살표가 목표 쪽을 가리킨다
- 지나온 길이 미니맵에 점선으로 남는다

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

## 원리
- 세계 지도는 한 장의 캔버스로 미리 그려 두고, 큰 화면과 작은 지도가 같은 그림을 다른 배율로 잘라 그린다 (drawImage 의 원본 영역).
- 큰 화면 배율 Z 에서 보이는 세계 범위 = 화면 폭 ÷ Z. 그 왼쪽 위 (sx, sy) 와 크기 (vw, vh) 를 작은 지도 배율 mz 로 곱하면 「보이는 범위 틀」이 된다.
- 카메라는 캐릭터 위치로 lerp(카메라, 캐릭터, dt·3) — 바로 붙지 않고 부드럽게 따라간다.
- 목표가 큰 화면 밖이면 화면 가운데에서 목표 쪽 각도로 가장자리에 화살표를 그린다.
- 지나온 길은 6 이상 움직였을 때만 점을 더하고(최대 400개), 작은 지도에 점선으로 그린다.

## 핵심 코드 — 같은 세계 그림으로 큰 화면 + 미니맵 + 보이는 범위 틀
(발췌: demos/demosMapB.ts i271 draw() 를 정리)
```ts
// 카메라는 캐릭터를 부드럽게 따라간다
cam = [cam[0] + (p[0] - cam[0]) * Math.min(1, dt * 3), cam[1] + (p[1] - cam[1]) * Math.min(1, dt * 3)];
// 큰 화면: 배율 Z 로 카메라 둘레만 잘라 그리기
const vw = w / Z, vh = h / Z;
const sx = cam[0] - vw / 2, sy = cam[1] - vh / 2;
g.drawImage(world, sx, sy, vw, vh, 0, 0, w, h);
const toS = (q: [number, number]) => [(q[0] - sx) * Z, (q[1] - sy) * Z];
// 화면 밖 목표 → 가장자리 화살표
const ts = toS(tgt);
if (ts[0] < 0 || ts[1] < 0 || ts[0] > w || ts[1] > h) {
  const ang = Math.atan2(ts[1] - h / 2, ts[0] - w / 2);
  const ex = Math.min(w - 14, Math.max(14, w / 2 + Math.cos(ang) * w));
  const ey = Math.min(h - 14, Math.max(14, h / 2 + Math.sin(ang) * h));
  drawArrow(g, ex, ey, ang);
}
// 미니맵: 같은 세계 그림을 작게
const mw = w * 0.34 * mmSize, mh = (mw * WORLD_H) / WORLD_W;
const mx = w - mw - 8, my = 8, mz = mw / WORLD_W;
g.save();
g.beginPath(); g.roundRect(mx, my, mw, mh, 5); g.clip();
g.drawImage(world, mx, my, mw, mh);
// 보이는 범위 틀 = 큰 화면 카메라 범위 × 미니맵 배율
g.strokeStyle = '#fff'; g.lineWidth = 1.5;
g.strokeRect(mx + sx * mz, my + sy * mz, vw * mz, vh * mz);
// 내 위치 · 목표
g.fillStyle = '#ff8fa3';
g.beginPath(); g.arc(mx + p[0] * mz, my + p[1] * mz, 2.6, 0, Math.PI * 2); g.fill();
g.restore();
```

## 흔한 실수 · 확인 목록
- [ ] **미니맵을 매 프레임 새로 그리면 큰 지도에서 느리다** — 세계 그림을 한 번 구워 두고 drawImage 로 줄여 그린다.
- [ ] **보이는 범위 틀이 실제 화면과 어긋난다** — 큰 화면에서 쓴 sx · sy · vw · vh 를 그대로 미니맵 배율로 곱한다 — 따로 계산하지 않는다.
- [ ] **카메라가 캐릭터에 딱 붙어 화면이 덜컹거린다** — dt 에 비례한 lerp 로 따라가게 한다.

## 완성 기준 체크리스트
- [ ] 캐릭터가 움직이면 큰 화면이 부드럽게 따라가고, 미니맵의 흰 틀도 같이 움직인다
- [ ] 큰 화면 확대를 키우면 미니맵의 틀이 작아진다
- [ ] 목표가 화면 밖이면 가장자리 화살표가 목표 쪽을 가리킨다
- [ ] 지나온 길이 미니맵에 점선으로 남는다

## 이 기술 정보
- id: `i271` · 분류: 지도 · 길찾기 › 지도 보기 · 표현 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 세계 그림은 한 번만 굽고, 프레임마다 같은 그림을 두 번 drawImage. 3D 라면 미니맵용 두 번째 렌더가 그만큼 더 든다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i271
- 쓰면 좋을 때: 화면보다 훨씬 큰 지도에서 길을 잃지 않게 할 때 / 목표 방향을 알려 줘야 할 때 / 전략 게임처럼 전체 상황을 함께 봐야 할 때
- 쓰지 말 때: 화면 하나에 다 들어오는 작은 판 — 작은 지도가 자리만 차지한다 / 탐험 자체가 재미인 안개 지도 — 미니맵에도 안 본 곳을 가리거나(i268) 빼기

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i271 견본 항목 — `src/demos/demosMapB.ts:2788`
```ts
  i271: {
    kind: '2d',
    caption: '큰 화면은 내 주변만, 구석 작은 지도에는 섬 전체 · 내 위치 · 지금 보이는 범위(흰 틀) · 목표(깜박이는 별)',
    make() {
      const W = getWorld();
      let mmSize = 1;
      let zoom = 1.7;
      let showFrame = true;
      let showTrail = true;
      let p: V2 = [W.towns[0]?.[0] ?? 500, W.towns[0]?.[1] ?? 320];
      let tgt: V2 = p;
      let cam: V2 = [p[0], p[1]];
      let found = 0;
      let seed = 1;
      const trail: V2[] = [];
      const pickTarget = (): void => {
        const r = rng(seed++ * 13);
        for (let k = 0; k < 80; k++) {
          const q: V2 = [80 + r() * (WORLD_W - 160), 60 + r() * (WORLD_H - 120)];
          const d = Math.hypot(q[0] - p[0], q[1] - p[1]);
          if (d < 140 || d > 420) continue;
          let ok = true;
          for (let s = 0; s <= 20; s++) if (worldH(lerp(p[0], q[0], s / 20), lerp(p[1], q[1], s / 20)) < 0.03) ok = false;
          if (ok) {
            tgt = q;
            return;
          }
        }
        tgt = [WORLD_W / 2, WORLD_H / 2];
      };
      pickTarget();
      return {
        draw(g, w, h, t, dt) {
          reset(g);
          const u = scaleOf(w, h);
          const d = Math.min(dt, 0.1);
          const dx = tgt[0] - p[0];
          const dy = tgt[1] - p[1];
          const dist = Math.hypot(dx, dy);
          const sp = 70 * d;
          if (dist < sp + 1) {
            found++;
            pickTarget();
          } else {
            p = [p[0] + (dx / dist) * sp, p[1] + (dy / dist) * sp];
          }
          const last = trail[trail.length - 1];
          if (!last || Math.hypot(last[0] - p[0], last[1] - p[1]) > 6) {
            trail.push([p[0], p[1]]);
            if (trail.length > 400) trail.shift();
          }
          cam = [lerp(cam[0], p[0], clamp01(d * 3)), lerp(cam[1], p[1], clamp01(d * 3))];
          // 큰 화면
          const Z = Math.min(zoom * u, 1.3 + zoom * 0.7);
          const vw = w / Z;
          const vh = h / Z;
          const sx = cam[0] - vw / 2;
          const sy = cam[1] - vh / 2;
          g.fillStyle = '#10325c';
          g.fillRect(0, 0, w, h);
          g.imageSmoothingEnabled = true;
          g.drawImage(W.c, sx, sy, vw, vh, 0, 0, w, h);
          const toS = (q: V2): V2 => [(q[0] - sx) * Z, (q[1] - sy) * Z];
          // 목표 (화면 밖이면 가장자리 화살표)
          const ts = toS(tgt);
          const blink = 0.5 + 0.5 * Math.sin(t * 8);
          if (ts[0] > 0 && ts[1] > 0 && ts[0] < w && ts[1] < h) {
            drawStar(g, ts[0], ts[1], (7 + 2 * blink) * u, `rgba(255,214,90,${0.6 + 0.4 * blink})`, t);
          } else {
            const ang = Math.atan2(ts[1] - h / 2, ts[0] - w / 2);
            const ex = clamp(w / 2 + Math.cos(ang) * w, 14 * u, w - 14 * u);
            const ey = clamp(h / 2 + Math.sin(ang) * h, 14 * u, h - 14 * u);
            g.save();
            g.translate(ex, ey);
            g.rotate(ang);
            g.fillStyle = `rgba(255,214,90,${0.6 + 0.4 * blink})`;
            g.beginPath();
            g.moveTo(8 * u, 0);
            g.lineTo(-5 * u, -6 * u);
            g.lineTo(-2 * u, 0);
            g.lineTo(-5 * u, 6 * u);
            g.fill();
            g.restore();
          }
          const ps = toS(p);
          drawHero(g, ps[0], ps[1] - 3 * u, 13 * u, Math.atan2(dy, dx), '#ff8fa3');
          vignette(g, w, h, 0.35);
          // 미니맵
          const mw = w * 0.34 * mmSize;
          const mh = (mw * WORLD_H) / WORLD_W;
          const mx = w - mw - 8 * u;
          const my = 8 * u;
          const mz = mw / WORLD_W;
          g.save();
          g.shadowColor = 'rgba(0,0,0,0.55)';
          g.shadowBlur = 10 * u;
          g.shadowOffsetY = 3 * u;
          rr(g, mx - 3 * u, my - 3 * u, mw + 6 * u, mh + 6 * u, 7 * u);
          g.fillStyle = '#e9dcc0';
          g.fill();
          g.restore();
          g.save();
          rr(g, mx, my, mw, mh, 5 * u);
          g.clip();
          g.drawImage(W.c, mx, my, mw, mh);
          g.fillStyle = 'rgba(10,20,40,0.12)';
          g.fillRect(mx, my, mw, mh);
          if (showTrail && trail.length > 1) {
            g.setLineDash([2 * u, 2 * u]);
            g.strokeStyle = 'rgba(255,255,255,0.8)';
            g.lineWidth = 1 * u;
            g.beginPath();
            trail.forEach((q, i) => (i ? g.lineTo(mx + q[0] * mz, my + q[1] * mz) : g.moveTo(mx + q[0] * mz, my + q[1] * mz)));
            g.stroke();
            g.setLineDash([]);
          }
          if (showFrame) {
            g.strokeStyle = '#ffffff';
            g.lineWidth = 1.4 * u;
            g.fillStyle = 'rgba(255,255,255,0.14)';
            g.beginPath();
            g.rect(mx + sx * mz, my + sy * mz, vw * mz, vh * mz);
            g.fill();
            g.stroke();
          }
          drawStar(g, mx + tgt[0] * mz, my + tgt[1] * mz, (3.5 + 2 * blink) * u, blink > 0.5 ? '#ffd65a' : '#ff6b6b', t);
          const pr = 2.6 * u;
          g.fillStyle = 'rgba(255,143,163,0.35)';
          g.beginPath();
          g.arc(mx + p[0] * mz, my + p[1] * mz, pr * (1.8 + ((t * 2) % 1) * 1.5), 0, TAU);
          g.fill();
          g.fillStyle = '#ff4f7b';
          g.strokeStyle = '#fff';
          g.lineWidth = 1 * u;
          g.beginPath();
          g.arc(mx + p[0] * mz, my + p[1] * mz, pr, 0, TAU);
          g.fill();
          g.stroke();
          g.restore();
          rr(g, mx, my, mw, mh, 5 * u);
          g.strokeStyle = '#7a5a35';
          g.lineWidth = 1.5 * u;
          g.stroke();
          pill(g, `찾은 별 ${found}`, 8 * u, 13 * u, 7.5 * u, 'rgba(15,20,35,0.75)', '#ffd65a', 'left');
        },
        controls: [
          { type: 'range', label: '미니맵 크기', min: 0.6, max: 1.6, step: 0.05, value: 1, on: (v) => (mmSize = v) },
          { type: 'range', label: '큰 화면 확대', min: 0.8, max: 4, step: 0.1, value: 1.7, on: (v) => (zoom = v) },
          { type: 'toggle', label: '보이는 범위 틀', value: true, on: (v) => (showFrame = v) },
          { type: 'toggle', label: '지나온 길', value: true, on: (v) => (showTrail = v) },
        ] as Control[],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [잡음 섬 지도 (높이 → 생물군)](https://ai-techstudio.web.app/ai/t/i245.md) `i245`
- 다음에 해 볼 기술: [확대 · 끌기 지도 (슬리피 맵 · 타일 층)](https://ai-techstudio.web.app/ai/t/i272.md) `i272` · [시야 · 가림 (그림자 던지기 FOV)](https://ai-techstudio.web.app/ai/t/i268.md) `i268`
