# AI 꾸러미 — 보물 지도 그림체 (양피지) — Treasure map style (parchment texture)
> 양피지 결 · 얼룩 · 접힌 자국 · 탄 가장자리를 코드로 구워 깔고, 그 위에 잉크 점선 길이 깃펜 끝을 따라 그려지다 X 표로 끝나게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i273

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

## 주문서

### 만들어 줘: 보물 지도 그림체 (양피지) — Treasure map style (parchment texture)

#### 1. 목표
보물섬 지도를 양피지 보물 지도 그림체로 그려 줘 — 울퉁불퉁 탄 가장자리 · 얼룩 · 접힌 자국 · 잉크 물결 바다 · 나침반 장미 위로 점선 길이 깃펜을 따라 그려지고 X 표가 찍히게. 분위기는 낡은 해적 지도.

#### 2. 핵심 기술 용어
- **Treasure map style (parchment texture)** — 낡은 양피지 보물 지도 그림체
- **Procedural paper (stains, folds, burnt edges)** — 코드로 그리는 얼룩 · 접힌 자국 · 탄 가장자리
- **Animated path drawing (partial stroke)** — 길이의 앞부분만 그어 그려지는 길
- **Compass rose** — 나침반 장미

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

#### 4. 조건
- 바탕(종이 · 섬 · 산 · 나침반 · 제목)은 화면 밖 캔버스에 굽고, 열쇠(시드 · 낡은 정도 · 크기 · dpr)가 바뀔 때만 다시
- 그림 파일 없이 모두 코드로, 글씨는 손글씨 글꼴로
- 길은 Catmull-Rom 으로 부드럽게 한 뒤 전체 길이의 앞부분만 그어 그려지는 모습을, 끝점에 깃펜
- 길이 다 그려지면 X 표를 두 획으로 긋고 깜박이는 원 + 「여기!」
- 「낡은 정도」 슬라이더로 얼룩 · 탄 가장자리 세기를 바꿀 수 있게

#### 5. 완성 기준 (이게 보이면 성공)
- 종이 가장자리가 울퉁불퉁하고 탄 자국처럼 어둡게 번져 있다
- 빨간 점선 길이 깃펜 끝을 따라 그려지고, 끝에서 X 표가 두 획으로 그어진다
- 낡은 정도를 0 으로 하면 깨끗한 종이, 1 이면 얼룩 · 탄 가장자리가 짙다
- 폰에서도 끊김 없이 그려진다

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

## 원리
- 종이 가장자리는 네 변을 4px 간격 점으로 나누고, 잡음 + 작은 흔들림으로 안팎으로 밀어 울퉁불퉁하게 (낡을수록 더 크게).
- 그 모양 안을 가운데 밝고 가장자리 어두운 방사 그러데이션으로 칠하고, 흐린 갈색 얼룩 원 수십 개 · 작은 점 1,500개로 결을 낸다.
- 접힌 자국 = 가운데 어둡고 바로 옆 밝은 좁은 그러데이션 띠, 탄 가장자리 = 종이 테두리를 굵게 · 흐리게(blur) 한 번, 얇고 진하게 한 번.
- 섬 둘레 잉크 물결은 굵은 선을 그리고 destination-out 으로 안쪽을 지워 고리만 남기기를 5겹.
- 이 모든 바탕은 화면 밖 캔버스에 한 번 굽고, 매 프레임엔 점선 길의 앞부분(전체 길이 × k)만 그어 깃펜 끝을 그 끝점에 둔다.

## 핵심 코드 — 울퉁불퉁 종이 가장자리 + 탄 테두리 + 그려지는 점선 길
(발췌: demos/demosMapB.ts i273 paint() · strokePathPart 를 정리)
```ts
// 1) 종이 가장자리: 네 변을 점으로 나눠 잡음으로 밀기 (age = 낡은 정도 0~1)
const edge: [number, number][] = [];
const per = (x0: number, y0: number, x1: number, y1: number, nx: number, ny: number) => {
  const n = Math.ceil(Math.hypot(x1 - x0, y1 - y0) / (4 * u));
  for (let i = 0; i < n; i++) {
    const f = i / n;
    const j = (vnoise(f * 12 + x0 * 0.01, y0 * 0.01, seed) - 0.5) * 7 * u * (0.5 + age) + (r() - 0.5) * 1.6 * u;
    edge.push([x0 + (x1 - x0) * f + nx * j, y0 + (y1 - y0) * f + ny * j]);
  }
};
const m = 9 * u;
per(m, m, w - m, m, 0, 1); per(w - m, m, w - m, h - m, -1, 0); per(w - m, h - m, m, h - m, 0, -1); per(m, h - m, m, m, 1, 0);
const paperPath = () => { lg.beginPath(); edge.forEach(([x, y], i) => (i ? lg.lineTo(x, y) : lg.moveTo(x, y))); lg.closePath(); };
// 2) 탄 가장자리: 굵고 흐린 선 + 얇고 진한 선 (종이 모양으로 clip 한 상태에서)
paperPath();
lg.strokeStyle = 'rgba(60,30,10,0.55)';
lg.lineWidth = (10 + 18 * age) * u;
lg.filter = 'blur(' + (3 + 4 * age) * u + 'px)';
lg.stroke();
lg.filter = 'none';
paperPath(); lg.strokeStyle = 'rgba(40,18,5,0.85)'; lg.lineWidth = 2.2 * u; lg.stroke();

// 3) 매 프레임: 길의 앞부분 k 만 긋고 끝점을 돌려준다 (깃펜 자리)
function strokePathPart(g: CanvasRenderingContext2D, pts: [number, number][], k: number) {
  let total = 0;
  for (let i = 1; i < pts.length; i++) total += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
  let left = total * Math.min(1, Math.max(0, k));
  g.beginPath(); g.moveTo(pts[0][0], pts[0][1]);
  let head = pts[0];
  for (let i = 1; i < pts.length; i++) {
    const a = pts[i - 1], b = pts[i], L = Math.hypot(b[0] - a[0], b[1] - a[1]);
    if (L >= left) { head = [a[0] + ((b[0] - a[0]) * left) / (L || 1), a[1] + ((b[1] - a[1]) * left) / (L || 1)]; g.lineTo(head[0], head[1]); break; }
    left -= L; g.lineTo(b[0], b[1]); head = b;
  }
  g.stroke();
  return head;
}
g.setLineDash([5 * u, 4 * u]); g.strokeStyle = 'rgba(150,30,25,0.9)';
const pen = strokePathPart(g, pathPts, ease(Math.min(1, phaseT / 3.2)));
g.setLineDash([]);
```

## 흔한 실수 · 확인 목록
- [ ] **얼룩 · 결 · blur 를 매 프레임 그리면 폰에서 느리다** — 바탕은 화면 밖 캔버스에 한 번 굽고, 바뀌는 것(길 · X 표 · 촛불 빛)만 매 프레임 그린다.
- [ ] **가장자리를 Math.random 으로만 흔들면 톱니처럼 거칠다** — 잡음(vnoise)으로 크게 출렁이고, 작은 무작위는 조금만 더한다.
- [ ] **점선 길을 setLineDash 로 그리고 안 풀면 다음 그림까지 점선이 된다** — 길을 그은 뒤 setLineDash([]) 로 되돌린다.

## 완성 기준 체크리스트
- [ ] 종이 가장자리가 울퉁불퉁하고 탄 자국처럼 어둡게 번져 있다
- [ ] 빨간 점선 길이 깃펜 끝을 따라 그려지고, 끝에서 X 표가 두 획으로 그어진다
- [ ] 낡은 정도를 0 으로 하면 깨끗한 종이, 1 이면 얼룩 · 탄 가장자리가 짙다
- [ ] 폰에서도 끊김 없이 그려진다

## 이 기술 정보
- id: `i273` · 분류: 지도 · 길찾기 › 지도 보기 · 표현 · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 바탕(얼룩 수십 · 점 1,500 · blur 한 번)은 시드 · 낡은 정도 · 크기가 바뀔 때만 굽는다. 매 프레임은 그림 한 장 + 점선 길 하나.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i273
- 쓰면 좋을 때: 보물찾기 · 모험 게임의 지도 화면 / 이야기 시작 · 단계 고르기 연출 / 종이 느낌의 결과 화면
- 쓰지 말 때: 실제 길찾기 · 좌표 읽기처럼 정확해야 하는 지도 — 얼룩 · 접힌 자국이 읽기를 방해한다 / 자주 바뀌는 지도 — 바탕을 매번 다시 구우면 무겁다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i273 견본 항목 — `src/demos/demosMapB.ts:3214`
```ts
  i273: {
    kind: '2d',
    caption: '양피지 결 · 탄 가장자리 · 접힌 자국 · 잉크 물결 바다 · 나침반 장미 — 점선 길이 그려지며 X 표까지',
    make() {
      let seed = 3;
      let age = 0.6;
      let speed = 1;
      let phaseT = 0;
      const layer = new Layer();
      const geo = (w: number, h: number, u: number): { isl: V2[]; path: V2[]; ship: V2; X: V2; cx: number; cy: number } => {
        const r = rng(seed);
        const cx = w * 0.47;
        const cy = h * 0.53;
        const R0 = Math.min(w * 0.28, h * 0.32);
        const isl: V2[] = [];
        const ph1 = r() * 6;
        const ph2 = r() * 6;
        for (let i = 0; i < 90; i++) {
          const a = (i / 90) * TAU;
          const k = 1 + 0.22 * Math.sin(a * 2 + ph1) + 0.12 * Math.sin(a * 5 + ph2) + 0.07 * (vnoise(Math.cos(a) * 3 + seed, Math.sin(a) * 3, 3) - 0.5) * 4;
          isl.push([cx + Math.cos(a) * R0 * k * 1.35, cy + Math.sin(a) * R0 * k * 0.85]);
        }
        const ship: V2 = [cx - R0 * 2.1, cy + R0 * 0.75];
        const land: V2 = isl[Math.floor(90 * 0.42)]!;
        const X: V2 = [cx + R0 * (0.3 + r() * 0.5), cy - R0 * (0.15 + r() * 0.3)];
        const path: V2[] = [ship];
        const mid1: V2 = [lerp(ship[0], land[0], 0.5), lerp(ship[1], land[1], 0.5) + 10 * u];
        path.push(mid1, land);
        for (let k = 1; k <= 5; k++) {
          const f = k / 5;
          path.push([lerp(land[0], X[0], f) + Math.sin(f * 7 + seed) * R0 * 0.18, lerp(land[1], X[1], f) + Math.cos(f * 5) * R0 * 0.12]);
        }
        return { isl, path, ship, X, cx, cy };
      };
      const smoothPts = (pts: V2[], n: number): V2[] => {
        const out: V2[] = [];
        for (let i = 0; i + 1 < pts.length; i++) {
          const p0 = pts[Math.max(0, i - 1)]!;
          const p1 = pts[i]!;
          const p2 = pts[i + 1]!;
          const p3 = pts[Math.min(pts.length - 1, i + 2)]!;
          for (let k = 0; k < n; k++) {
            const s = k / n;
            const s2 = s * s;
            const s3 = s2 * s;
            const f = (a: number, b: number, c: number, d: number): number => 0.5 * (2 * b + (-a + c) * s + (2 * a - 5 * b + 4 * c - d) * s2 + (-a + 3 * b - 3 * c + d) * s3);
            out.push([f(p0[0], p1[0], p2[0], p3[0]), f(p0[1], p1[1], p2[1], p3[1])]);
          }
        }
        out.push(pts[pts.length - 1]!);
        return out;
      };
      const paint = (lg: G, w: number, h: number, u: number): void => {
        const r = rng(seed * 7 + 1);
        const { isl, ship, cx, cy } = geo(w, h, u);
        // 책상
        bgGrad(lg, w, h, '#3a271a', '#21160e');
        lg.strokeStyle = 'rgba(0,0,0,0.25)';
        for (let y = 0; y < h; y += 5 * u) {
          lg.lineWidth = 0.6 * u + r() * u;
          lg.beginPath();
          lg.moveTo(0, y);
          for (let x = 0; x <= w; x += 20 * u) lg.lineTo(x, y + Math.sin(x * 0.01 + y) * 2 * u);
          lg.stroke();
        }
        // 종이 모양 (울퉁불퉁 가장자리)
        const m = 9 * u;
        const edge: V2[] = [];
        const per = (x0: number, y0: number, x1: number, y1: number, nx: number, ny: number): void => {
          const L = Math.hypot(x1 - x0, y1 - y0);
          const n = Math.ceil(L / (4 * u));
          for (let i = 0; i < n; i++) {
            const f = i / n;
            const j = (vnoise(f * 12 + x0 * 0.01, y0 * 0.01, seed) - 0.5) * 7 * u * (0.5 + age) + (r() - 0.5) * 1.6 * u;
            edge.push([lerp(x0, x1, f) + nx * j, lerp(y0, y1, f) + ny * j]);
          }
        };
        per(m, m, w - m, m, 0, 1);
        per(w - m, m, w - m, h - m, -1, 0);
        per(w - m, h - m, m, h - m, 0, -1);
        per(m, h - m, m, m, 1, 0);
        const paperPath = (): void => {
          lg.beginPath();
          edge.forEach(([x, y], i) => (i ? lg.lineTo(x, y) : lg.moveTo(x, y)));
          lg.closePath();
        };
        lg.save();
        lg.shadowColor = 'rgba(0,0,0,0.6)';
        lg.shadowBlur = 14 * u;
        lg.shadowOffsetY = 5 * u;
        paperPath();
        lg.fillStyle = '#e3cc96';
        lg.fill();
        lg.restore();
        lg.save();
        paperPath();
        lg.clip();
        const pg = lg.createRadialGradient(w * 0.5, h * 0.45, 0, w * 0.5, h * 0.5, Math.hypot(w, h) * 0.6);
        pg.addColorStop(0, '#f3e3b8');
        pg.addColorStop(0.6, '#e2c58c');
        pg.addColorStop(1, '#b98a4e');
        lg.fillStyle = pg;
        lg.fillRect(0, 0, w, h);
        // 얼룩 · 결
        for (let k = 0; k < 60 * (0.4 + age); k++) {
          const x = r() * w;
          const y = r() * h;
          const rad = (4 + r() * 26) * u;
          const sg = lg.createRadialGradient(x, y, 0, x, y, rad);
          sg.addColorStop(0, `rgba(120,80,30,${0.05 + r() * 0.08 * age})`);
          sg.addColorStop(1, 'rgba(120,80,30,0)');
          lg.fillStyle = sg;
          lg.fillRect(x - rad, y - rad, rad * 2, rad * 2);
        }
        lg.fillStyle = 'rgba(90,60,25,0.06)';
        for (let k = 0; k < 1500; k++) lg.fillRect(r() * w, r() * h, u * (0.5 + r()), u * (0.5 + r()));
        // 커피 고리
        lg.strokeStyle = `rgba(110,65,25,${0.12 + 0.15 * age})`;
        lg.lineWidth = 2.2 * u;
        lg.beginPath();
        lg.arc(w * 0.82, h * 0.78, 16 * u, 0.3, 5.6);
        lg.stroke();
        // 바다 잉크 물결선 (섬 둘레 고리)
        const ink = 'rgba(70,40,18,';
        const islPath = (g2: G): void => {
          g2.beginPath();
          isl.forEach(([x, y], i) => (i ? g2.lineTo(x, y) : g2.moveTo(x, y)));
          g2.closePath();
        };
        const rip = mk(w * 2, h * 2);
        const rg = rip.getContext('2d')!;
        rg.scale(2, 2);
        rg.lineJoin = 'round';
        for (let k = 5; k >= 1; k--) {
          islPath(rg);
          rg.globalCompositeOperation = 'source-over';
          rg.strokeStyle = `${ink}${0.12 + 0.1 * (5 - k) / 4})`;
          rg.lineWidth = k * 7 * u;
          rg.stroke();
          rg.globalCompositeOperation = 'destination-out';
          rg.lineWidth = k * 7 * u - 1.3 * u;
          rg.strokeStyle = '#000';
          rg.stroke();
        }
        lg.drawImage(rip, 0, 0, w, h);
        // 바다 물결 무늬
        lg.strokeStyle = `${ink}0.35)`;
        lg.lineWidth = 0.9 * u;
        for (let k = 0; k < 14; k++) {
          const x = r() * w;
          const y = r() * h;
          if (Math.hypot((x - cx) / 1.6, y - cy) < Math.min(w, h) * 0.38) continue;
          lg.beginPath();
          lg.moveTo(x, y);
          lg.quadraticCurveTo(x + 4 * u, y - 3 * u, x + 8 * u, y);
          lg.quadraticCurveTo(x + 12 * u, y - 3 * u, x + 16 * u, y);
          lg.stroke();
        }
        // 섬
        islPath(lg);
        const ig = lg.createRadialGradient(cx, cy, 0, cx, cy, Math.min(w, h) * 0.4);
        ig.addColorStop(0, 'rgba(160,150,80,0.45)');
        ig.addColorStop(1, 'rgba(190,160,90,0.3)');
        lg.fillStyle = ig;
        lg.fill();
        lg.strokeStyle = `${ink}0.9)`;
        lg.lineWidth = 1.6 * u;
        lg.stroke();
        // 해변 점 찍기
        lg.save();
        islPath(lg);
        lg.clip();
        lg.lineWidth = 6 * u;
        lg.strokeStyle = 'rgba(240,215,150,0.5)';
        islPath(lg);
        lg.stroke();
        // 산 (잉크 ∧ + 빗금)
        for (let k = 0; k < 6; k++) {
          const x = cx - Math.min(w, h) * 0.12 + k * 13 * u + (r() - 0.5) * 6 * u;
          const y = cy + (k % 2 ? -6 : 4) * u - 10 * u;
          const s = (8 + r() * 5) * u;
          lg.fillStyle = 'rgba(150,120,70,0.35)';
          lg.beginPath();
          lg.moveTo(x - s, y + s * 0.6);
          lg.lineTo(x, y - s * 0.6);
          lg.lineTo(x + s, y + s * 0.6);
          lg.fill();
          lg.strokeStyle = `${ink}0.85)`;
          lg.lineWidth = 1.2 * u;
          lg.beginPath();
          lg.moveTo(x - s, y + s * 0.6);
          lg.lineTo(x, y - s * 0.6);
          lg.lineTo(x + s, y + s * 0.6);
          lg.stroke();
          lg.lineWidth = 0.7 * u;
          for (let q = 1; q < 4; q++) {
            lg.beginPath();
            lg.moveTo(x + (s * q) / 5, y - s * 0.6 + (s * 1.2 * q) / 5);
            lg.lineTo(x + (s * q) / 5 - s * 0.15, y + s * 0.6);
            lg.stroke();
          }
        }
        // 야자나무
        for (let k = 0; k < 5; k++) {
          const a = r() * TAU;
          const x = cx + Math.cos(a) * Math.min(w, h) * 0.24;
          const y = cy + Math.sin(a) * Math.min(w, h) * 0.13 + 8 * u;
          const s = 7 * u;
          lg.strokeStyle = `${ink}0.85)`;
          lg.lineWidth = 1.3 * u;
          lg.beginPath();
          lg.moveTo(x, y);
          lg.quadraticCurveTo(x + s * 0.4, y - s * 0.8, x + s * 0.2, y - s * 1.6);
          lg.stroke();
          for (let q = 0; q < 5; q++) {
            const b = -Math.PI / 2 + (q - 2) * 0.7;
            lg.beginPath();
            lg.moveTo(x + s * 0.2, y - s * 1.6);
            lg.quadraticCurveTo(x + s * 0.2 + Math.cos(b) * s * 0.7, y - s * 1.6 + Math.sin(b) * s * 0.5 - s * 0.3, x + s * 0.2 + Math.cos(b) * s * 1.1, y - s * 1.6 + Math.sin(b) * s * 0.6 + s * 0.3);
            lg.stroke();
          }
        }
        lg.restore();
        // 배
        {
          const [x, y] = ship;
          const s = 11 * u;
          lg.fillStyle = 'rgba(110,70,30,0.6)';
          lg.strokeStyle = `${ink}0.9)`;
          lg.lineWidth = 1.2 * u;
          lg.beginPath();
          lg.moveTo(x - s, y);
          lg.lineTo(x + s, y);
          lg.lineTo(x + s * 0.7, y + s * 0.4);
          lg.lineTo(x - s * 0.7, y + s * 0.4);
          lg.closePath();
          lg.fill();
          lg.stroke();
          lg.beginPath();
          lg.moveTo(x, y);
          lg.lineTo(x, y - s * 1.3);
          lg.stroke();
          lg.fillStyle = 'rgba(245,232,200,0.9)';
          lg.beginPath();
          lg.moveTo(x + 1 * u, y - s * 1.2);
          lg.quadraticCurveTo(x + s * 0.9, y - s * 0.7, x + 1 * u, y - s * 0.2);
          lg.closePath();
          lg.fill();
          lg.stroke();
          lg.beginPath();
          lg.moveTo(x - 1 * u, y - s * 1.1);
          lg.quadraticCurveTo(x - s * 0.7, y - s * 0.65, x - 1 * u, y - s * 0.3);
          lg.closePath();
          lg.fill();
          lg.stroke();
        }
        // 바다 괴물 꼬리
        {
          const x = w * 0.78;
          const y = h * 0.28;
          lg.strokeStyle = `${ink}0.8)`;
          lg.lineWidth = 1.3 * u;
          for (let q = 0; q < 3; q++) {
            lg.beginPath();
            lg.arc(x + q * 12 * u, y, 5 * u, Math.PI, 0);
            lg.stroke();
          }
          lg.beginPath();
          lg.moveTo(x + 41 * u, y);
          lg.lineTo(x + 46 * u, y - 6 * u);
          lg.lineTo(x + 44 * u, y + 1 * u);
          lg.stroke();
        }
        // 나침반 장미
        {
          const x = w * 0.85;
          const y = h * 0.62;
          const R = Math.min(w, h) * 0.13;
          lg.strokeStyle = `${ink}0.8)`;
          lg.lineWidth = 1 * u;
          lg.beginPath();
          lg.arc(x, y, R * 0.8, 0, TAU);
          lg.stroke();
          lg.beginPath();
          lg.arc(x, y, R * 0.72, 0, TAU);
          lg.stroke();
          for (let i = 0; i < 8; i++) {
            const a = (i * Math.PI) / 4 - Math.PI / 2;
            const L = i % 2 ? R * 0.6 : R;
            const s = i % 2 ? R * 0.1 : R * 0.15;
            for (const side of [-1, 1]) {
              lg.beginPath();
              lg.moveTo(x, y);
              lg.lineTo(x + Math.cos(a) * L, y + Math.sin(a) * L);
              lg.lineTo(x + Math.cos(a + (side * Math.PI) / 2) * s, y + Math.sin(a + (side * Math.PI) / 2) * s);
              lg.closePath();
              lg.fillStyle = side > 0 ? 'rgba(70,40,18,0.85)' : 'rgba(240,225,190,0.9)';
              lg.fill();
              lg.stroke();
            }
          }
          txt(lg, 'N', x, y - R - 6 * u, 9 * u, 'rgba(120,30,20,0.9)', 'center', 900, 'Georgia, serif');
        }
        // 제목
        txt(lg, '보물섬', w * 0.18, h * 0.16, 17 * u, 'rgba(70,40,18,0.9)', 'center', 700, HAND);
        lg.strokeStyle = `${ink}0.6)`;
        lg.lineWidth = 1 * u;
        lg.beginPath();
        lg.moveTo(w * 0.1, h * 0.16 + 11 * u);
        lg.quadraticCurveTo(w * 0.18, h * 0.16 + 15 * u, w * 0.26, h * 0.16 + 10 * u);
        lg.stroke();
        // 접힌 자국
        for (const [x0, y0, x1, y1] of [
          [w * 0.5, 0, w * 0.5, h],
          [0, h * 0.5, w, h * 0.5],
        ] as [number, number, number, number][]) {
          const vert = x0 === x1;
          const lgr = vert ? lg.createLinearGradient(x0 - 6 * u, 0, x0 + 6 * u, 0) : lg.createLinearGradient(0, y0 - 6 * u, 0, y0 + 6 * u);
          lgr.addColorStop(0, 'rgba(0,0,0,0)');
          lgr.addColorStop(0.48, 'rgba(90,60,25,0.16)');
          lgr.addColorStop(0.52, 'rgba(255,245,215,0.22)');
          lgr.addColorStop(1, 'rgba(0,0,0,0)');
          lg.fillStyle = lgr;
          if (vert) lg.fillRect(x0 - 6 * u, y0, 12 * u, y1 - y0);
          else lg.fillRect(x0, y0 - 6 * u, x1 - x0, 12 * u);
        }
        // 탄 가장자리
        lg.lineJoin = 'round';
        paperPath();
        lg.strokeStyle = 'rgba(60,30,10,0.55)';
        lg.lineWidth = (10 + 18 * age) * u;
        lg.filter = `blur(${(3 + 4 * age) * u}px)`;
        lg.stroke();
        lg.filter = 'none';
        paperPath();
        lg.strokeStyle = 'rgba(40,18,5,0.85)';
        lg.lineWidth = 2.2 * u;
        lg.stroke();
        lg.restore();
      };
      return {
        draw(g, w, h, t, dt) {
          reset(g);
          const u = scaleOf(w, h);
          const dpr = dprOf(g);
          phaseT += Math.min(dt, 0.1) * speed;
          const L = layer.get(`${seed}|${age}|${w}|${h}|${dpr}`, w, h, dpr, (lg) => paint(lg, w, h, u));
          g.drawImage(L, 0, 0, w, h);
          const G2 = geo(w, h, u);
          const pts = smoothPts(G2.path, 12);
          const drawK = clamp01(phaseT / 3.2);
          // 잉크 점선 길
          g.setLineDash([5 * u, 4 * u]);
          g.strokeStyle = 'rgba(150,30,25,0.9)';
          g.lineWidth = 1.9 * u;
          g.lineCap = 'round';
          const head = strokePathPart(g, pts, ease(drawK));
          g.setLineDash([]);
          // 깃펜 끝
          if (head && drawK < 1) {
            g.save();
            g.translate(head[0], head[1]);
            g.rotate(-0.7);
            g.fillStyle = 'rgba(250,245,235,0.95)';
            g.strokeStyle = 'rgba(60,40,20,0.8)';
            g.lineWidth = 0.8 * u;
            g.beginPath();
            g.moveTo(0, 0);
            g.quadraticCurveTo(4 * u, -10 * u, 2 * u, -24 * u);
            g.quadraticCurveTo(-4 * u, -12 * u, 0, 0);
            g.fill();
            g.stroke();
            g.restore();
          }
          // X 표
          const xk = clamp01((phaseT - 3.2) / 0.5);
          if (xk > 0) {
            const [x, y] = G2.X;
            const s = 8 * u * (1 + 0.08 * Math.sin(t * 5));
            g.strokeStyle = '#a3121a';
            g.lineWidth = 3.2 * u;
            g.lineCap = 'round';
            g.beginPath();
            const k1 = clamp01(xk * 2);
            const k2 = clamp01(xk * 2 - 1);
            g.moveTo(x - s, y - s);
            g.lineTo(x - s + 2 * s * k1, y - s + 2 * s * k1);
            if (k2 > 0) {
              g.moveTo(x + s, y - s);
              g.lineTo(x + s - 2 * s * k2, y - s + 2 * s * k2);
            }
            g.stroke();
            if (xk >= 1) {
              g.globalAlpha = 0.5 + 0.5 * Math.sin(t * 4);
              g.strokeStyle = 'rgba(200,40,30,0.5)';
              g.lineWidth = 1.2 * u;
              g.beginPath();
              g.arc(x, y, s * 1.9, 0, TAU);
              g.stroke();
              g.globalAlpha = 1;
// … (22줄 더 — 전체는 라이브 견본 페이지의 「코드」 탭)
```

## 관련 기술
- 먼저 알면 좋은 기술: [잡음 섬 지도 (높이 → 생물군)](https://ai-techstudio.web.app/ai/t/i245.md) `i245`
- 다음에 해 볼 기술: [디오라마 지도 (3D 섬 판)](https://ai-techstudio.web.app/ai/t/i275.md) `i275` · [미니맵 (작은 지도 + 시야 틀)](https://ai-techstudio.web.app/ai/t/i271.md) `i271`
