인공지능 과목을 공부하면서 배운 게임트리를 실제로 구현해보고자 간단한 삼목게임(tic-tac-toe)을 만들어보았습니다.
게임트리는 최대최소 탐색을 통해 작성됩니다. 이후 알파-베타 가지치기(- pruning)를 적용하여 탐색 범위를 좁히는 알고리즘을 구현할 예정입니다.
typescript로 작성된 React 앱이고, vite 와 tailwind를 사용했습니다.
게임이 종료되지 않은 경우 수행되는 평가 함수입니다.
X 또는 O가 승리조건을 만족 할 수 있는 줄에 대해 같은 가중치로 점수를 평가합니다.
(하지만 이 경우 X가 두개 놓인 줄과 X가 하나 놓인 줄이 같은 점수로 계산되는 문제가 있습니다. 추후에 수정할 예정입니다.)
function evaluate(isAIFirst: boolean, currentState: State[]): number {
let possibleX = 0;
let possibleO = 0;
LINES.forEach(line => {
const stateSet = new Set();
line.forEach(idx => {
const state = currentState[idx];
if (state) stateSet.add(state);
})
if (stateSet.size !== 1) return;
if (stateSet.has('O')) possibleO++;
else possibleX++;
})
return isAIFirst ? possibleX - possibleO : possibleO - possibleX;
}
// 승리 조건
const LINES = [
[0, 1, 2],
[3, 4, 5],
[6, 7, 8],
[0, 3, 6],
[1, 4, 7],
[2, 5, 8],
[0, 4, 8],
[2, 4, 6],
];
다만 기본적으로 탐색 깊이 제한을 9로 두었기 때문에 실제로 수행되지는 않고 있습니다.
최대 깊이 제한에 닿을 경우 평가함수를 수행하고 종료합니다.
그 외에는 게임의 종료 여부를 판단하고, 결과에 따라 점수를 반환합니다.
게임이 끝나지 않은 경우에는 상대방의 입장에서 AI의 보상 최대화 탐색을 고려한 최소 점수 탐색을 수행합니다.
function minimize(isAIFirst: boolean, currentState: State[], depth: number): number {
if (depth >= DEPTH_BOUND) {
return evaluate(isAIFirst, currentState);
}
const aiPlayer = isAIFirst ? 'X' : 'O';
const winner = calculateWinner(currentState);
const isDraw = !winner && currentState.every(Boolean);
if (winner === aiPlayer) {
return Infinity;
} else if (isDraw) {
return 0;
} else if (winner !== null && winner !== aiPlayer) {
return -Infinity;
}
const humanPlayer = isAIFirst ? 'O' : 'X';
let minValue = Infinity;
// 현재 상태로부터 가능 상태를 판단합니다.
currentState.forEach((state, idx) => {
// 빈 칸에
if (state === null) {
const possibleMove = currentState.slice();
// 내(사람)가 착수를 했을 때,
possibleMove[idx] = humanPlayer;
// 상대방(AI)이 자신의 보상을 극대화 하는 결정중에서
const value = maximize(isAIFirst, possibleMove, depth + 1);
// 가장 작은 점수를 찾아 그 값을 선택합니다.
if (value < minValue) {
minValue = value;
}
}
})
return minValue;
}
최대 탐색 함수는 최소 탐색함수와 같으나, AI의 입장에서 상대(사람)의 보상 최대화(AI의 보상 최소화) 탐색을 고려하여 최대 탐색을 수행합니다.
function maximize(isAIFirst: boolean, currentState: State[], depth: number): number {
if (depth >= DEPTH_BOUND) {
return evaluate(isAIFirst, currentState);
}
const aiPlayer = isAIFirst ? 'X' : 'O';
const winner = calculateWinner(currentState);
const isDraw = !winner && currentState.every(Boolean);
if (winner === aiPlayer) {
return Infinity;
} else if (isDraw) {
return 0;
} else if (winner !== null && winner !== aiPlayer) {
return -Infinity;
}
let maxValue = -Infinity;
// 마찬가지로 현재 상태로부터 가능 상태를 탐색합니다.
currentState.forEach((state, idx) => {
// 빈칸에
if (state === null) {
const possibleMove = currentState.slice();
// 내(AI)가 착수했을 때,
possibleMove[idx] = aiPlayer;
// 상대(사람)가 나의 점수를 최소화(상대의 점수 최대화)하는 선택을 고려하여
const value = minimize(isAIFirst, possibleMove, depth + 1);
// 가장 큰 보상을 택합니다.
if (value > maxValue) {
maxValue = value;
}
}
})
return maxValue;
}
AI의 입장에서 상대(사람)의 최소화 탐색을 고려한 최대 탐색을 수행합니다.
export function minimax(isAIFirst: boolean, currentState: State[]): number {
const aiPlayer = isAIFirst ? 'X' : 'O';
let maxValue = -Infinity;
let bestMove = 0;
currentState.forEach((state, idx) => {
if (state === null) {
const possibleMove = currentState.slice();
possibleMove[idx] = aiPlayer;
const value = minimize(isAIFirst, possibleMove, 1);
if (value > maxValue) {
maxValue = value;
bestMove = idx;
}
}
})
return bestMove;
}
확실히 책으로 배운 내용을 실제로 구현하는 과정에서 알고리즘에 대해 더 잘 이해할 수 있었습니다. 알파-베타 가지치기나 몬테카를로 트리 탐색 등 더 복잡한 알고리즘을 작성해본 뒤, 더 복잡한 게임에 적용할 수 있도록 확장해볼 계획입니다.