사용자가 흑돌(사람) vs 백돌(컴퓨터) 대전을 요청하고 있다. 현재는 두 플레이어 모두 사람이 두는 방식이니, 백돌은 컴퓨터가 자동으로 두도록 수정해야 한다.
먼저 현재 `OmokGame.jsx` 코드를 확인한 다음, 컴퓨터 AI 로직을 추가해야 한다.
컴퓨터 AI의 수준에 대해 사용자가 특별히 지정하지 않았다. 간단하지만 적당히 합리적인 AI를 만들어야 한다. 기본적인 수준으로는:
1. 랜덤 배치 (너무 약함)
2. 간단한 휴리스틱 (방어 + 공격)
3. 미니맥스 등
적당히 재미있는 수준으로, 기본적인 공격/방어 휴리스틱을 구현하는 것이 좋겠다. 컴퓨터는:
- 자신(백)의 5목을 만들 수 있으면 즉시 둔다 (승리)
- 상대(흑)의 5목을 막을 수 있으면 즉시 막는다 (방어)
- 그 외에는 간단한 평가 함수로 최적의 위치를 선택
이 정도면 적당한 난이도가 될 것이다.
먼저 현재 코드를 읽어보자.
먼저 현재 게임 코드를 확인하고, 컴퓨터 AI를 추가하겠습니다.
✅파일 읽기web/src/OmokGame.jsx
이제 코드를 이해했다. 컴퓨터 AI를 구현해야 한다. 계획:
1. 컴퓨터 AI 함수 구현 - 기본적인 평가 기반 수 찾기
2. 사용자(흑)가 둔 후 자동으로 컴퓨터(백)가 두도록 변경
3. 컴퓨터가 두는 동안 사용자 클릭 방지
4. 테스트 (read_logs)
AI 전략:
- 각 빈 칸에 대해 점수를 매겨서 가장 높은 점수의 위치에 둔다
- 백돌(컴퓨터)이 5개를 만들 수 있으면 최우선
- 흑돌(사용자)이 5개를 만들려는 것을 막는 것이 그 다음
- 그 외에는 연속된 돌의 개수에 따라 점수 부여
좀 더 구체적으로:
- 각 빈 칸에 대해, 4방향으로 백돌과 흑돌의 연속 개수를 센다
- 백돌 관점: 4개 연속이면 즉시 승리 (가장 높은 점수)
- 흑돌 관점: 4개 연속이면 방어 (두 번째로 높은 점수)
- 그 외: 3개, 2개 등에 가중치 부여
이런 간단한 휴리스틱으로 구현하자.
또한 컴퓨터가 둘 때 약간의 지연(0.3초 정도)을 주어 자연스럽게 보이도록 하자.
useEffect를 사용해서 턴이 WHITE일 때 자동으로 컴퓨터가 두도록 구현하겠다.
계획을 세우자: 3단계 - AI 함수 구현 → useEffect로 자동 플레이 연결 → 로그 확인
3단계 계획을 세웁니다.
✅계획3단계 (완료 0)
이제 AI 함수를 포함하여 OmokGame.jsx를 수정하자.
AI 함수:
- getBestMove(board): 모든 빈 칸을 평가하여 최고 점수의 위치를 반환
- evaluatePosition(board, row, col, player): 특정 위치에 특정 플레이어의 돌을 놓았을 때의 점수
점수 체계:
- 5개 완성: 100000
- 열린 4개 (양쪽이 막히지 않음): 10000
- 4개 (한쪽만 막힘): 1000
- 열린 3개: 1000
- 3개: 100
- 열린 2개: 100
- 2개: 10
방향별로 연속된 돌 개수를 세는 함수가 필요하다.
구현을 좀 단순화하자. 각 빈 칸에 대해, 그 칸에 돌을 놓았을 때 4방향으로 연속된 같은 색 돌의 개수를 세고, 그에 따른 점수를 매긴다.
점수 = 공격 점수(백돌 기준) + 방어 점수(흑돌 기준). 방어도 중요하게 처리해야 한다.
또한, useEffect를 사용해 턴이 WHITE이고 승자가 없을 때 setTimeout으로 약간 지연 후 컴퓨터가 두도록 한다. useRef로 AI가 계산 중인지 추적해서 중복 실행을 방지한다.
코드 수정 계획:
1. `countConsecutive` 헬퍼 함수: 특정 방향으로 연속된 돌 개수 세기
2. `evaluateCell` 함수: 한 칸에 대한 점수 계산
3. `getBestMove` 함수: 최적의 수 찾기
4. 컴포넌트 내에서:
- `useEffect`로 백돌 턴 감지 → setTimeout → AI 수 계산 → 배치
- `isThinking` 상태 추가해서 AI 계산 중에는 클릭 불가
- handleClick에서 turn이 BLACK일 때만 클릭 허용 (이미 winner 체크는 있음)
코드를 작성하자. 기존 파일을 edit_file로 수정한다.
부분 수정을 위해:
1. DIRECTIONS 상수 아래에 AI 관련 함수 추가
2. 컴포넌트 함수 내에 useEffect, isThinking 상태 추가
3. handleClick 수정 (BLACK 턴에만)
4. reset 함수 수정
좀 더 구체적으로 수정할 부분을 찾자.
먼저 `const DIRECTIONS = ...` 아래에 AI 함수들을 추가하고, 컴포넌트 내부를 수정한다.
old_string으로 사용할 부분을 정확히 찾아야 한다.
컴포넌트 함수 시작 부분:
```
export default function OmokGame() {
const [board, setBoard] = useState(createBoard);
const [turn, setTurn] = useState(BLACK);
const [winner, setWinner] = useState(null);
```
이 부분을 수정해서 isThinking 상태를 추가.
handleClick도 수정:
```
const handleClick = useCallback((r, c) => {
if (winner || board[r][c] !== EMPTY) return;
const nextBoard = board.map(row => [...row]);
nextBoard[r][c] = turn;
setBoard(nextBoard);
if (checkWin(nextBoard, r, c, turn)) {
setWinner(turn);
} else {
setTurn(turn === BLACK ? WHITE : BLACK);
}
}, [board, turn, winner]);
```
이것을 BLACK 턴에만 동작하도록, 그리고 isThinking 상태도 체크.
또한 useEffect를 추가해야 한다. handleClick과 reset 사이에 추가.
전체적으로 여러 부분을 수정해야 하니, 각 부분을 edit_file로 하나씩 처리하자.
먼저 DIRECTIONS 아래에 AI 함수를 추가하는 부분부터.
기존:
```
const DIRECTIONS = [
[0, 1], // 가로
[1, 0], // 세로
[1, 1], // 대각선 ↘
[1, -1], // 대각선 ↙
];
function checkWin(board, row, col, player) {
```
여기에 AI 함수를 추가할 수 있다. DIRECTIONS와 checkWin 사이에 추가하자.
추가할 내용:
```javascript
// 한 방향으로 연속된 같은 색 돌 개수 세기 (현재 위치 제외)
function countDirection(board, row, col, dr, dc, player) {
let count = 0;
let r = row + dr;
let c = col + dc;
while (
r >= 0 && r < BOARD_SIZE &&
c >= 0 && c < BOARD_SIZE &&
board[r][c] === player
) {
count++;
r += dr;
c += dc;
}
return count;
}
// 특정 위치에 돌을 놓았을 때의 점수 평가
function evaluateCell(board, row, col, player) {
let score = 0;
for (const [dr, dc] of DIRECTIONS) {
const cnt1 = countDirection(board, row, col, dr, dc, player);
const cnt2 = countDirection(board, row, col, -dr, -dc, player);
const total = cnt1 + cnt2;
// 양쪽 끝 상태 확인
const r1 = row + dr * (cnt1 + 1);
const c1 = col + dc * (cnt1 + 1);
const r2 = row - dr * (cnt2 + 1);
const c2 = col - dc * (cnt2 + 1);
const open1 = r1 >= 0 && r1 < BOARD_SIZE && c1 >= 0 && c1 < BOARD_SIZE && board[r1][c1] === EMPTY;
const open2 = r2 >= 0 && r2 < BOARD_SIZE && c2 >= 0 && c2 < BOARD_SIZE && board[r2][c2] === EMPTY;
const openEnds = (open1 ? 1 : 0) + (open2 ? 1 : 0);
if (total >= 4) {
score += 100000; // 즉시 승리/방어
} else if (total === 3) {
score += openEnds > 0 ? 5000 : 500;
} else if (total === 2) {
score += openEnds > 0 ? 500 : 50;
} else if (total === 1) {
score += openEnds > 0 ? 50 : 10;
}
}
return score;
}
// 컴퓨터(백)의 최적 수 찾기
function getBestMove(board) {
let bestScore = -1;
let bestMove = null;
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE; c++) {
if (board[r][c] !== EMPTY) continue;
// 공격 점수 (백돌)
const attackScore = evaluateCell(board, r, c, WHITE);
// 방어 점수 (흑돌이 이 자리에 두었을 때의 위협)
const defenseScore = evaluateCell(board, r, c, BLACK);
const totalScore = attackScore + defenseScore * 0.9;
if (totalScore > bestScore) {
bestScore = totalScore;
bestMove = [r, c];
}
}
}
return bestMove;
}
```
그리고 컴포넌트 내부 수정.
첫 번째 edit: DIRECTIONS와 checkWin 사이에 AI 함수 추가.
두 번째 edit: 컴포넌트 시작 부분에 isThinking 추가 + useEffect 추가.
세 번째 edit: handleClick 수정.
네 번째 edit: reset에 isThinking 리셋 추가.
하나씩 처리하자.
AI 함수를 추가하고, 컴포넌트를 수정하겠습니다. 먼저 AI 평가 함수를 추가합니다.
✅파일 수정web/src/OmokGame.jsx
이제 컴포넌트 내부를 수정해야 한다.
1. isThinking 상태 추가
2. useEffect로 컴퓨터 자동 플레이
3. handleClick 수정
4. reset 수정
먼저 컴포넌트 시작 부분:
```
export default function OmokGame() {
const [board, setBoard] = useState(createBoard);
const [turn, setTurn] = useState(BLACK);
const [winner, setWinner] = useState(null);
```
여기에 isThinking 추가:
```
const [isThinking, setIsThinking] = useState(false);
```
그리고 handleClick을 수정:
- turn이 BLACK이 아니면 return
- isThinking이면 return
그 다음 useEffect 추가.
reset에도 isThinking 리셋 추가.
하나씩 처리하자.
이제 컴포넌트 내부에 `isThinking` 상태와 `useEffect`를 추가하고, `handleClick`과 `reset`을 수정합니다.
✅파일 수정web/src/OmokGame.jsx
이제 useEffect를 import 해야 한다. 현재 `import { useState, useCallback } from "react";` 인데, useEffect도 추가해야 한다.
그리고 statusText에서 "차례" 대신 컴퓨터가 생각 중일 때의 표시도 추가하면 좋겠다.
먼저 import 수정부터.
`useEffect` import를 추가하고, 상태 메시지도 개선합니다.
✅파일 수정web/src/OmokGame.jsx
이제 statusText를 수정해서 컴퓨터가 생각 중일 때 표시를 개선한다.
현재:
```
const statusText = winner
? `🎉 ${winner === BLACK ? "흑돌" : "백돌"} 승리!`
: `${turn === BLACK ? "흑돌" : "백돌"} 차례`;
```
컴퓨터가 생각 중일 때(isThinking) "컴퓨터 생각 중..." 같은 메시지를 보여주자.
상태 메시지에 컴퓨터 생각 중 표시를 추가합니다.
✅파일 수정web/src/OmokGame.jsx
이제 set_plan을 갱신하고 read_logs로 확인하자. 먼저 계획을 갱신.
✅계획3단계 (완료 2)
read_logs로 오류 확인하자.
✅로그 확인최근 30줄
HMR 업데이트가 정상적으로 이루어졌다. 오류 메시지는 보이지 않는다.
하지만 useEffect 로직에 잠재적 문제가 있을 수 있다. 두 번째 useEffect에서 `board`, `turn`, `winner`, `isThinking`을 의존성으로 가지고 있는데, 이 useEffect는 board가 변경될 때마다 실행되면서 백돌 승리 여부를 확인한다. 그리고 승리가 아니면 setIsThinking(false)를 호출한다.
그런데 첫 번째 useEffect에서 setTimeout으로 setBoard를 호출한 후, setTurn(BLACK)을 호출한다. React 18의 배치 처리로 인해 setBoard와 setTurn이 함께 처리될 수 있다. 그런데 첫 번째 useEffect의 setTurn에서 `prev => BLACK` 형태로 쓰고 있어서 잘 동작할 것이다.
하지만 문제가 하나 있다. 첫 번째 useEffect에서:
1. setIsThinking(true)
2. 300ms 후 setBoard(...) 호출
3. 그 다음 setTurn(prev => BLACK) 호출
setBoard와 setTurn이 배치로 처리될 수 있지만, setBoard에서 사용하는 `prev`는 클로저에 캡처된 시점의 board 값이다. 사실 setBoard(prev => ...) 형태로 써야 안전하다. 지금은 그렇게 쓰고 있어서 괜찮다.
두 번째 useEffect에서:
- turn === BLACK && !winner && !isThinking 일 때 board를 검사
- 그런데 isThinking은 아직 true인 상태에서 setBoard가 호출되고, setTurn이 BLACK으로 바뀌면:
1. 첫 렌더링: board 업데이트, turn은 아직 WHITE? 아니면 둘 다 배치로 처리되어 같이 변경?
React 18에서는 setBoard와 setTurn이 배치 처리되지만, useEffect는 렌더링 이후에 실행된다. 첫 번째 useEffect의 setTimeout 내에서 setBoard와 setTurn을 호출하면, 두 상태 변경이 하나의 렌더링으로 배치된다.
렌더링 후:
- board는 업데이트됨
- turn은 BLACK
- isThinking은 여전히 true (setIsThinking(false)는 아직 호출되지 않음)
그러면 두 번째 useEffect가 실행된다:
- turn === BLACK ✓
- !winner ✓
- !isThinking ✗ (isThinking은 true)
따라서 두 번째 useEffect는 아무것도 하지 않는다.
그럼 setIsThinking(false)는 언제 호출되는가? 두 번째 useEffect에서 isThinking이 false가 되어야 하는데, 현재 로직에서는 turn이 BLACK이고 isThinking이 false일 때만 실행된다. 즉, isThinking이 true인 상태에서는 두 번째 useEffect가 실행되지 않으므로 setIsThinking(false)가 호출되지 않는다. 데드락!
이 문제를 해결해야 한다.
방법 1: 첫 번째 useEffect의 setTimeout 내에서 직접 승리 체크를 하고, 승리가 아니면 setIsThinking(false)를 호출한다.
방법 2: 두 번째 useEffect의 조건에서 isThinking 조건을 제거한다.
방법 1이 더 깔끔할 것 같다. setTimeout 내에서 board 상태를 업데이트한 후, 승리 체크를 하고, 승리가 아니면 setIsThinking(false)로 처리한다.
그런데 문제는 setBoard 후 바로 board 값을 읽을 수 없다는 것이다. board는 클로저에 캡처된 값이다. 대신 getBestMove로 얻은 좌표를 이용해 nextBoard를 만들어서 checkWin을 직접 호출할 수 있다.
첫 번째 useEffect를 다시 작성:
```javascript
useEffect(() => {
if (turn !== WHITE || winner) return;
setIsThinking(true);
const timer = setTimeout(() => {
setBoard(prev => {
const bestMove = getBestMove(prev);
if (!bestMove) return prev;
const [r, c] = bestMove;
const nextBoard = prev.map(row => [...row]);
nextBoard[r][c] = WHITE;
// 승리 체크
if (checkWin(nextBoard, r, c, WHITE)) {
// 승리: winner 설정, isThinking 해제
// setWinner는 setBoard 바깥에서 호출해야 함...
// 여기서는 직접 setWinner를 호출할 수 없음 (렌더링 중)
return nextBoard; // 승리 케이스는 별도 처리 필요
}
return nextBoard;
});
// setBoard 이후 처리...
// 이 부분이 복잡하다.
}, 300);
return () => clearTimeout(timer);
}, [turn, winner]);
```
React의 setState 함수형 업데이트 내에서는 다른 상태를 변경할 수 없다. 그래서 승리 체크를 setBoard 내에서 처리하기 어렵다.
더 나은 접근: setTimeout 내에서 board를 직접 read할 수 있도록 useRef를 사용하거나, 단순히 구조를 바꾸자.
useRef로 board를 추적하는 방식:
```javascript
const boardRef = useRef(board);
boardRef.current = board;
```
이렇게 하면 setTimeout 내에서 `boardRef.current`로 최신 board를 읽을 수 있다.
또는, 더 간단하게: 두 번째 useEffect를 수정해서 isThinking 조건을 빼고, 대신 board의 변경을 감지해서 승리 체크를 하고 isThinking을 해제하도록 하자.
```javascript
useEffect(() => {
if (turn !== BLACK || winner) return;
// board에서 백돌 승리 여부 확인
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c < BOARD_SIZE; c++) {
if (board[r][c] === WHITE && checkWin(board, r, c, WHITE)) {
setWinner(WHITE);
setIsThinking(false);
return;
}
}
}
setIsThinking(false);
}, [board, turn, winner]);
```
이렇게 하면 isThinking 조건을 제거했으므로, turn이 BLACK으로 바뀌고 board가 업데이트된 후 이 useEffect가 실행된다. isThinking은 여전히 true이지만, 조건에서 체크하지 않으므로 실행되어 승리 체크를 하고 isThinking을 false로 설정한다.
이게 더 간단하고 정확할 것 같다. 수정하자.
그리고 첫 번째 useEffect에서 setTurn을 `prev => BLACK` 대신 그냥 `BLACK`으로 해도 될 것이다. 어차피 항상 BLACK으로 설정하니까.
또한 첫 번째 useEffect에서 getBestMove가 null을 반환하는 경우 (모든 칸이 찼을 때)를 처리해야 하는데, 이 경우 그냥 무승부 처리하거나 setIsThinking(false)를 해야 한다. 그런데 오목은 보통 다 채워지기 전에 승부가 나니까 큰 문제는 없을 것이다.
자, 두 번째 useEffect를 수정하자.
로직에 잠재적 데드락이 발견되었습니다. 두 번째 `useEffect`의 조건에서 `isThinking`을 제거하여 컴퓨터가 둔 후 `isThinking`이 `false`로 풀리지 않는 문제를 수정합니다.
✅파일 수정web/src/OmokGame.jsx
read_logs로 확인하고, 계획을 완료하자.
✅로그 확인최근 10줄
HMR 업데이트가 잘 되었다. 오류 없음.
계획 완료.
HMR 정상 반영, 오류 없습니다.
✅계획3단계 (완료 3)
에이전트 메모를 업데이트해야 한다.
에이전트 메모를 갱신합니다.
✅파일 읽기docs/에이전트-메모.md