# AI 꾸러미 — 지도 길 그리기 연출 (점선 따라)
> 출발 → 도착으로 점선이 그려지고 발자국 · 비행기가 따라감  
> 견본: https://ai-techstudio.web.app/#t/i280

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

## 주문서

### 만들어 줘: 지도 길 그리기 연출 (점선 따라)

#### 1. 목표
이동 경로에 「지도 길 그리기 연출 (점선 따라)」 기술을 깔끔한 기본 느낌으로 넣어 줘. — 출발 → 도착으로 점선이 그려지고 발자국 · 비행기가 따라감

#### 2. 핵심 기술 용어
- 지도 길 그리기 연출 (점선 따라) — 출발 → 도착으로 점선이 그려지고 발자국 · 비행기가 따라감

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

#### 4. 조건
- devicePixelRatio 를 맞춰 또렷하게
- 매 프레임 다시 그릴 필요 없는 그림은 화면 밖 캔버스에 미리 구워 두기
- 한 프레임 16ms 안 (폰 포함)

#### 5. 완성 기준 (이게 보이면 성공)
- 견본처럼 「출발 → 도착으로 점선이 그려지고 발자국 · 비행기가 따라감」 이(가) 첫 화면에서 바로 보인다
- 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

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

## 원리
- 출발 → 도착으로 점선이 그려지고 발자국 · 비행기가 따라감


## 흔한 실수 · 확인 목록
- (아직 정리된 것이 없어요)

## 완성 기준 체크리스트
- [ ] 견본처럼 「출발 → 도착으로 점선이 그려지고 발자국 · 비행기가 따라감」 이(가) 첫 화면에서 바로 보인다
- [ ] 켬/끔(또는 전/후) 비교 단추로 이 기술이 만드는 차이를 확인할 수 있다
- [ ] 중요한 값 1~3개를 슬라이더로 바꿔 볼 수 있다
- [ ] 콘솔 오류 0, 폰 폭(390px)에서도 동작한다

## 이 기술 정보
- id: `i280` · 분류: 지도 · 길찾기 › 지도 보기 · 표현 · 2D · 난이도 쉬움 · 폰 부담 보통 (폰 주의)
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i280
- 쓰면 좋을 때: 쓰면 좋을 곳 — 이동 경로 · 거리 연출

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i280 견본 항목 — `src/demos/demosMapB.ts:4281`
```ts
  i280: {
    kind: '2d',
    caption: '출발 → 도착으로 점선이 그려지며 비행기가 날고, 땅길은 발자국이 콕콕 — 거리 숫자가 같이 올라가요',
    make() {
      const Wd = getWorld();
      let speed = 1;
      let mode = 0; // 0 자동 · 1 비행기 · 2 걷기
      let seq: number[] = [];
      let legT = 0;
      let leg = 0;
      let round = 0;
      let total = 0;
      const newSeq = (): void => {
        const r = rng(round++ * 7 + 3);
        const n = Wd.towns.length;
        const s: number[] = [Math.floor(r() * n)];
        while (s.length < 4) {
          const c = Math.floor(r() * n);
          if (!s.includes(c)) s.push(c);
        }
        seq = s;
        leg = 0;
        legT = 0;
        total = 0;
      };
      newSeq();
      const legMode = (i: number): number => (mode ? mode : i % 2 ? 2 : 1);
      const legPts = (i: number): V2[] => {
        const a = Wd.towns[seq[i]!]!;
        const b = Wd.towns[seq[i + 1]!]!;
        const out: V2[] = [];
        const lm = legMode(i);
        const dx = b[0] - a[0];
        const dy = b[1] - a[1];
        const L = Math.hypot(dx, dy);
        for (let k = 0; k <= 40; k++) {
          const f = k / 40;
          const nx = -dy / L;
          const ny = dx / L;
          const off = lm === 1 ? Math.sin(f * Math.PI) * L * 0.28 : Math.sin(f * Math.PI * 3 + i) * L * 0.04;
          out.push([a[0] + dx * f + nx * off, a[1] + dy * f + ny * off]);
        }
        return out;
      };
      const LEG = 2.4;
      return {
        draw(g, w, h, t, dt) {
          reset(g);
          const u = scaleOf(w, h);
          legT += (Math.min(dt, 0.1) * speed) / LEG;
          if (legT >= 1.25) {
            total += polyLen(legPts(leg)) * 0.05;
            leg++;
            legT = 0;
            if (leg >= seq.length - 1) newSeq();
          }
          // 지도 (책 지도 같은 색감)
          const sc = Math.max(w / WORLD_W, h / WORLD_H) * 1.15;
          const ox = (w - WORLD_W * sc) / 2;
          const oy = (h - WORLD_H * sc) / 2;
          g.drawImage(Wd.c, ox, oy, WORLD_W * sc, WORLD_H * sc);
          g.globalCompositeOperation = 'multiply';
          g.fillStyle = '#e8d6b0';
          g.fillRect(0, 0, w, h);
          g.globalCompositeOperation = 'source-over';
          g.fillStyle = 'rgba(255,240,210,0.12)';
          g.fillRect(0, 0, w, h);
          const S = (p: V2): V2 => [ox + p[0] * sc, oy + p[1] * sc];
          // 지난 길
          for (let i = 0; i < leg; i++) {
            const pts = legPts(i).map(S);
            g.setLineDash(legMode(i) === 1 ? [5 * u, 4 * u] : [2 * u, 3 * u]);
            g.strokeStyle = 'rgba(160,40,40,0.55)';
            g.lineWidth = 1.6 * u;
            strokePathPart(g, pts, 1);
          }
          g.setLineDash([]);
          // 지금 길
          const pts = legPts(leg).map(S);
          const k = clamp01(legT);
          const lm = legMode(leg);
          const ek = ease(k);
          if (lm === 1) {
            g.setLineDash([5 * u, 4 * u]);
            g.lineDashOffset = -t * 20 * u;
            g.strokeStyle = '#c0262d';
            g.lineWidth = 2 * u;
            g.lineCap = 'round';
            strokePathPart(g, pts, ek);
            g.setLineDash([]);
            const [p, ang] = alongPoly(pts, ek * polyLen(pts));
            const lift = Math.sin(k * Math.PI);
            // 그림자 → 비행기
            g.fillStyle = 'rgba(0,0,0,0.25)';
            g.beginPath();
            g.ellipse(p[0] + 6 * u * lift, p[1] + 10 * u * lift, 7 * u, 3 * u, ang, 0, TAU);
            g.fill();
            g.save();
            g.translate(p[0], p[1]);
            g.rotate(ang);
            const s = (8 + 3 * lift) * u;
            g.fillStyle = '#ffffff';
            g.strokeStyle = '#2a3550';
            g.lineWidth = 0.9 * u;
            g.beginPath();
            g.moveTo(s, 0);
            g.quadraticCurveTo(s * 0.8, -s * 0.16, s * 0.2, -s * 0.14);
            g.lineTo(-s * 0.1, -s * 0.85);
            g.lineTo(-s * 0.35, -s * 0.85);
            g.lineTo(-s * 0.15, -s * 0.14);
            g.lineTo(-s * 0.75, -s * 0.12);
            g.lineTo(-s * 0.95, -s * 0.42);
            g.lineTo(-s * 1.05, -s * 0.42);
            g.lineTo(-s * 0.95, 0);
            g.lineTo(-s * 1.05, s * 0.42);
            g.lineTo(-s * 0.95, s * 0.42);
            g.lineTo(-s * 0.75, s * 0.12);
            g.lineTo(-s * 0.15, s * 0.14);
            g.lineTo(-s * 0.35, s * 0.85);
            g.lineTo(-s * 0.1, s * 0.85);
            g.lineTo(s * 0.2, s * 0.14);
            g.quadraticCurveTo(s * 0.8, s * 0.16, s, 0);
            g.closePath();
            g.fill();
            g.stroke();
            g.fillStyle = '#e63946';
            g.fillRect(-s * 0.95, -s * 0.06, s * 0.3, s * 0.12);
            g.restore();
          } else {
            // 발자국
            const L = polyLen(pts);
            const step = 7 * u;
            const n = Math.floor((ek * L) / step);
            for (let q = 0; q <= n; q++) {
              const [p, ang] = alongPoly(pts, q * step);
              const side = q % 2 ? 1 : -1;
              const age = n - q;
              g.globalAlpha = clamp(1 - age * 0.04, 0.35, 1);
              g.save();
              g.translate(p[0] - Math.sin(ang) * side * 2.2 * u, p[1] + Math.cos(ang) * side * 2.2 * u);
              g.rotate(ang + Math.PI / 2);
              g.fillStyle = '#5a3418';
              g.beginPath();
              g.ellipse(0, 0, 1.4 * u, 2.3 * u, 0, 0, TAU);
              g.fill();
              g.beginPath();
              g.ellipse(0, 2.9 * u, 1.1 * u, 1.1 * u, 0, 0, TAU);
              g.fill();
              g.restore();
            }
            g.globalAlpha = 1;
          }
          // 도시 핀
          seq.forEach((ti, i) => {
            if (i > leg + 1) return;
            const p = S(Wd.towns[ti]!);
            const born = i <= leg ? 1 : clamp01((legT - 0.95) / 0.25);
            if (born <= 0) return;
            const bounce = 1 - Math.abs(Math.sin(born * Math.PI * 1.5)) * (1 - born) * 0.8;
            g.save();
            g.translate(p[0], p[1]);
            g.scale(born, born * bounce + (1 - born));
            drawPin(g, 0, 0, 16 * u, i === 0 ? '#2a9d8f' : '#e63946', t);
            g.restore();
            pill(g, `${i + 1}`, p[0], p[1] - 22 * u, 6.5 * u, '#fff', '#222');
          });
          const curDist = total + polyLen(legPts(leg)) * 0.05 * ek;
          pill(g, `${lm === 1 ? '✈ 비행기' : '걷기'} · ${curDist.toFixed(1)} km`, 8 * u, 14 * u, 8.5 * u, 'rgba(255,255,255,0.92)', '#2a1a10', 'left');
          vignette(g, w, h, 0.4);
        },
        controls: [
          { type: 'range', label: '속도', min: 0.3, max: 3, step: 0.1, value: 1, on: (v) => (speed = v) },
          { type: 'range', label: '이동 (0 번갈아 · 1 비행기 · 2 걷기)', min: 0, max: 2, step: 1, value: 0, on: (v) => (mode = v) },
          { type: 'button', label: '새 여행', on: newSeq },
        ] as Control[],
      };
    },
  }
```

## 관련 기술
- 이 효과를 만든 기술: [SVG 선 그리기 (stroke-dashoffset) · 손떨림](https://ai-techstudio.web.app/ai/t/i80.md) `i80`
