# AI 꾸러미 — 무리 짓기 (boids) — Boids (flocking)
> 물고기 한 마리마다 「너무 가까우면 떨어지기 · 이웃과 같은 방향 · 이웃 가운데로」 세 규칙만 주면, 아무도 지휘하지 않는데 떼가 생긴다.  
> 견본: https://ai-techstudio.web.app/#t/i50

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

## 주문서

### 만들어 줘: 무리 짓기 (boids) — Boids (flocking)

#### 1. 목표
물고기 떼 바닷속에 무리 짓기(boids)를 넣어 줘 — 한 마리마다 분리 · 정렬 · 결집 세 규칙만으로 떼가 생기고, 규칙을 끄면 흩어지게. 분위기는 밝은 열대 바다.

#### 2. 핵심 기술 용어
- **Boids (flocking)** — 무리 짓기 — 레이놀즈의 새 떼
- **Separation · Alignment · Cohesion** — 분리 · 정렬 · 결집 세 규칙
- **Emergent behavior** — 단순한 규칙이 모여 생기는 복잡한 움직임
- **Toroidal wrap-around** — 화면 끝이 반대편과 이어진 세계

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

#### 4. 조건
- 물고기는 이웃만 본다 (보는 거리 제한) — 전체 평균을 쓰면 떼가 하나로 뭉치기만 한다
- 속도 크기를 최소 · 최대 사이로 묶는다 (멈추거나 폭주하지 않게)
- 끝이 이어진 세계면 거리도 짧은 쪽으로 잰다
- 규칙 켬/끔 표시를 화면에 보여 차이를 확인할 수 있게

#### 5. 완성 기준 (이게 보이면 성공)
- 물고기들이 몇 무리로 모여 같은 방향으로 헤엄친다
- 정렬 · 결집을 끄면 흩어지고, 켜면 다시 모인다 (14초마다 저절로)
- 포인터를 가까이 대면 물고기가 피해 간다
- 머리 방향이 헤엄치는 방향과 같고 꼬리가 흔들린다

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

## 원리
- 한 마리마다 가까운 이웃(거리² < 0.012)만 본다 — 떼 전체를 아는 물고기는 없다.
- 분리: 아주 가까운(거리² < 0.0012) 이웃에게서 거리²에 반비례해 멀어진다.
- 정렬: 이웃 평균 속도 쪽으로 6% 맞춘다. 결집: 이웃의 평균 위치 쪽으로 5% 다가간다.
- 작은 흔들림을 더하고 속도를 0.12 ~ 0.28 로 묶는다. 화면 끝은 반대편과 이어져 있어 거리도 짧은 쪽으로 잰다.
- 정렬 · 결집을 끄면 금방 흩어지고, 켜면 다시 떼가 된다 — 규칙의 효과가 눈에 보인다.

## 핵심 코드 — 분리 · 정렬 · 결집 (한 마리 갱신)
(발췌: demos/demosSim.ts i50 draw() 의 갱신 부분을 정리)
```ts
interface Fish { x: number; y: number; vx: number; vy: number }
const clamp = (v: number, a: number, b: number) => (v < a ? a : v > b ? b : v);

function steer(f: Fish, fish: Fish[], A: number, rules = { sep: true, ali: true, coh: true }) {
  let sx = 0, sy = 0, ax = 0, ay = 0, cx = 0, cy = 0, n = 0;
  for (const o of fish) {
    if (o === f) continue;
    let dx = o.x - f.x, dy = o.y - f.y;
    if (dx > A / 2) dx -= A; if (dx < -A / 2) dx += A;     // 끝이 이어진 세계 — 짧은 쪽 거리
    if (dy > 0.5) dy -= 1; if (dy < -0.5) dy += 1;
    const d2 = dx * dx + dy * dy;
    if (d2 < 0.012) {                                     // 보이는 이웃만
      n++; ax += o.vx; ay += o.vy; cx += dx; cy += dy;
      if (d2 < 0.0012) { sx -= dx / (d2 + 1e-4); sy -= dy / (d2 + 1e-4); }
    }
  }
  if (rules.sep) { f.vx += sx * 0.00025; f.vy += sy * 0.00025; }        // 분리
  if (n) {
    if (rules.ali) { f.vx += (ax / n - f.vx) * 0.06; f.vy += (ay / n - f.vy) * 0.06; }  // 정렬
    if (rules.coh) { f.vx += (cx / n) * 0.05; f.vy += (cy / n) * 0.05; }                // 결집
  }
  f.vx += (Math.random() - 0.5) * 0.01;
  f.vy += (Math.random() - 0.5) * 0.01;
  const sp = Math.hypot(f.vx, f.vy);
  const want = clamp(sp, 0.12, 0.28);                    // 너무 느리거나 빠르지 않게
  f.vx = (f.vx / (sp || 1)) * want;
  f.vy = (f.vy / (sp || 1)) * want;
}
// 모두 steer 한 뒤에 위치를 옮긴다: f.x = (f.x + f.vx * dt + A) % A
```

## 흔한 실수 · 확인 목록
- [ ] **한 마리씩 갱신하며 바로 옮기면 앞 물고기의 새 위치가 뒤 계산에 섞인다** — 모두 방향을 고친 뒤에 위치를 한꺼번에 옮긴다.
- [ ] **이웃 거리 제한 없이 전체를 보면 떼가 하나로만 뭉친다** — 보는 거리를 짧게 둬야 여러 무리가 생기고 갈라지고 합쳐진다.
- [ ] **속도를 묶지 않으면 결집 때문에 한 점에 멈춰 버린다** — 속도 크기를 최소 · 최대 사이로 정규화한다.
- [ ] **화면 끝에서 이어지는 세계인데 거리를 그냥 재면 끝에서 떼가 찢어진다** — 차이가 절반을 넘으면 세계 폭만큼 빼서 짧은 쪽으로 잰다.

## 완성 기준 체크리스트
- [ ] 물고기들이 몇 무리로 모여 같은 방향으로 헤엄친다
- [ ] 정렬 · 결집을 끄면 흩어지고, 켜면 다시 모인다 (14초마다 저절로)
- [ ] 포인터를 가까이 대면 물고기가 피해 간다
- [ ] 머리 방향이 헤엄치는 방향과 같고 꼬리가 흔들린다

## 이 기술 정보
- id: `i50` · 분류: 물리 · 수학 원리 › 물리 · 시뮬레이션 · 공통 · 난이도 보통 · 폰 부담 보통 (폰 주의) — 견본은 110마리 모두 비교(1만 2천 쌍)라 가볍다. 500마리를 넘기면 격자 이웃 찾기가 필요하다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i50
- 쓰면 좋을 때: 살아 있는 배경 (물고기 · 새 · 반딧불) / 「단순한 규칙 → 복잡한 모양」 · 벡터 더하기를 보여 줄 때
- 쓰지 말 때: 수천 마리를 CPU 에서 모두 비교 — 마리² 계산이라 느리다. 격자로 이웃을 찾거나 GPU 로 / 정해진 길을 가야 하는 캐릭터 — 길찾기가 맞다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i50 견본 항목 — `src/demos/demosSim.ts:4094`
```ts
  i50: {
    kind: '2d',
    caption: '물고기 한 마리마다 규칙 셋(분리 · 정렬 · 결집)만 — 그런데 떼가 생겨요. 몇 초마다 정렬 · 결집을 껐다 켜요',
    make() {
      const N = 110;
      const r = rng(8);
      const fish = Array.from({ length: N }, (_, i) => ({ x: r() * 1.6, y: r(), vx: (r() - 0.5) * 0.2, vy: (r() - 0.5) * 0.2, c: i % 7 === 0 ? '#ff6a8a' : i % 3 ? '#ffb02a' : '#ffd84a' }));
      const rules = { sep: true, ali: true, coh: true };
      let auto = true;
      const ptr = pointerOf();
      const bubbles = Array.from({ length: 14 }, () => ({ x: r(), y: r(), s: 0.5 + r() }));
      return {
        draw(g, w, h, t, dt) {
          ptr.attach(g.canvas as HTMLCanvasElement);
          dt = Math.min(dt, 1 / 30);
          const A = w / h;
          if (auto) {
            const k = t % 14;
            const off = k > 9;
            rules.sep = true;
            rules.ali = !off;
            rules.coh = !off;
          }
          for (const f of fish) {
            let sx = 0;
            let sy = 0;
            let ax = 0;
            let ay = 0;
            let cx = 0;
            let cy = 0;
            let n = 0;
            for (const o of fish) {
              if (o === f) continue;
              let dx = o.x - f.x;
              let dy = o.y - f.y;
              if (dx > A / 2) dx -= A;
              if (dx < -A / 2) dx += A;
              if (dy > 0.5) dy -= 1;
              if (dy < -0.5) dy += 1;
              const d2 = dx * dx + dy * dy;
              if (d2 < 0.012) {
                n++;
                ax += o.vx;
                ay += o.vy;
                cx += dx;
                cy += dy;
                if (d2 < 0.0012) {
                  sx -= dx / (d2 + 1e-4);
                  sy -= dy / (d2 + 1e-4);
                }
              }
            }
            if (rules.sep) {
              f.vx += sx * 0.00025;
              f.vy += sy * 0.00025;
            }
            if (n) {
              if (rules.ali) {
                f.vx += (ax / n - f.vx) * 0.06;
                f.vy += (ay / n - f.vy) * 0.06;
              }
              if (rules.coh) {
                f.vx += (cx / n) * 0.05;
                f.vy += (cy / n) * 0.05;
              }
            }
            if (ptr.inside) {
              const dx = f.x - ptr.x / h;
              const dy = f.y - ptr.y / h;
              const d2 = dx * dx + dy * dy;
              if (d2 < 0.04) {
                f.vx += (dx / (d2 + 0.002)) * 0.004;
                f.vy += (dy / (d2 + 0.002)) * 0.004;
              }
            }
            f.vx += (r() - 0.5) * 0.01;
            f.vy += (r() - 0.5) * 0.01;
            const sp = Math.hypot(f.vx, f.vy);
            const want = clamp(sp, 0.12, 0.28);
            f.vx = (f.vx / (sp || 1)) * want;
            f.vy = (f.vy / (sp || 1)) * want;
          }
          for (const f of fish) {
            f.x = (f.x + f.vx * dt + A) % A;
            f.y = (f.y + f.vy * dt + 1) % 1;
          }
          // 그리기
          bg(g, w, h, '#2ab0d8', '#0a3a7a');
          g.fillStyle = 'rgba(255,255,255,0.06)';
          for (let k = 0; k < 4; k++) {
            const x0 = ((k * 0.3 + t * 0.02) % 1.3) * w;
            g.beginPath();
            g.moveTo(x0, 0);
            g.lineTo(x0 + w * 0.08, 0);
            g.lineTo(x0 - w * 0.12, h);
            g.lineTo(x0 - w * 0.22, h);
            g.fill();
          }
          g.strokeStyle = 'rgba(255,255,255,0.35)';
          for (const b of bubbles) {
            const y = (((b.y - t * 0.05 * b.s) % 1) + 1) % 1;
            g.beginPath();
            g.arc(b.x * w + Math.sin(t + b.x * 9) * 4, y * h, 2 + b.s * 2, 0, TAU);
            g.stroke();
          }
          const L = Math.max(5, h * 0.028);
          for (const f of fish) {
            const a = Math.atan2(f.vy, f.vx);
            g.save();
            g.translate(f.x * h, f.y * h);
            g.rotate(a);
            const wag = Math.sin(t * 12 + f.x * 30) * 0.35;
            g.fillStyle = f.c;
            g.beginPath();
            g.moveTo(-L * 0.8, 0);
            g.lineTo(-L * 1.5, -L * 0.5 + wag * L * 0.3);
            g.lineTo(-L * 1.5, L * 0.5 + wag * L * 0.3);
            g.fill();
            g.beginPath();
            g.ellipse(0, 0, L, L * 0.48, 0, 0, TAU);
            g.fill();
            g.fillStyle = '#10203a';
            g.beginPath();
            g.arc(L * 0.5, -L * 0.12, L * 0.12, 0, TAU);
            g.fill();
            g.restore();
          }
          const fs = clamp(h * 0.05, 9, 18);
          let x = 8;
          for (const [k, name] of [
            ['sep', '분리'],
            ['ali', '정렬'],
            ['coh', '결집'],
          ] as const) {
            const on = rules[k];
            g.font = `800 ${fs}px ${FONT}`;
            const tw = g.measureText(`${name} ${on ? '켬' : '끔'}`).width + fs * 1.1;
            pill(g, `${name} ${on ? '켬' : '끔'}`, x, h - fs * 1.3, fs, on ? 'rgba(255,210,60,0.95)' : 'rgba(0,0,0,0.45)', on ? '#3a2400' : '#cfe0ff');
            x += tw + 6;
          }
        },
        controls: [
          {
            type: 'toggle',
            label: '분리 (너무 붙지 않기)',
            value: true,
            on: (v) => {
              auto = false;
              rules.sep = v;
            },
          },
          {
            type: 'toggle',
            label: '정렬 (같은 쪽 보기)',
            value: true,
            on: (v) => {
              auto = false;
              rules.ali = v;
            },
          },
          {
            type: 'toggle',
            label: '결집 (가운데로 모이기)',
            value: true,
            on: (v) => {
              auto = false;
              rules.coh = v;
            },
          },
        ],
        dispose() {
          ptr.dispose();
        },
      };
    },
  }
```

## 관련 기술
- 먼저 알면 좋은 기술: [3D 강체 물리 (충돌 · 마찰 · 쌓기 — 직접 만든 엔진)](https://ai-techstudio.web.app/ai/t/i24.md) `i24`
- 다음에 해 볼 기술: [SPH · 입자 물](https://ai-techstudio.web.app/ai/t/i47.md) `i47`
- 참고 문서: [Craig Reynolds — Boids](https://www.red3d.com/cwr/boids/) · [three.js 예제 — gpgpu birds](https://threejs.org/examples/#webgl_gpgpu_birds)
