Делаем бота для крестиков-ноликов, который почти невозможно обыграть
medium

Делаем бота для крестиков-ноликов, который почти невозможно обыграть

Проверяем алгоритм минимакса в деле

Вчера мы разобрали алгоритм минимакса, который позволяет находить лучшую стратегию в плохих условиях. Вот короткая версия:

  • Есть игры, в которых выигрыш одного сразу приводит к проигрышу всех остальных.
  • Есть стратегия минимакса — она находит наилучшее решение в наихудших условиях. Проще говоря, она старается сделать так, что даже в случае проигрыша он был максимально небольшим.
  • Благодаря этой стратегии вы можете выигрывать в подобных играх. Или не вы, а тот, кто будет этой стратегией пользоваться, — например компьютер. 

Сегодня мы напишем бота, который будет считать минимаксную стратегию и принимать самое рациональное игровое решение. 

Что делаем

Проект будет состоять из трёх частей: 

  1. Игровой интерфейс, который работает в браузере, — игровое поле, фигуры, их постановка.
  2. Логика самой игры — правила и проверка выигрыша.
  3. Поведение бота, который будет играть против нас. 

Исходная пустая страница

Для игры нам достаточно обычной веб-страницы. Мы разместим на ней название игры, кнопку перезапуска и игровое поле. Игровое поле — таблица 3 на 3, чтобы было проще ориентироваться в клетках. Клеткам сразу дадим id с номером, чтобы потом к ним обращаться. Ещё сразу добавим блок с сообщением о выигрыше или проигрыше, но мы его сначала скроем, а покажем только в самом конце. 

<!DOCTYPE html>
<html lang="ru" >
<head>
  <meta charset="UTF-8">
  <title>Крестики-нолики</title>
  <link rel="stylesheet" href="style.css">

</head>
<body>

  <h1>Крестики-нолики</h1>
  <!-- игровое поле -->
	<table>
		<!-- каждая клетка — ячейка таблицы -->
		<tr>
			<td class="cell" id="0"></td>
			<td class="cell" id="1"></td>
			<td class="cell" id="2"></td>
		</tr>
		<tr>
			<td class="cell" id="3"></td>
			<td class="cell" id="4"></td>
			<td class="cell" id="5"></td>
		</tr>
		<tr>
			<td class="cell" id="6"></td>
			<td class="cell" id="7"></td>
			<td class="cell" id="8"></td>
		</tr>
	</table>
	<!-- блок с сообщением о конце игры -->
	<div class="endgame">
		<div class="text"></div>
	</div>
	<!-- кнопка перезапуска -->
	<button onClick="startGame()">Заново</button>
	<!-- подключаем скрипт -->
  <script src="script.js"></script>
</body>
</html>

Добавляем стили

Чтобы всё выглядело прилично, накатим стили: создадим файл style.css и добавим туда такой код. Читайте комментарии, если будут непонятные моменты:

/* настройки ячейки */
td {
/* граница */
	border:  2px solid #333;
/* высота и ширина ячейки */
	height:  100px;
	width:  100px;
/* выравнивание по центру */
	text-align:  center;
	vertical-align:  middle;
/* настраиваем шрифт */
	font-family:  "Comic Sans MS", cursive, sans-serif;
	font-size:  70px;
/* меняем вид курсора над ячейкой */
	cursor: pointer;
}

/* настройки всей таблицы */
table {
/* общая граница пусть выглядит как одна */
	border-collapse: collapse;
/* включаем абсолютное позиционирование */
	position: absolute;
/* размещаем таблицу на странице */
	left: 50%;
	margin-left: -155px;
	top: 50px;
}

/* убираем границы снаружи таблицы, чтобы расчертить поле как для крестиков-ноликов */
table tr:first-child td {
	border-top: 0;
}

table tr:last-child td {
	border-bottom: 0;
}

table tr td:first-child {
	border-left: 0;
}

table tr td:last-child {
	border-right: 0;
}

/* блок с сообщением о конце игры */
.endgame {
/* на старте не показываем */
  display: none;
/* размеры и положение блока */
  width: 200px;
  top: 120px;
  position: absolute;
  left: 50%;
/* цвет фона */
  background-color: rgba(205,133,63, 0.8);
/* настраиваем отступы */
  margin-left: -100px;
  padding-top: 50px;
  padding-bottom: 50px;
  text-align: center;
/* радиус скругления */
  border-radius: 5px;
/* цвет и размер шрифта */
  color: white;
  font-size: 2em;
}
Теперь у нас есть полноценное поле для игры

Пишем скрипт

Первым делом объявим переменные — на них мы будем опираться почти в каждой функции. Нам понадобится переменная для игрового поля, символы крестиков и ноликов, а также все выигрышные комбинации. На них остановимся подробнее.

Если мы пронумеруем все клетки от 0 до 8, то сможем выписать все выигрышные комбинации. Например, для выигрыша по горизонтали комбинации будут такие:

0 1 2

3 4 5

6 7 8

То же самое можно сделать и для выигрышных комбинаций по вертикали и диагонали:

После этого сразу вызовем запуск игры — и он же у нас привязан к кнопке перезапуска. Внутри функции запуска мы скроем ещё раз сообщение о том, что игра закончилась, очистим игровое поле и добавим обработчик событий для каждой клетки.

Создадим файл script.js и запишем туда всё это:

// переменная для игрового поля
var origBoard;
// игрок ставит крестики, компьютер — нолики
const huPlayer = 'X';
const aiPlayer = 'O';
// выигрышные комбинации
const winCombos = [
	[0, 1, 2],
	[3, 4, 5],
	[6, 7, 8],
	[0, 3, 6],
	[1, 4, 7],
	[2, 5, 8],
	[0, 4, 8],
	[6, 4, 2]
]

// получаем доступ к HTML-клеткам на доске
const cells = document.querySelectorAll('.cell');
// запускаем игру
startGame();

// запуск игры
function startGame() {
	// скрываем текст про то, что игра закончилась
	document.querySelector(".endgame").style.display = "none";
	// формируем игровое поле
	origBoard = Array.from(Array(9).keys());
	// перебираем все клетки, очищаем их, убираем цвет фона и вешаем обработчик клика на каждую
	for (var i = 0; i < cells.length; i++) {
		cells[i].innerText = '';
		cells[i].style.removeProperty('background-color');
		cells[i].addEventListener('click', turnClick, false);
	}
}

Обрабатываем нажатия на клетки

Логика нажатия будет такая: если в этом месте не число (а крестик или нолик), то ничего не происходит, а если число — ставим там крестик и проверяем, привело это к победе или нет.

Для проверки выигрыша или ничьей мы используем метод reduce — он берёт исходный массив и обрабатывает его по указанному правилу, формируя какое-то новое значение. В нашем случае мы попросим его пройти по массиву и собрать все комбинации, где стоят символы одного из игроков. Дальше мы просто сравним эти комбинации с выигрышными и, если одна из них совпадёт, — выводим сообщение и останавливаем игру.

// обрабатываем клик на клетке
function turnClick(square) {
	// если клетка свободна
	if (typeof origBoard[square.target.id] == 'number') {
		// ставим там крестик
		turn(square.target.id, huPlayer)
		// если после хода игрок не выиграл и нет ничьей, компьютер находит лучшее место для нолика и ставит его туда
		if (!checkWin(origBoard, huPlayer) && !checkTie()) turn(bestSpot(), aiPlayer);
	}
}

// обработка хода
function turn(squareId, player) {
	// ставим фигуру на выбранное место
	origBoard[squareId] = player;
	// рисуем её на игровом поле на странице
	document.getElementById(squareId).innerText = player;
	// проверяем, есть ли победа после хода
	let gameWon = checkWin(origBoard, player)
	// если есть — выводим сообщение об этом
	if (gameWon) gameOver(gameWon)
}
// компьютер находит лучшее поле для хода
function bestSpot() {
	// получаем номер клетки для лучшего хода из минимаксного алгоритма
	return minimax(origBoard, aiPlayer).index;
}

// проверяем, выиграл ли кто-то после своего хода
function checkWin(board, player) {
	// проходим по доске и собираем все комбинации, проставленные участником
	let plays = board.reduce((a, e, i) =>
		(e === player) ? a.concat(i) : a, []);
	// на старте считаем, что выигрышной ситуации нет
	let gameWon = null;
	// перебираем все выигрышные комбинации и сравниваем их с ситуацией на доске
	for (let [index, win] of winCombos.entries()) {
		// если одна из них совпадает с тем, что на доске — формируем информацию о победителе
		if (win.every(elem => plays.indexOf(elem) > -1)) {
			gameWon = {index: index, player: player};
			break;
		}
	}
	// возвращаем информацию о победителе
	return gameWon;
}

// конец игры
function gameOver(gameWon) {
	// берём выигрышную комбирацию
	for (let index of winCombos[gameWon.index]) {
		// и раскрашиваем её в нужные цвета
		document.getElementById(index).style.backgroundColor =
			gameWon.player == huPlayer ? "blue" : "red";
	}
	// убираем обработчик нажатия со всех клеток
	for (var i = 0; i < cells.length; i++) {
		cells[i].removeEventListener('click', turnClick, false);
	}
	// выводим сообщение о проигрыше или победе игрока
	declareWinner(gameWon.player == huPlayer ? "Вы выиграли!" : "Вы проиграли.");
}

// вывод сообщения о победе
function declareWinner(who) {
	// делаем сообщение видимым
	document.querySelector(".endgame").style.display = "block";
	// заполняем его нужным текстом
	document.querySelector(".endgame .text").innerText = who;
}

Применяем стратегию минимакса

Мы уже разбирали этот алгоритм в отдельной статье, вот ключевые моменты:

  1. Первый игрок ставит крестик в любом месте поля. Всего клеток 9, одну он занял, осталось 8.
  2. Мы по очереди виртуально ставим нолики в оставшиеся 8 клеток и оцениваем ситуацию, выиграли ли мы или проиграли. Если непонятно — закидываем уже новую ситуацию в алгоритм и прогоняем её с новыми вводными.
  3. Так делаем до тех пор, пока не заполнятся все клетки — при этом у нас будет очень много вариантов и ветвлений. 
  4. С теми ветками, где мы выиграли, каждому своему ходу в определённую клетку добавляем какое-то количество очков, а где проиграли — отнимаем такое же количество.
  5. После просчёта мы получим для каждой из 8 клеток, с которых начали в самом начале, свою оценку хода. 
  6. Наконец, мы выбираем для ответного хода ту клетку, которая набрала больше всего очков, и ставим нолик туда.

Чтобы реализовать эту стратегию, нам нужно знать, какие клетки свободны и образовалась ли ничья в текущей ситуации. Свободные клетки получаем по тому, есть ли там цифра или нет, а статус ничьей — по тому, что у нас не осталось пустых клеток, но при этом игра не закончилась победой одного из игроков.

// функция, которая проверяет, пустая ли выбранная клетка на поле или нет
function emptySquares() {
	return origBoard.filter(s => typeof s == 'number');
}
// проверка на ничью
function checkTie() {
	// если пустых клеток не осталось
	if (emptySquares().length == 0) {
		// перебираем все клетки и раскрашиваем их зелёным
		for (var i = 0; i < cells.length; i++) {
			cells[i].style.backgroundColor = "green";
			// отключаем обработчики нажатий
			cells[i].removeEventListener('click', turnClick, false);
		}
		// выводим сообщение про ничью
		declareWinner("Ничья!")
		return true;
	}
	return false;
}

Последнее, что осталось сделать, — написать алгоритм минимакса на JavaScript. Мы прокомментировали каждую строку, чтобы вы могли разобраться, что происходит на каждом этапе его работы.

// алгоритм поиска лучшего хода с минимаксной стратегией
function minimax(newBoard, player) {
	// получаем все клетки, доступные для ходов
	var availSpots = emptySquares();

	// если при текущем расположении побеждает игрок
	if (checkWin(newBoard, huPlayer)) {
		// отнимаем от результата 10 очков
		return {score: -10};
	// если выиграет компьютер
	} else if (checkWin(newBoard, aiPlayer)) {
		// прибавляем 10 очков
		return {score: 10};
	// если ничья, то не менеяем очки
	} else if (availSpots.length === 0) {
		return {score: 0};
	}

	// тут будем хранить все будущие ходы для их оценки
	var moves = [];
	// перебираем доступные клетки
	for (var i = 0; i < availSpots.length; i++) {
		// переменная для следующего шага
		var move = {};
		// делаем шаг в очередную пустую клетку и получаем новое положение на доске
		move.index = newBoard[availSpots[i]];
		// заполняем эту клетку символом того, чей ход мы рассчитываем
		newBoard[availSpots[i]] = player;

		// если считаем ход для компьютера
		if (player == aiPlayer) {
			// рекурсивно вызываем минимаксную функцию для нового положения и указываем, что следующий ход делает человек
			var result = minimax(newBoard, huPlayer);
			move.score = result.score;
		// то же самое, но если считаем ход человека
		} else {
			var result = minimax(newBoard, aiPlayer);
			move.score = result.score;
		}
		// запоминаем результат
		newBoard[availSpots[i]] = move.index;
		// добавляем ход в список ходов
		moves.push(move);
	}
	// переменная для лучшего хода
	var bestMove;
	// если считаем ход компьютера
	if(player === aiPlayer) {
		// берём максимально низкое значение
		var bestScore = -10000;
		// перебираем все ходы, что у нас получились
		for(var i = 0; i < moves.length; i++) {
			// если очки текущего хода больше лучшего значения
			if (moves[i].score > bestScore) {
				// запоминаем это как лучшее значение
				bestScore = moves[i].score;
				// запоминаем номер хода
				bestMove = i;
			}
		}
	// то же самое делаем с ходом, если моделируем ход человека
	} else {
		var bestScore = 10000;
		for(var i = 0; i < moves.length; i++) {
			if (moves[i].score < bestScore) {
				bestScore = moves[i].score;
				bestMove = i;
			}
		}
	}
	// возвращаем лучший ход
	return moves[bestMove];
}

Обновляем страницу и играем в игру, в которой компьютер всегда или побеждает, или сводит игру вничью:

Поиграть в крестики-нолики на странице проекта

<!DOCTYPE html>
<html lang="ru" >
<head>
  <meta charset="UTF-8">
  <title>Крестики-нолики</title>
  <link rel="stylesheet" href="style.css">

</head>
<body>

  <h1>Крестики-нолики</h1>
  <!-- игровое поле -->
	<table>
		<!-- каждая клетка — ячейка таблицы -->
		<tr>
			<td class="cell" id="0"></td>
			<td class="cell" id="1"></td>
			<td class="cell" id="2"></td>
		</tr>
		<tr>
			<td class="cell" id="3"></td>
			<td class="cell" id="4"></td>
			<td class="cell" id="5"></td>
		</tr>
		<tr>
			<td class="cell" id="6"></td>
			<td class="cell" id="7"></td>
			<td class="cell" id="8"></td>
		</tr>
	</table>
	<!-- блок с сообщением о конце игры -->
	<div class="endgame">
		<div class="text"></div>
	</div>
	<!-- кнопка перезапуска -->
	<button onClick="startGame()">Заново</button>
	<!-- подключаем скрипт -->
  <script src="script.js"></script>
</body>
</html>

/* настройки ячейки */
td {
/* граница */
	border:  2px solid #333;
/* высота и ширина ячейки */
	height:  100px;
	width:  100px;
/* выравнивание по центру */
	text-align:  center;
	vertical-align:  middle;
/* настраиваем шрифт */
	font-family:  "Comic Sans MS", cursive, sans-serif;
	font-size:  70px;
/* меняем вид курсора над ячейкой */
	cursor: pointer;
}

/* настройки всей таблицы */
table {
/* общая граница пусть выглядит как одна */
	border-collapse: collapse;
/* включаем абсолютное позиционирование */
	position: absolute;
/* размещаем таблицу на странице */
	left: 50%;
	margin-left: -155px;
	top: 50px;
}

/* убираем границы снаружи таблицы, чтобы расчертить поле как для крестиков-ноликов */
table tr:first-child td {
	border-top: 0;
}

table tr:last-child td {
	border-bottom: 0;
}

table tr td:first-child {
	border-left: 0;
}

table tr td:last-child {
	border-right: 0;
}

/* блок с сообщением о конце игры */
.endgame {
/* на старте не показываем */
  display: none;
/* размеры и положение блока */
  width: 200px;
  top: 120px;
  position: absolute;
  left: 50%;
/* цвет фона */
  background-color: rgba(205,133,63, 0.8);
/* настраиваем отступы */
  margin-left: -100px;
  padding-top: 50px;
  padding-bottom: 50px;
  text-align: center;
/* радиус скругления */
  border-radius: 5px;
/* цвет и размер шрифта */
  color: white;
  font-size: 2em;
}

// переменная для игрового поля
var origBoard;
// игрок ставит крестики, компьютер — нолики
const huPlayer = 'X';
const aiPlayer = 'O';
// выигрышные комбинации
const winCombos = [
	[0, 1, 2],
	[3, 4, 5],
	[6, 7, 8],
	[0, 3, 6],
	[1, 4, 7],
	[2, 5, 8],
	[0, 4, 8],
	[6, 4, 2]
]

// получаем доступ к HTML-клеткам на доске
const cells = document.querySelectorAll('.cell');
// запускаем игру
startGame();

// запуск игры
function startGame() {
	// скрываем текст про то, что игра закончилась
	document.querySelector(".endgame").style.display = "none";
	// формируем игровое поле
	origBoard = Array.from(Array(9).keys());
	// перебираем все клетки, очищаем их, убираем цвет фона и вешаем обработчик клика на каждую
	for (var i = 0; i < cells.length; i++) {
		cells[i].innerText = '';
		cells[i].style.removeProperty('background-color');
		cells[i].addEventListener('click', turnClick, false);
	}
}

// обрабатываем клик на клетке
function turnClick(square) {
	// если клетка свободна
	if (typeof origBoard[square.target.id] == 'number') {
		// ставим там крестик
		turn(square.target.id, huPlayer)
		// если после хода игрок не выиграл и нет ничьей, компьютер находит лучшее место для нолика и ставит его туда
		if (!checkWin(origBoard, huPlayer) && !checkTie()) turn(bestSpot(), aiPlayer);
	}
}

// обработка хода
function turn(squareId, player) {
	// ставим фигуру на выбранное место
	origBoard[squareId] = player;
	// рисуем её на игровом поле на странице
	document.getElementById(squareId).innerText = player;
	// проверяем, есть ли победа после хода
	let gameWon = checkWin(origBoard, player)
	// если есть — выводим сообщение об этом
	if (gameWon) gameOver(gameWon)
}

// проверяем, выиграл ли кто-то после своего хода
function checkWin(board, player) {
	// проходим по доске и собираем все комбинации, проставленные участником
	let plays = board.reduce((a, e, i) =>
		(e === player) ? a.concat(i) : a, []);
	// на старте считаем, что выигрышной ситуации нет
	let gameWon = null;
	// перебираем все выигрышные комбинации и сравниваем их с ситуацией на доске
	for (let [index, win] of winCombos.entries()) {
		// если одна из них совпадает с тем, что на доске — формируем информацию о победителе
		if (win.every(elem => plays.indexOf(elem) > -1)) {
			gameWon = {index: index, player: player};
			break;
		}
	}
	// возвращаем информацию о победителе
	return gameWon;
}

// конец игры
function gameOver(gameWon) {
	// берём выигрышную комбирацию
	for (let index of winCombos[gameWon.index]) {
		// и раскрашиваем её в нужные цвета
		document.getElementById(index).style.backgroundColor =
			gameWon.player == huPlayer ? "blue" : "red";
	}
	// убираем обработчик нажатия со всех клеток
	for (var i = 0; i < cells.length; i++) {
		cells[i].removeEventListener('click', turnClick, false);
	}
	// выводим сообщение о проигрыше или победе игрока
	declareWinner(gameWon.player == huPlayer ? "Вы выиграли!" : "Вы проиграли.");
}

// вывод сообщения о победе
function declareWinner(who) {
	// делаем сообщение видимым
	document.querySelector(".endgame").style.display = "block";
	// заполняем его нужным текстом
	document.querySelector(".endgame .text").innerText = who;
}

// функция, которая проверяеят, пустая ли выбранная клетка на поле или нет
function emptySquares() {
	return origBoard.filter(s => typeof s == 'number');
}

// компьютер находит лучшее поле для хода
function bestSpot() {
	// получаем номер клетки для лучшего хода из минимаксного алгоритма
	return minimax(origBoard, aiPlayer).index;
}

// проверка на ничью
function checkTie() {
	// если пустых клеток не осталось
	if (emptySquares().length == 0) {
		// перебираем все клетки и раскрашиваем их зелёным
		for (var i = 0; i < cells.length; i++) {
			cells[i].style.backgroundColor = "green";
			// отключаем обработчики нажатий
			cells[i].removeEventListener('click', turnClick, false);
		}
		// выводим сообщение про ничью
		declareWinner("Ничья!")
		return true;
	}
	return false;
}

// алгоритм поиска лучшего хода с минимаксной стратегией
function minimax(newBoard, player) {
	// получаем все клетки, доступные для ходов
	var availSpots = emptySquares();

	// если при текущем расположении побеждает игрок
	if (checkWin(newBoard, huPlayer)) {
		// отнимаем от результата 10 очков
		return {score: -10};
	// если выиграет компьютер
	} else if (checkWin(newBoard, aiPlayer)) {
		// прибавляем 10 очков
		return {score: 10};
	// если ничья, то не менеяем очки
	} else if (availSpots.length === 0) {
		return {score: 0};
	}

	// тут будем хранить все будущие ходы для их оценки
	var moves = [];
	// перебираем доступные клетки
	for (var i = 0; i < availSpots.length; i++) {
		// переменная для следующего шага
		var move = {};
		// делаем шаг в очередную пустую клетку и получаем новое положение на доске
		move.index = newBoard[availSpots[i]];
		// заполняем эту клетку символом того, чей ход мы рассчитываем
		newBoard[availSpots[i]] = player;

		// если считаем ход для компьютера
		if (player == aiPlayer) {
			// рекурсивно вызываем минимаксную функцию для нового положения и указываем, что следующий ход делает человек
			var result = minimax(newBoard, huPlayer);
			move.score = result.score;
		// то же самое, но если считаем ход человека
		} else {
			var result = minimax(newBoard, aiPlayer);
			move.score = result.score;
		}
		// запоминаем результат
		newBoard[availSpots[i]] = move.index;
		// добавляем ход в список ходов
		moves.push(move);
	}
	// переменная для лучшего хода
	var bestMove;
	// если считаем ход компьютера
	if(player === aiPlayer) {
		// берём максимально низкое значение
		var bestScore = -10000;
		// перебираем все ходы, что у нас получились
		for(var i = 0; i < moves.length; i++) {
			// если очки текущего хода больше лучшего значения
			if (moves[i].score > bestScore) {
				// запоминаем это как лучшее значение
				bestScore = moves[i].score;
				// запоминаем номер хода
				bestMove = i;
			}
		}
	// то же самое делаем с ходом, если моделируем ход человека
	} else {
		var bestScore = 10000;
		for(var i = 0; i < moves.length; i++) {
			if (moves[i].score < bestScore) {
				bestScore = moves[i].score;
				bestMove = i;
			}
		}
	}
	// возвращаем лучший ход
	return moves[bestMove];
}

Код:

Мэтью Беннет

Художник:

Алексей Сухов

Корректор:

Ирина Михеева

Вёрстка:

Кирилл Климентьев

Соцсети:

Аня Соколова

Получите ИТ-профессию
В «Яндекс Практикуме» можно стать разработчиком, тестировщиком, аналитиком и менеджером цифровых продуктов. Первая часть обучения всегда бесплатная, чтобы попробовать и найти то, что вам по душе. Дальше — программы трудоустройства.
Еще по теме
medium
[anycomment]
Exit mobile version