# AI 꾸러미 — 잡음 섬 지도 (높이 → 생물군) — Procedural island map (fBm noise heightmap)
> 잡음을 여러 겹 쌓아 높이를 만들고 가장자리를 낮춰 섬 모양으로, 높이 · 습도로 바다 · 모래 · 숲 · 눈을 색칠한다.  
> 견본: https://ai-techstudio.web.app/#t/i245

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

## 주문서

### 만들어 줘: 잡음 섬 지도 (높이 → 생물군) — Procedural island map (fBm noise heightmap)

#### 1. 목표
보물섬 지도를 잡음 높이 지도 + 섬 틀 + 습도로 만들어 줘 — 바다 · 모래 · 풀 · 숲 · 바위 · 눈이 높이대로 보이게. 분위기는 밝은 지도 색.

#### 2. 핵심 기술 용어
- **Procedural island map (fBm noise heightmap)** — 잡음으로 만드는 섬 지도
- **Fractal Brownian motion (octaves)** — 크기가 다른 잡음을 반씩 줄여 겹치기
- **Radial falloff (island mask)** — 가운데에서 멀수록 높이를 깎는 섬 틀
- **Biome map (elevation × moisture)** — 높이와 습도 두 값으로 땅 종류 고르기

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

#### 4. 조건
- 잡음 · 시드는 직접 만든 해시(같은 시드 = 같은 지도) — Math.random 으로 칸 값을 정하지 않기
- 바다 높이는 고정값이 아니라 「땅 비율」로 정렬해서 고르기 (시드마다 바다만 나오는 일이 없게)
- 지도는 화면 밖 캔버스(ImageData)에 한 번 굽고, 매 프레임엔 그 그림만 그리기
- 단계 보기: ① 잡음 겹치기 ② 가장자리 낮추기 ③ 색칠이 차례로 보이게

#### 5. 완성 기준 (이게 보이면 성공)
- 「새 시드」를 누르면 다른 섬이 나오고, 같은 시드면 똑같은 섬이 나온다
- 회색 잡음 → 섬 모양 → 색 지도로 바뀌는 단계가 보인다
- 「섬 모양 세기」를 0 으로 하면 가장자리까지 땅이 이어지고, 키우면 작은 섬이 된다
- 범례(깊은 바다 · 모래 · 풀밭 · 숲 · 바위 · 눈)의 색이 지도 색과 맞다

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

## 원리
- 값 잡음(value noise)을 크기 2배씩 작게 · 세기 반씩 약하게 여러 번(옥타브) 더하면 산과 골짜기가 있는 높이가 된다 (fBm).
- 가운데에서의 거리 d² = 0.9·nx² + ny² 만큼 깎으면 (h = 잡음 − 0.62·d²) 가장자리가 바다로 가라앉아 섬이 된다.
- 바다 높이는 정렬한 높이에서 「땅 45%」가 되는 값으로 정해, 시드가 달라도 땅 넓이가 비슷하다.
- 바다 위 높이 t 와 따로 만든 습도 m 으로 모래 → 풀 · 덤불 → 숲 → 바위 → 눈을 smoothstep 으로 섞어 칠한다.
- 이웃 칸과의 높이 차로 언덕 그늘(밝기 0.68~1.28)을 곱하면 평면 지도에도 입체감이 생긴다.

## 핵심 코드 — 높이 = fBm − 섬 틀, 바다 높이 = 땅 45% 되는 값
(발췌: demos/demosMapA.ts makeTerrain · fbm 을 정리)
```ts
function fbm(x: number, y: number, s = 0, oct = 4): number {
  let a = 0.5, f = 1, sum = 0, n = 0;
  for (let k = 0; k < oct; k++) {
    sum += vnoise(x * f, y * f, s + k * 17) * a; // 옥타브마다 다른 시드
    n += a;
    a *= 0.5;   // 세기 반
    f *= 2.03;  // 크기 2배 작게 (정확히 2 면 무늬가 겹쳐 보인다)
  }
  return sum / n;
}

function makeTerrain(W: number, H: number, seed: number, land = 0.45, isl = 1) {
  const h = new Float32Array(W * H), m = new Float32Array(W * H);
  const sc = 3.4 / H;
  const ox = hash2(seed, 1) * 200, oy = hash2(seed, 2) * 200;
  for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
    const i = y * W + x;
    const nx = (x / (W - 1)) * 2 - 1, ny = (y / (H - 1)) * 2 - 1;
    const d2 = nx * nx * 0.9 + ny * ny;           // 가운데에서 거리²
    const n = fbm(x * sc + ox, y * sc + oy, seed, 5);
    h[i] = n - isl * 0.62 * d2;                   // 가장자리를 깎아 섬으로
    m[i] = fbm(x * sc * 0.8 + 40, y * sc * 0.8 + 20, seed + 77, 3); // 습도
  }
  const s = Array.from(h).sort((a, b) => a - b);
  const sea = s[Math.floor((1 - land) * (s.length - 1))]; // 땅이 45% 되는 높이
  return { W, H, h, m, sea, hi: s[s.length - 1] };
}
// 색칠: t = (h − sea) / (hi − sea) → 모래 · 풀(습도로 마른 풀 ↔ 무성한 풀) · 숲 · 바위(0.52~) · 눈(0.76~)
```

## 흔한 실수 · 확인 목록
- [ ] **바다 높이를 고정값으로 두면 시드마다 땅 넓이가 들쭉날쭉하다** — 높이를 정렬해 「땅 비율」이 되는 값을 바다 높이로 쓴다.
- [ ] **옥타브 배율을 정확히 2 로 두면 격자 무늬가 겹쳐 보인다** — 견본처럼 2.03 처럼 조금 어긋나게, 옥타브마다 시드도 바꾼다.
- [ ] **매 프레임 칸마다 잡음을 다시 계산하면 느리다** — 지형은 시드가 바뀔 때만 ImageData 에 굽고, 프레임마다 그림만 그린다.
- [ ] **경계를 if 로 딱 끊으면 땅 색 사이가 계단처럼 거칠다** — smoothstep 으로 좁은 구간에서 섞는다 (모래 → 풀 0.025~0.05).

## 완성 기준 체크리스트
- [ ] 「새 시드」를 누르면 다른 섬이 나오고, 같은 시드면 똑같은 섬이 나온다
- [ ] 회색 잡음 → 섬 모양 → 색 지도로 바뀌는 단계가 보인다
- [ ] 「섬 모양 세기」를 0 으로 하면 가장자리까지 땅이 이어지고, 키우면 작은 섬이 된다
- [ ] 범례(깊은 바다 · 모래 · 풀밭 · 숲 · 바위 · 눈)의 색이 지도 색과 맞다

## 이 기술 정보
- id: `i245` · 분류: 지도 · 길찾기 › 지도 생성 (절차) · 2D · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 320×200 칸 = 6만 4천 칸에 잡음 몇 번이라 한 번 굽는 데 수십 ms. 다 구운 뒤엔 그림 한 장(drawImage)이라 폰도 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i245
- 쓰면 좋을 때: 게임마다 새로운 섬 · 대륙 지도가 필요할 때 / 좌표 · 높이 읽기 체험 판의 배경 / 같은 시드로 같은 지도를 다시 만들어야 할 때
- 쓰지 말 때: 정해진 모양의 실제 지도 — 대신 그린 그림이나 자료 지도를 쓴다 / 나라 · 지역 경계가 주인공일 때 — 대신 보로노이 지역(i246)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i245 견본 항목 — `src/demos/demosMapA.ts:500`
```ts
  i245: {
    kind: '2d',
    caption: '잡음을 겹치고(①) 가장자리를 낮춰 섬으로(②) — 높이 · 습도로 바다 · 모래 · 풀 · 숲 · 바위 · 눈 색칠(③)',
    make() {
      let T: Terrain | null = null;
      let W = 0;
      let H = 0;
      let L: Float32Array[] = [];
      let fin: Uint8ClampedArray = new Uint8ClampedArray(4);
      let cv = mkCanvas(1, 1);
      let img: ImageData | null = null;
      let tk = 0;
      let steps = true;
      let isl = 1;
      let res = 150;
      const A = 110;
      const B = 55;
      const C = 75;
      const build = (seed: number): void => {
        W = res;
        H = Math.round(res * 0.625);
        T = makeTerrain(W, H, seed, 0.45, isl);
        const sc = 3.4 / H;
        const ox = hash2(seed, 1) * 200;
        const oy = hash2(seed, 2) * 200;
        L = [1, 2, 3].map((o) => {
          const a = new Float32Array(W * H);
          for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) a[y * W + x] = fbm(x * sc + ox, y * sc + oy, seed, o);
          return a;
        });
        L.push(T.raw);
        cv = mkCanvas(W, H);
        img = c2(cv).createImageData(W, H);
        fin = new Uint8ClampedArray(W * H * 4);
        for (let y = 0; y < H; y++)
          for (let x = 0; x < W; x++) {
            const c = terrainColor(T, x, y);
            const o = (y * W + x) * 4;
            fin[o] = c[0];
            fin[o + 1] = c[1];
            fin[o + 2] = c[2];
            fin[o + 3] = 255;
          }
      };
      const run = runner({
        rate: 60,
        hold: 1.8,
        start(seed) {
          build(seed);
          tk = steps ? 0 : A + B;
        },
        step() {
          tk++;
          return tk >= A + B + C;
        },
      });
      run.restart();
      let lastStage = '';
      const paint = (): void => {
        if (!T || !img) return;
        const d = img.data;
        const n = W * H;
        const gray = (v: number, o: number): void => {
          const k = clamp01((v + 0.15) / 0.95);
          const kk = k * k * (3 - 2 * k);
          d[o] = 18 + kk * 222;
          d[o + 1] = 22 + kk * 220;
          d[o + 2] = 34 + kk * 214;
          d[o + 3] = 255;
        };
        if (tk < A) {
          const f = (tk / A) * 3;
          const li = Math.min(2, Math.floor(f));
          const k = ease(f - li);
          const a = L[li]!;
          const b = L[li + 1]!;
          for (let i = 0; i < n; i++) gray(lerp(a[i]!, b[i]!, k), i * 4);
          lastStage = `① 잡음 겹치기 · 옥타브 ${li + 1 + (k > 0.5 ? 1 : 0)}`;
        } else if (tk < A + B) {
          const k = ease((tk - A) / B);
          for (let i = 0; i < n; i++) gray(lerp(T.raw[i]!, T.h[i]!, k), i * 4);
          lastStage = '② 가장자리 낮추기 → 섬 모양';
        } else {
          const p = (tk - A - B) / C;
          for (let y = 0; y < H; y++)
            for (let x = 0; x < W; x++) {
              const i = y * W + x;
              const o = i * 4;
              const front = p * 1.3 - 0.15;
              const pos = (x / W) * 0.75 + (y / H) * 0.25;
              const k = smooth(front + 0.15, front, pos);
              const gk = clamp01((T.h[i]! + 0.15) / 0.95);
              const gg = gk * gk * (3 - 2 * gk);
              const edge = Math.max(0, 1 - Math.abs(pos - front - 0.07) * 18) * 60;
              d[o] = lerp(18 + gg * 222, fin[o]!, k) + edge;
              d[o + 1] = lerp(22 + gg * 220, fin[o + 1]!, k) + edge;
              d[o + 2] = lerp(34 + gg * 214, fin[o + 2]!, k) + edge;
              d[o + 3] = 255;
            }
          lastStage = run.holding ? `완성 · 시드 ${run.seed}` : '③ 높이 · 습도 → 생물군 색칠';
        }
        c2(cv).putImageData(img, 0, 0);
      };
      const legend: [string, V3][] = [
        ['깊은 바다', SEA[4]![1]],
        ['얕은 바다', SEA[1]![1]],
        ['모래', C_SAND],
        ['풀밭', C_GRASS],
        ['숲', C_FOREST],
        ['바위', C_ROCK],
        ['눈', C_SNOW],
      ];
      return {
        draw(g, w, h, _t, dt) {
          reset(g);
          const want = w > 420 ? 320 : 150;
          if (want !== res) {
            res = want;
            run.restart(run.seed);
          }
          run.tick(dt);
          paint();
          drawCover(g, cv, w, h);
          vignette(g, w, h, 0.3);
          const u = ui(scaleOf(w, h));
          pill(g, lastStage, 8 * ui(u), 13 * ui(u), 9 * ui(u), 'rgba(10,20,40,0.72)');
          if (tk >= A + B && w > 420) {
            let x = 10 * u;
            const y = h - 14 * u;
            g.font = `700 ${8 * u}px ${F}`;
            for (const [name, c] of legend) {
              const tw = g.measureText(name).width;
              rr(g, x, y - 7 * u, tw + 20 * u, 14 * u, 7 * u);
              g.fillStyle = 'rgba(10,20,40,0.6)';
              g.fill();
              g.beginPath();
              g.arc(x + 7 * u, y, 3.5 * u, 0, TAU);
              g.fillStyle = rgb(c);
              g.fill();
              txt(g, name, x + 13 * u, y, 8 * u, '#fff', 'left');
              x += tw + 24 * u;
            }
          }
        },
        controls: [
          seedCtl(run),
          speedCtl(run),
          { type: 'toggle', label: '단계 보기 (잡음 → 섬 → 색)', value: true, on: (v) => { steps = v; run.restart(run.seed); } },
          { type: 'range', label: '섬 모양 세기', min: 0, max: 1.6, step: 0.1, value: 1, on: (v) => { isl = v; run.restart(run.seed); } },
        ],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [시드 지도 (같은 수 = 같은 지도)](https://ai-techstudio.web.app/ai/t/i255.md) `i255`
- 다음에 해 볼 기술: [보로노이 지역 · 나라 나누기](https://ai-techstudio.web.app/ai/t/i246.md) `i246` · [강 흘려보내기 (내리막 따라)](https://ai-techstudio.web.app/ai/t/i251.md) `i251` · [마칭 스퀘어 (등고선 · 경계)](https://ai-techstudio.web.app/ai/t/i259.md) `i259` · [디오라마 지도 (3D 섬 판)](https://ai-techstudio.web.app/ai/t/i275.md) `i275`
- 참고 문서: [Red Blob Games — Making maps with noise functions](https://www.redblobgames.com/maps/terrain-from-noise/)
