# AI 꾸러미 — 평가 함수 설계 (가중치) — Evaluation function (weighted features)
> 판 점수를 「가운데 × w₁ + 내 3줄 × w₂ − 상대 3줄 × w₃」 처럼 특징 × 가중치의 합으로 매겨, 가중치만 바꿔도 AI 성격이 달라지게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i346

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

## 주문서

### 만들어 줘: 평가 함수 설계 (가중치) — Evaluation function (weighted features)

#### 1. 목표
커넥트4의 판 평가 함수를 특징 × 가중치 합으로 만들어 줘 — 가중치 슬라이더로 AI 성격이 바뀌게. 분위기는 칸마다 점수 막대가 오르내리는 설명.

#### 2. 핵심 기술 용어
- **Evaluation function (weighted features)** — 판의 좋음 = 특징 값 × 가중치의 합
- **Feature: center control · threats** — 가운데 차지 · 4칸 창 안의 내 3개 + 빈칸 1
- **Sliding windows (4 in a row)** — 가로 · 세로 · 두 대각선 4칸 창 모두 훑기

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 4칸 창 목록은 처음에 한 번만 만든다 (가로 · 세로 · 두 대각선)
- 점수는 늘 AI 기준 하나로 (상대 특징은 빼기)
- 가중치 3개 슬라이더 + 칸마다 점수 막대 + 고른 칸 금빛
- 고른 까닭(가운데 차지 · 내 3줄 만들기 · 상대 4줄 막기)을 한 줄로 보여 주기

#### 5. 완성 기준 (이게 보이면 성공)
- 가중치를 바꾸면 칸마다 점수 막대가 오르내리고 고르는 칸(금빛)이 바뀐다
- w₃ 를 키우면 상대 3줄을 막는 칸을, w₁ 을 키우면 가운데를 고른다
- 같은 판에서 결과가 늘 같다 (무작위 없음)

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

## 원리
- 판을 숫자 몇 개(특징)로 요약한다: 가운데 줄 점수(열마다 0 · 1 · 2 · 3 · 2 · 1 · 0), 내 3줄 수, 상대 3줄 수.
- 「3줄」 = 가로 · 세로 · 두 대각선 4칸 창 가운데 한쪽 말 3개 + 빈칸 1개인 창의 수.
- 수 하나를 둬 본 판마다 점수 = 가운데 × w₁ + 내 3줄 × 3 × w₂ − 상대 3줄 × 3 × w₃ 을 매기고 가장 큰 칸을 고른다.
- 가중치가 성격이다: w₁ 크면 가운데 욕심, w₂ 크면 공격, w₃ 크면 막기. 견본은 (1, 0.15, 0.1) → (0.25, 1, 0.1) → (0.2, 0.3, 1) 을 오가며 고르는 칸이 바뀐다.

## 핵심 코드 — 커넥트4 평가 — 4칸 창 훑기 + 가중치 합
(발췌: demos/demosGameAI.ts I346 의 windows · count3 · scores 를 정리)
```ts
// B[c][r]: 7열 × 6줄 (r = 0 아래), 0 빈칸 · 1 나 · 2 상대
const colW = [0, 1, 2, 3, 2, 1, 0];                    // 가운데일수록 좋다
const windows: [number, number][][] = [];
for (let c = 0; c < 7; c++) for (let r = 0; r < 6; r++)
  for (const [dc, dr] of [[1, 0], [0, 1], [1, 1], [1, -1]] as [number, number][]) {
    const w: [number, number][] = [];
    for (let k = 0; k < 4; k++) w.push([c + dc * k, r + dr * k]);
    if (w.every(([a, b]) => a >= 0 && a < 7 && b >= 0 && b < 6)) windows.push(w);
  }

/** 한쪽 말 3개 + 빈칸 1개인 4칸 창의 수 */
function count3(B: number[][], who: number): number {
  let n = 0;
  for (const w of windows) {
    let m = 0, e = 0;
    for (const [a, b] of w) { const v = B[a]![b]!; if (v === who) m++; else if (v === 0) e++; }
    if (m === 3 && e === 1) n++;
  }
  return n;
}

/** 열 c 에 둬 본 판의 점수 (w = [가운데, 내 3줄, 상대 3줄]) */
function scoreMove(B: number[][], c: number, w: [number, number, number]): number {
  const row = B[c]!.indexOf(0);
  if (row < 0) return -Infinity;                       // 꽉 찬 열
  const nb = B.map((col) => col.slice());
  nb[c]![row] = 1;
  return colW[c]! * w[0] + count3(nb, 1) * 3 * w[1] - count3(nb, 2) * 3 * w[2];
}
```

## 흔한 실수 · 확인 목록
- [ ] **특징 값의 크기가 제각각이면 가중치가 뜻대로 안 먹힌다** — 특징을 비슷한 크기로 맞춘다 — 견본은 3줄 수에 3 을 곱해 가운데(0~3)와 맞췄다.
- [ ] **내 3줄만 세면 상대 4줄을 못 막는다** — 상대 위협을 빼는 항을 꼭 넣는다 (w₃).
- [ ] **평가 함수만으로 두면 한 수 앞만 본다** — 알파베타(i341)의 잎에서 이 점수를 쓰면 몇 수 앞까지 같은 성격으로 본다.

## 완성 기준 체크리스트
- [ ] 가중치를 바꾸면 칸마다 점수 막대가 오르내리고 고르는 칸(금빛)이 바뀐다
- [ ] w₃ 를 키우면 상대 3줄을 막는 칸을, w₁ 을 키우면 가운데를 고른다
- [ ] 같은 판에서 결과가 늘 같다 (무작위 없음)

## 이 기술 정보
- id: `i346` · 분류: 게임 시스템 · AI › 게임 AI · 공통 · 난이도 쉬움 · 폰 부담 가벼움 (폰 OK) — 커넥트4 4칸 창 69개 × 4칸 훑기 = 수백 번. 탐색 잎마다 부르니 탐색 비용의 대부분 — 미리 창 목록을 만들어 둔다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i346
- 쓰면 좋을 때: 끝까지 다 볼 수 없는 게임에서 깊이 끝 판을 점수로 바꿀 때 (알파베타 · 정지 탐색과 함께) / 난이도 · 성격이 다른 AI 를 여럿 만들 때
- 쓰지 말 때: 끝까지 볼 수 있는 작은 게임 — 승 · 무 · 패면 충분(i340) / 좋은 특징을 모를 때 — 평가 없이 강해지는 몬테카를로 트리 탐색(i347)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### I346 — `src/demos/demosGameAI.ts:1439`
```ts
const I346: DemoMap = {
  i346: {
    kind: '2d',
    caption: '점수 = 가운데 × w₁ + 내 3줄 × w₂ − 상대 3줄 × w₃ — 가중치만 바꿔도 AI 가 고르는 칸(금빛)이 달라져요',
    make() {
      // 7 × 6 (r = 0 아래). 1 빨강(나) · 2 노랑
      const B: number[][] = Array.from({ length: 7 }, () => Array<number>(6).fill(0));
      for (const [c, r] of [[2, 0], [4, 0], [5, 0], [4, 1]] as [number, number][]) B[c]![r] = 1;
      for (const [c, r] of [[3, 0], [6, 0], [6, 1], [6, 2]] as [number, number][]) B[c]![r] = 2;
      const colW = [0, 1, 2, 3, 2, 1, 0];
      const windows: [number, number][][] = [];
      for (let c = 0; c < 7; c++)
        for (let r = 0; r < 6; r++)
          for (const [dc, dr] of [[1, 0], [0, 1], [1, 1], [1, -1]] as [number, number][]) {
            const wv: [number, number][] = [];
            for (let k = 0; k < 4; k++) wv.push([c + dc * k, r + dr * k]);
            if (wv.every(([a, b]) => a >= 0 && a < 7 && b >= 0 && b < 6)) windows.push(wv);
          }
      const count3 = (bb: number[][], who: number): number => {
        let n = 0;
        for (const wv of windows) {
          let m = 0;
          let e = 0;
          for (const [a, b] of wv) {
            const v = bb[a]![b]!;
            if (v === who) m++;
            else if (v === 0) e++;
          }
          if (m === 3 && e === 1) n++;
        }
        return n;
      };
      const feats: ({ center: number; own: number; opp: number; row: number } | null)[] = [];
      for (let c = 0; c < 7; c++) {
        const row = B[c]!.indexOf(0);
        if (row < 0) {
          feats.push(null);
          continue;
        }
        const nb = B.map((col) => col.slice());
        nb[c]![row] = 1;
        feats.push({ center: colW[c]!, own: count3(nb, 1), opp: count3(nb, 2), row });
      }
      const presets: [number, number, number][] = [
        [1, 0.15, 0.1],
        [0.25, 1, 0.1],
        [0.2, 0.3, 1],
      ];
      let auto = true;
      let pi = 0;
      let pt = 0;
      const wv: [number, number, number] = [1, 0.15, 0.1];
      let from: [number, number, number] = [...wv];
      let lastBest = -1;
      let dropT = 0;
      const st = { speed: 1 };
      const names = ['가운데', '내 3줄', '상대 3줄'];
      const cols = [C.violet, C.x, C.gold];
      return {
        draw(g, w, h, t, dt) {
          reset(g);
          const d = Math.min(dt, 0.1) * st.speed;
          if (auto) {
            pt += d;
            if (pt > 3.2) {
              pt = 0;
              from = [...wv];
              pi = (pi + 1) % presets.length;
            }
            const k = ease(pt / 0.9);
            const tg = presets[pi]!;
            for (let i = 0; i < 3; i++) wv[i] = lerp(from[i]!, tg[i]!, k);
          }
          const u = scaleOf(w, h);
          bg(g, w, h);
          header(g, w, u, '평가 함수', C.text);
          const scores = feats.map((f) => (f ? f.center * wv[0] + f.own * 3 * wv[1] - f.opp * 3 * wv[2] : -Infinity));
          let best = 0;
          scores.forEach((s, i) => {
            if (s > scores[best]!) best = i;
          });
          if (best !== lastBest) {
            lastBest = best;
            dropT = 0;
          }
          dropT += d;
          // 판
          const bwid = Math.min(w * 0.52, (h - 86 * u) * (7 / 6));
          const cs = bwid / 7;
          const bx = 8 * u;
          const by = h - cs * 6 - 8 * u;
          // 점수 막대 (판 위)
          const barTop = 24 * u;
          const barMid = barTop + (by - barTop - 6 * u) * 0.5;
          const maxAbs = Math.max(1, ...scores.filter((s) => Number.isFinite(s)).map((s) => Math.abs(s)));
          const up = barMid - barTop - 8 * u;
          const down = by - 11 * u - barMid;
          for (let c = 0; c < 7; c++) {
            const s = scores[c]!;
            if (!Number.isFinite(s)) continue;
            const x = bx + c * cs + cs * 0.2;
            const hgt = (s / maxAbs) * (s >= 0 ? up : down);
            g.fillStyle = c === best ? C.gold : s >= 0 ? 'rgba(111,227,160,0.75)' : 'rgba(255,93,108,0.7)';
            rr(g, x, s >= 0 ? barMid - hgt : barMid, cs * 0.6, Math.abs(hgt), 1.5 * u);
            g.fill();
            txt(g, s.toFixed(1), x + cs * 0.3, s >= 0 ? barMid - hgt - 4.5 * u : barMid + Math.abs(hgt) + 4 * u, 5.8 * u, c === best ? C.gold : C.sub, 'center', 800);
          }
          g.strokeStyle = 'rgba(255,255,255,0.3)';
          g.lineWidth = 0.8 * u;
          g.beginPath();
          g.moveTo(bx, barMid);
          g.lineTo(bx + bwid, barMid);
          g.stroke();
          // 판 몸
          rr(g, bx - 2 * u, by - 2 * u, bwid + 4 * u, cs * 6 + 4 * u, 4 * u);
          g.fillStyle = '#2346b8';
          g.fill();
          const disc = (x: number, y: number, v: number, a = 1): void => {
            g.globalAlpha = a;
            const gr = g.createRadialGradient(x - cs * 0.12, y - cs * 0.14, cs * 0.05, x, y, cs * 0.4);
            if (v === 1) {
              gr.addColorStop(0, '#ff8a9c');
              gr.addColorStop(1, '#d62845');
            } else {
              gr.addColorStop(0, '#ffe68a');
              gr.addColorStop(1, '#e0a812');
            }
            g.fillStyle = gr;
            g.beginPath();
            g.arc(x, y, cs * 0.38, 0, TAU);
            g.fill();
            g.globalAlpha = 1;
          };
          for (let c = 0; c < 7; c++)
            for (let r = 0; r < 6; r++) {
              const x = bx + c * cs + cs / 2;
              const y = by + (5 - r) * cs + cs / 2;
              const v = B[c]![r]!;
              if (v) disc(x, y, v);
              else {
                g.fillStyle = '#0d1430';
                g.beginPath();
                g.arc(x, y, cs * 0.38, 0, TAU);
                g.fill();
              }
            }
          // 떨어지는 말
          const f = feats[best];
          if (f) {
            const x = bx + best * cs + cs / 2;
            const yEnd = by + (5 - f.row) * cs + cs / 2;
            const k = clamp(dropT / 0.45, 0, 1);
            const y = lerp(by - cs * 0.6, yEnd, k * k);
            glow(g, x, yEnd, cs * 0.9, C.gold, 0.5 + 0.2 * Math.sin(t * 6));
            disc(x, y, 1, 0.92);
          }
          // 오른쪽: 가중치 · 특징
          const rx = bx + bwid + 14 * u;
          const rw = w - rx - 8 * u;
          txt(g, '가중치', rx, 26 * u, 7.5 * u, C.sub, 'left', 800);
          for (let i = 0; i < 3; i++) {
            const y = 38 * u + i * 20 * u;
            txt(g, names[i]!, rx, y, 7.5 * u, cols[i]!, 'left', 800);
            txt(g, `w${'₁₂₃'[i]} ${wv[i]!.toFixed(2)}`, rx + rw, y, 7 * u, C.text, 'right', 700);
            rr(g, rx, y + 5 * u, rw, 4 * u, 2 * u);
            g.fillStyle = 'rgba(255,255,255,0.08)';
            g.fill();
            rr(g, rx, y + 5 * u, rw * wv[i]!, 4 * u, 2 * u);
            g.fillStyle = cols[i]!;
            g.fill();
            g.fillStyle = '#fff';
            g.beginPath();
            g.arc(rx + rw * wv[i]!, y + 7 * u, 3 * u, 0, TAU);
            g.fill();
          }
          if (f) {
            const y = 38 * u + 3 * 20 * u;
            txt(g, `고른 칸 ${best + 1}번 줄`, rx, y, 7.5 * u, C.gold, 'left', 900);
            txt(g, `가운데 ${f.center} · 내 3줄 ${f.own} · 상대 3줄 ${f.opp}`, rx, y + 11 * u, 6.2 * u, C.sub, 'left', 700);
            const why = f.opp === 0 && feats.some((q) => q && q.opp > 0) && wv[2] >= wv[0] && wv[2] >= wv[1] ? '상대 4줄 막기' : f.own > 0 && wv[1] >= wv[0] ? '내 3줄 만들기' : '가운데 차지';
            pill(g, why, rx, y + 23 * u, 6.8 * u, 'rgba(255,209,102,0.92)', '#1a1405', 'left');
          }
        },
        controls: [
          { type: 'toggle', label: '가중치 자동으로 바꾸기', value: true, on: (v) => { auto = v; } },
          { type: 'range', label: 'w₁ 가운데', min: 0, max: 1, step: 0.05, value: 1, on: (v) => { auto = false; wv[0] = v; } },
          { type: 'range', label: 'w₂ 내 3줄', min: 0, max: 1, step: 0.05, value: 0.15, on: (v) => { auto = false; wv[1] = v; } },
          { type: 'range', label: 'w₃ 상대 3줄 막기', min: 0, max: 1, step: 0.05, value: 0.1, on: (v) => { auto = false; wv[2] = v; } },
        ] as Control[],
      };
    },
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [미니맥스 게임 나무](https://ai-techstudio.web.app/ai/t/i340.md) `i340`
- 다음에 해 볼 기술: [알파베타 가지치기](https://ai-techstudio.web.app/ai/t/i341.md) `i341` · [정지 탐색 (수평선 효과 막기)](https://ai-techstudio.web.app/ai/t/i345.md) `i345` · [난이도 = 사람 같은 실수 (온도 소프트맥스)](https://ai-techstudio.web.app/ai/t/i358.md) `i358`
- 참고 문서: [Chessprogramming wiki — Evaluation](https://www.chessprogramming.org/Evaluation)
