# AI 꾸러미 — 흐름장 (많은 유닛 한꺼번에) — Flow field pathfinding
> 목표에서 거꾸로 거리 지도를 한 번 만들고 칸마다 내리막 화살표를 두어, 수백 마리 유닛이 그 지도 하나만 보고 몰려가게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i265

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

## 주문서

### 만들어 줘: 흐름장 (많은 유닛 한꺼번에) — Flow field pathfinding

#### 1. 목표
유닛 수백 마리 무리를 흐름장으로 움직여 줘 — 목표에서 거리 물결이 퍼지며 칸마다 화살표가 생기고, 유닛 수백 마리가 서로 밀며 목표로 몰려가게. 분위기는 어두운 바탕 형광 점.

#### 2. 핵심 기술 용어
- **Flow field pathfinding** — 칸마다 갈 방향 화살표를 둔 지도
- **Distance field (Dijkstra map)** — 목표에서 거꾸로 잰 거리 지도
- **Crowd separation (spatial hash)** — 가까운 유닛끼리 밀어내기 — 칸별 목록으로 이웃 찾기

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

#### 4. 조건
- 거리 지도는 목표에서 거꾸로 한 번만 — 유닛마다 길찾기를 돌리지 않기
- 유닛 위치 · 속도는 Float32Array 에 모아 두기 (객체 수백 개 대신)
- 밀어내기 이웃은 칸별 목록으로 둘레 9칸만 — 모든 쌍(n²)을 비교하지 않기
- 속도는 원하는 속도로 부드럽게 다가가게(lerp, dt·7), 벽에 막히면 그 축만 튕기기
- 거리 물결이 퍼지는 모습 · 화살표 · 거리 숫자를 켜고 끌 수 있게

#### 5. 완성 기준 (이게 보이면 성공)
- 목표가 바뀌면 거리 물결이 목표에서 둥글게 퍼지며 칸이 물들고 화살표가 생긴다
- 유닛 수백 마리가 벽을 돌아 목표로 흘러가고 서로 겹치지 않는다
- 유닛 수를 800 으로 늘려도 화면이 끊기지 않는다
- 거리 숫자를 켜면 목표에서 멀수록 수가 커진다

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

## 원리
- 목표 칸에서 다익스트라를 한 번 돌려 모든 칸의 「목표까지 거리」를 구한다 (벽은 무한대).
- 칸마다 8이웃 중 거리가 가장 작은 쪽을 가리키는 단위 화살표를 저장한다 — 이것이 흐름장.
- 유닛은 길찾기 없이, 지금 서 있는 칸의 화살표 × 속도(3.4)를 원하는 속도로 삼는다. 유닛이 몇 마리든 지도는 하나.
- 가까운 유닛끼리(거리 0.4 안) 밀어내는 힘을 더해 겹치지 않게 하고, 이웃 찾기는 칸별 연결 목록(head · next)으로 둘레 9칸만 본다.
- 목표가 바뀌면 지도만 다시 만든다 (견본은 4.2초마다 새 목표).

## 핵심 코드 — 거리 지도 → 칸마다 화살표 → 유닛은 화살표만 따라
(발췌: demos/demosMapB.ts i265 retarget · 유닛 움직이기를 정리 (dijkstra · eachNb 는 같은 파일))
```ts
// 1) 목표에서 거꾸로 거리 지도 + 칸마다 내리막 화살표
const dist = dijkstra(C, R, cost, goal); // 벽 칸 cost = Infinity
const dir = new Float32Array(C * R * 2);
for (let i = 0; i < C * R; i++) {
  let bx = 0, by = 0, bd = dist[i];
  eachNb(C, R, (j) => wall[j] === 1, i, (j) => {
    if (dist[j] < bd) { bd = dist[j]; bx = (j % C) - (i % C); by = ((j / C) | 0) - ((i / C) | 0); }
  });
  const l = Math.hypot(bx, by) || 1;
  dir[i * 2] = bx / l; dir[i * 2 + 1] = by / l;
}
// 2) 매 프레임: 칸별 목록 → 화살표 + 밀어내기
head.fill(-1);
for (let k = 0; k < alive; k++) { const ci = (py[k] | 0) * C + (px[k] | 0); next[k] = head[ci]; head[ci] = k; }
const SP = 3.4;
for (let k = 0; k < alive; k++) {
  const x = px[k], y = py[k], ci = (y | 0) * C + (x | 0);
  let wx = dir[ci * 2] * SP, wy = dir[ci * 2 + 1] * SP;
  let sx = 0, sy = 0;
  for (let yy = (y | 0) - 1; yy <= (y | 0) + 1; yy++) for (let xx = (x | 0) - 1; xx <= (x | 0) + 1; xx++) {
    if (xx < 0 || yy < 0 || xx >= C || yy >= R) continue;
    for (let o = head[yy * C + xx]; o >= 0; o = next[o]) {
      if (o === k) continue;
      const ddx = x - px[o], ddy = y - py[o], d2 = ddx * ddx + ddy * ddy;
      if (d2 < 0.16 && d2 > 1e-6) { const d = Math.sqrt(d2); sx += (ddx / d) * (0.4 - d); sy += (ddy / d) * (0.4 - d); }
    }
  }
  wx += sx * 14; wy += sy * 14;
  vx[k] += (wx - vx[k]) * Math.min(1, dt * 7);
  vy[k] += (wy - vy[k]) * Math.min(1, dt * 7);
  const nx = x + vx[k] * dt; if (free(nx, y)) px[k] = nx; else vx[k] *= -0.2;      // 벽이면 그 축만 튕김
  const ny = py[k] + vy[k] * dt; if (free(px[k], ny)) py[k] = ny; else vy[k] *= -0.2;
}
```

## 흔한 실수 · 확인 목록
- [ ] **유닛마다 A* 를 돌리면 수백 마리에서 멈춘다** — 목표가 같으면 흐름장 하나를 모두가 함께 본다.
- [ ] **모든 유닛 쌍을 비교해 밀어내면 n² 이라 느리다** — 칸별 연결 목록으로 둘레 9칸 안의 유닛만 본다.
- [ ] **목표 바로 옆에서 유닛이 빙빙 돈다** — 목표 근처(거리 1.2 안)에서는 화살표 대신 목표 칸 가운데로 곧장 당긴다.
- [ ] **벽에 부딪힌 유닛이 벽 속으로 파고든다** — x · y 축을 따로 옮겨 막힌 축만 되돌리고 그 축 속도를 −0.2 배로 튕긴다.

## 완성 기준 체크리스트
- [ ] 목표가 바뀌면 거리 물결이 목표에서 둥글게 퍼지며 칸이 물들고 화살표가 생긴다
- [ ] 유닛 수백 마리가 벽을 돌아 목표로 흘러가고 서로 겹치지 않는다
- [ ] 유닛 수를 800 으로 늘려도 화면이 끊기지 않는다
- [ ] 거리 숫자를 켜면 목표에서 멀수록 수가 커진다

## 이 기술 정보
- id: `i265` · 분류: 지도 · 길찾기 › 길찾기 · 이동 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 32칸 판 다익스트라 한 번 + 칸마다 8이웃 비교 = 목표가 바뀔 때만. 유닛 800마리도 칸 조회 + 둘레 9칸 밀기라 가볍다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i265
- 쓰면 좋을 때: 많은 유닛이 같은 목표로 움직일 때 / 목표가 하나이고 자주 바뀌지 않을 때 / 거리 숫자 지도를 수업에서 보여 줄 때
- 쓰지 말 때: 유닛마다 목표가 다를 때 — 대신 유닛마다 A*(i264) / 아주 큰 열린 땅 — 칸이 너무 많아지면 내비 메시(i267)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### paintTiles — `src/demos/demosMapB.ts:394`
```ts
function paintTiles(lg: G, fit: Fit, cols: number, rows: number, wall: Uint8Array, floorA: string, floorB: string, wallTop: string, wallSide: string): void {
  const { s, ox, oy } = fit;
  rr(lg, ox - s * 0.25, oy - s * 0.25, cols * s + s * 0.5, rows * s + s * 0.5, s * 0.6);
  lg.fillStyle = 'rgba(0,0,0,0.35)';
  lg.fill();
  for (let y = 0; y < rows; y++)
    for (let x = 0; x < cols; x++) {
      lg.fillStyle = (x + y) & 1 ? floorA : floorB;
      lg.fillRect(ox + x * s, oy + y * s, s, s);
    }
  // 벽: 그림자 → 옆면 → 윗면
  lg.fillStyle = 'rgba(0,0,0,0.35)';
  for (let i = 0; i < wall.length; i++)
    if (wall[i]) {
      const x = i % cols;
      const y = (i / cols) | 0;
      lg.fillRect(ox + x * s + s * 0.15, oy + y * s + s * 0.25, s, s);
    }
  for (let i = 0; i < wall.length; i++)
    if (wall[i]) {
      const x = ox + (i % cols) * s;
      const y = oy + ((i / cols) | 0) * s;
      lg.fillStyle = wallSide;
      lg.fillRect(x, y, s, s);
      lg.fillStyle = wallTop;
      lg.fillRect(x, y - s * 0.12, s, s * 0.88);
      lg.fillStyle = 'rgba(255,255,255,0.13)';
      lg.fillRect(x, y - s * 0.12, s, s * 0.12);
    }
}
```

### i265 견본 항목 — `src/demos/demosMapB.ts:754`
```ts
  i265: {
    kind: '2d',
    caption: '목표에서 거꾸로 거리 물결을 퍼뜨려 칸마다 화살표 하나 — 유닛 수백 마리가 그 지도 하나만 보고 몰려가요',
    make() {
      const C = 32;
      const R = 20;
      let wall = new Uint8Array(C * R);
      let cost: Float32Array = new Float32Array(C * R);
      let dist: Float32Array = new Float32Array(C * R);
      const dir = new Float32Array(C * R * 2);
      let goal = 0;
      let waveStart = 0;
      let lastGoalT = -99;
      let mapSeed = 3;
      let showArrows = true;
      let showNum = false;
      let count = 300;
      let now = 0;
      const MAXU = 800;
      const px = new Float32Array(MAXU);
      const py = new Float32Array(MAXU);
      const vx = new Float32Array(MAXU);
      const vy = new Float32Array(MAXU);
      let alive = 0;
      const head = new Int32Array(C * R);
      const next = new Int32Array(MAXU);
      const layer = new Layer();
      const free = (x: number, y: number): boolean => x >= 0 && y >= 0 && x < C && y < R && wall[(y | 0) * C + (x | 0)] === 0;
      const buildMap = (): void => {
        const r = rng(mapSeed);
        wall = new Uint8Array(C * R);
        for (let k = 0; k < 9; k++) {
          const bw = r() < 0.5 ? 1 + Math.floor(r() * 2) : 3 + Math.floor(r() * 5);
          const bh = bw <= 2 ? 3 + Math.floor(r() * 6) : 1 + Math.floor(r() * 2);
          const x0 = 2 + Math.floor(r() * (C - bw - 4));
          const y0 = 1 + Math.floor(r() * (R - bh - 2));
          for (let y = y0; y < y0 + bh; y++) for (let x = x0; x < x0 + bw; x++) wall[y * C + x] = 1;
        }
        for (let k = 0; k < 3; k++) {
          const x0 = 3 + Math.floor(r() * (C - 6));
          const y0 = 2 + Math.floor(r() * (R - 4));
          for (let y = y0; y < y0 + 2; y++) for (let x = x0; x < x0 + 2; x++) wall[y * C + x] = 1;
        }
        cost = new Float32Array(C * R);
        for (let i = 0; i < C * R; i++) cost[i] = wall[i] ? Infinity : 1;
        alive = 0;
      };
      const retarget = (): void => {
        const r = rng(mapSeed * 97 + Math.floor(now * 10));
        let best = goal;
        let bestD = -1;
        for (let k = 0; k < 30; k++) {
          const x = 1 + Math.floor(r() * (C - 2));
          const y = 1 + Math.floor(r() * (R - 2));
          const i = y * C + x;
          if (wall[i]) continue;
          const d = Math.hypot(x - (goal % C), y - ((goal / C) | 0)) + r() * 4;
          if (d > bestD) {
            bestD = d;
            best = i;
          }
        }
        goal = best;
        dist = dijkstra(C, R, cost, goal);
        for (let i = 0; i < C * R; i++) {
          let bx = 0;
          let by = 0;
          let bd = dist[i]!;
          eachNb(C, R, (j) => wall[j] === 1, i, (j) => {
            if (dist[j]! < bd) {
              bd = dist[j]!;
              bx = (j % C) - (i % C);
              by = ((j / C) | 0) - ((i / C) | 0);
            }
          });
          const l = Math.hypot(bx, by) || 1;
          dir[i * 2] = bx / l;
          dir[i * 2 + 1] = by / l;
        }
        waveStart = now;
        lastGoalT = now;
      };
      const spawn = (n: number): void => {
        const r = rng(alive * 13 + 7 + mapSeed);
        while (alive < n) {
          const x = r() * C;
          const y = r() * R;
          if (!free(x, y) || !isFinite(dist[(y | 0) * C + (x | 0)]!)) continue;
          px[alive] = x;
          py[alive] = y;
          vx[alive] = 0;
          vy[alive] = 0;
          alive++;
        }
      };
      buildMap();
      goal = 2 * C + 2;
      retarget();
      spawn(count);
      const UCOL = ['#ffd166', '#06d6a0', '#ff6b8b', '#8ecae6'];
      const HEAT: RGB[] = ['#ffcf6b', '#ff7a59', '#c94f86', '#6a4fa8', '#2c3e7a'].map(hexRGB);
      return {
        draw(g, w, h, t, dtRaw) {
          reset(g);
          now = t;
          const dt = Math.min(dtRaw, 0.05);
          const u = scaleOf(w, h);
          const dpr = dprOf(g);
          if (t - lastGoalT > 4.2 || t < lastGoalT) retarget();
          if (alive < count) spawn(count);
          if (alive > count) alive = count;
          bgGrad(g, w, h, '#16203a', '#0b1222');
          const headH = 18 * u;
          const fit = fitGrid(C, R, 6 * u, headH, w - 12 * u, h - headH - 6 * u);
          const { s, ox, oy } = fit;
          const L = layer.get(`${mapSeed}|${w}|${h}|${dpr}`, w, h, dpr, (lg) => paintTiles(lg, fit, C, R, wall, '#1f2a45', '#222e4a', '#56618a', '#2b3354'));
          g.drawImage(L, 0, 0, w, h);
          const waveR = (t - waveStart) * 30;
          let maxD = 1;
          for (let i = 0; i < C * R; i++) if (isFinite(dist[i]!) && dist[i]! > maxD) maxD = dist[i]!;
          // 거리 물든 칸
          for (let i = 0; i < C * R; i++) {
            const d = dist[i]!;
            if (wall[i] || !isFinite(d) || d > waveR) continue;
            const x = ox + (i % C) * s;
            const y = oy + ((i / C) | 0) * s;
            const front = waveR - d < 1.6 && waveR < maxD + 2;
            g.fillStyle = front ? 'rgba(255,255,255,0.55)' : css(rampAt(HEAT, d / maxD), 0.42);
            g.fillRect(x + 0.5, y + 0.5, s - 1, s - 1);
          }
          // 화살표
          if (showArrows && s >= 4) {
            g.beginPath();
            const a = s * 0.3;
            for (let i = 0; i < C * R; i++) {
              if (wall[i] || i === goal || dist[i]! > waveR || !isFinite(dist[i]!)) continue;
              const cx = ox + ((i % C) + 0.5) * s;
              const cy = oy + (((i / C) | 0) + 0.5) * s;
              const dx = dir[i * 2]!;
              const dy = dir[i * 2 + 1]!;
              const tx = cx + dx * a;
              const ty = cy + dy * a;
              g.moveTo(cx - dx * a, cy - dy * a);
              g.lineTo(tx, ty);
              g.moveTo(tx - dx * a * 0.55 - dy * a * 0.45, ty - dy * a * 0.55 + dx * a * 0.45);
              g.lineTo(tx, ty);
              g.lineTo(tx - dx * a * 0.55 + dy * a * 0.45, ty - dy * a * 0.55 - dx * a * 0.45);
            }
            g.strokeStyle = 'rgba(235,240,255,0.55)';
            g.lineWidth = Math.max(0.7, s * 0.07);
            g.stroke();
          }
          if (showNum && s >= 16) {
            for (let i = 0; i < C * R; i++) {
              if (wall[i] || !isFinite(dist[i]!) || dist[i]! > waveR) continue;
              txt(g, String(Math.round(dist[i]!)), ox + ((i % C) + 0.5) * s, oy + (((i / C) | 0) + 0.72) * s, s * 0.28, 'rgba(255,255,255,0.8)', 'center', 700);
            }
          }
          // 유닛 움직이기
          head.fill(-1);
          for (let k = 0; k < alive; k++) {
            const ci = clamp(py[k]! | 0, 0, R - 1) * C + clamp(px[k]! | 0, 0, C - 1);
            next[k] = head[ci]!;
            head[ci] = k;
          }
          const SP = 3.4;
          for (let k = 0; k < alive; k++) {
            const x = px[k]!;
            const y = py[k]!;
            const ci = clamp(y | 0, 0, R - 1) * C + clamp(x | 0, 0, C - 1);
            let wx = dir[ci * 2]! * SP;
            let wy = dir[ci * 2 + 1]! * SP;
            if (ci === goal || dist[ci]! < 1.2) {
              const gx = (goal % C) + 0.5 - x;
              const gy = ((goal / C) | 0) + 0.5 - y;
              wx = gx * 1.5;
              wy = gy * 1.5;
            }
            // 서로 밀기
            let sx = 0;
            let sy = 0;
            const cx = x | 0;
            const cy = y | 0;
            for (let yy = cy - 1; yy <= cy + 1; yy++)
              for (let xx = cx - 1; xx <= cx + 1; xx++) {
                if (xx < 0 || yy < 0 || xx >= C || yy >= R) continue;
                for (let o = head[yy * C + xx]!; o >= 0; o = next[o]!) {
                  if (o === k) continue;
                  const ddx = x - px[o]!;
                  const ddy = y - py[o]!;
                  const d2 = ddx * ddx + ddy * ddy;
                  if (d2 < 0.16 && d2 > 1e-6) {
                    const d = Math.sqrt(d2);
                    sx += (ddx / d) * (0.4 - d);
                    sy += (ddy / d) * (0.4 - d);
                  }
                }
              }
            wx += sx * 14;
            wy += sy * 14;
            vx[k] = lerp(vx[k]!, wx, clamp01(dt * 7));
            vy[k] = lerp(vy[k]!, wy, clamp01(dt * 7));
            const nx = x + vx[k]! * dt;
            if (free(nx, y)) px[k] = nx;
            else vx[k] = vx[k]! * -0.2;
            const ny = py[k]! + vy[k]! * dt;
            if (free(px[k]!, ny)) py[k] = ny;
            else vy[k] = vy[k]! * -0.2;
          }
          // 유닛 그리기 (색마다 한 번에)
          const rad = Math.max(1.1, s * 0.2);
          g.fillStyle = 'rgba(0,0,0,0.35)';
          g.beginPath();
          for (let k = 0; k < alive; k++) {
            const X = ox + px[k]! * s;
            const Y = oy + py[k]! * s + rad * 0.5;
            g.moveTo(X + rad, Y);
            g.arc(X, Y, rad, 0, TAU);
          }
          g.fill();
          for (let c = 0; c < UCOL.length; c++) {
            g.fillStyle = UCOL[c]!;
            g.beginPath();
            for (let k = c; k < alive; k += UCOL.length) {
              const X = ox + px[k]! * s;
              const Y = oy + py[k]! * s;
              g.moveTo(X + rad, Y);
              g.arc(X, Y, rad, 0, TAU);
            }
            g.fill();
          }
          if (rad > 2.2) {
            g.fillStyle = '#1a1a2a';
            g.beginPath();
            for (let k = 0; k < alive; k++) {
              const sp = Math.hypot(vx[k]!, vy[k]!) || 1;
              const X = ox + px[k]! * s + (vx[k]! / sp) * rad * 0.35;
              const Y = oy + py[k]! * s + (vy[k]! / sp) * rad * 0.35;
              g.moveTo(X + rad * 0.22, Y);
              g.arc(X, Y, rad * 0.22, 0, TAU);
            }
            g.fill();
          }
          const gp = cellC(fit, C, goal);
          drawPin(g, gp[0], gp[1] + s * 0.4, s * 1.9, '#ff4f6b', t);
          txt(g, '흐름장', 8 * u, headH / 2 + 1 * u, 11 * u, '#ffd166', 'left', 900);
          pill(g, `유닛 ${alive}마리 · 지도 1장`, w - 7 * u, headH / 2 + 1 * u, 7.5 * u, 'rgba(255,255,255,0.12)', '#e8ecff', 'right');
          vignette(g, w, h, 0.3);
        },
        controls: [
          { type: 'range', label: '유닛 수', min: 50, max: 800, step: 50, value: 300, on: (v) => (count = v) },
          { type: 'toggle', label: '화살표 보기', value: true, on: (v) => (showArrows = v) },
          { type: 'toggle', label: '거리 숫자 (크게 볼 때)', value: false, on: (v) => (showNum = v) },
          { type: 'button', label: '새 목표', on: () => retarget() },
          { type: 'button', label: '새 지도', on: () => (mapSeed++, buildMap(), retarget(), spawn(count)) },
        ] as Control[],
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [A* 길찾기 (열린 · 닫힌 칸 보기)](https://ai-techstudio.web.app/ai/t/i264.md) `i264` · [다익스트라 비용 지도 (지형 비용)](https://ai-techstudio.web.app/ai/t/i266.md) `i266`
- 다음에 해 볼 기술: [내비 메시 (다각형 길찾기)](https://ai-techstudio.web.app/ai/t/i267.md) `i267`
- 참고 문서: [Red Blob Games — Flow Field Pathfinding (tower defense)](https://www.redblobgames.com/pathfinding/tower-defense/)
