Pull to refresh

Разработка Tic-Tac-Toe на нативном JavaScript

Reading time 6 min
Views 10K
Всем привет, и доброго времени суток, Хабравчане! Будучи в отпуске, дабы отвлечься от рутинных и рабочих процессов, решил чем-нибудь развлечь себя, и написать что-нибудь эдакое.

На чём писать? Решил выбрать нативный JavaScript, дабы подтянуть свой скилл, в одном из самых неоднозначных языков программирования. Что писать? Хоть и занимаюсь веб-разработкой, но давно испытываю любовь к GameDev'у, человек я творческий, что поделаешь. Поэтому остановился на одной из самых простых игр, — крестики-нолики.

Я не включал секундомер садясь за сие творчество, но вспоминая процесс, думаю, что потратил на игру часов 15-20. Особо за временем не следил, поэтому могу ошибаться. В день тратил по часу-два, не больше. Старался получать максимум удовольствия от процесса.

Чистый JavaScript, и только хардкор! Не использовал никаких библиотек, и не заглядывал в другие реализации сей игры, дабы не путать собственное виденье. Поэтому заранее извиняюсь, местами решения могут быть далеки от идеалов, но я готов к диалогу, к критике, или просто к советам.

Вся игра держится на трёх файлах JS, на одном стилевом файле, и на index.html. Плюс директория с трёмя изображениями (крестик, нолик, фон). Дизайнер с меня околонулевой, поэтому за оформление прошу сильно не пинать.

Итак, три файла JS, сердце игры, — main.js, ai.js и helpers.js. С Вашего позволения, я расскажу только про два, т.к. в helpers.js ничего особо интересного, там описаны вспомогательные и тривиальные функции. Вы можете сами его посмотреть, к их критике я тоже готов.

Ещё одно примечание, я не задавался целью кроссбраузерности, всё проверялась мною на последнем Chrome. Также используются некоторые возможности ES6, поэтому с недружелюбными к нему браузерами могут возникнуть проблемы.

Весь js-код в трёх файлах уместился в 272 строчки (на момент написания сей статьи). Файл main.js содержит основной код, а в файле ai.js хранится реализация подобия ИИ (язык не поворачивается назвать его просто ИИ, поэтому позвольте дальше я буду именовать его ПИИ).

Приступим к разбору.

Небольшой экскурс по переменным. Единственное, что я рассмотрю из helpers.js:

// Sides player and AI
var player;
var ai;
// Who goes first
var first_run;
// Battle blocks in the game
var blocks = document.getElementsByClassName("block");
// Collections win lines for points
var win_lines = [
	["1","2","3"],
	["4","5","6"],
	["7","8","9"],
	["1","4","7"],
	["2","5","8"],
	["3","6","9"],
	["1","5","9"],
	["3","5","7"],
];

Прошу прощения за свой ломанный английский в комментариях к коду. Переменные player и ai определяют, каким знаком играют игрок и ПИИ, у кого крестик, а у кого нолик. Переменная first_run определяет кто ходит первым. Переменная blocks содержит массив html-элементов, т.н. клетки на игровом поле. Ну и переменная win_lines — двумерный массив победных линий, сбор одной из которых и составляет цель игры.

Начало main.js:

Array.from(blocks).forEach((element) => {

	element.addEventListener("click", function() {

		if (element.classList.contains(player) || element.classList.contains(ai)) {
			return;
		}

		var ai_count    = countPoints(ai);
		var player_count = countPoints(player);

		if (first_run == "player" && ai_count < player_count) {
			return;
		}
		if (first_run == "ai" && ai_count == player_count) {
			return;
		}

		setImg(this, player);
		var win = identifyWinner(player);
		if (win) {
			endPlay("win");
			window.clearInterval(monitoringSteps);
		}
	});

});

Договоримся, знаками я буду называть крестики или нолики.

Перебираем массив элементов и вешаем на каждую клетку игрового поля слушателя на клик. По клику на клетку, если в ней нет знака, ставим знак игрока. На строках 9-10 подсчитываем все знаки и игрока и ПИИ. Строки с 12 по 17 помогают нам контролировать чей первый ход и очерёдность ходов. Чтобы игрок не мог походить не в свою очередь. На 19 строчке функция ставящая знак игрока в клетку по которой он щёлкнул, эта процедура описана в helpers.js ничего интересного. В конце определяем победный ли ход сделал игрок, если да, игра окончена. Функции indentifyWinner и endPlay описаны там же, в helpers.

var originally_points_player = 0;
var monitoringSteps = setInterval(() => {

	var player_points_count = countPoints(player);

	if (player_points_count > originally_points_player) {

		originally_points_player = player_points_count;
		var empty_blocks = emptyBlocks();

		if (empty_blocks.length > 0) {

			// run enemy
			var favorite_run = selectFavoriteRun(empty_blocks);
			if (favorite_run > 0) {
				var block_for_run = document.getElementById(favorite_run);
			} else {
				var random_index = Math.floor( Math.random() * (empty_blocks.length) );
				var block_for_run = empty_blocks[random_index];
			}
			setImg(block_for_run, ai);
			// end run enemy

			var win = identifyWinner(ai);
			if (win) {
				endPlay("lose");
			}
		}
	}

}, 2000);

Код отслеживающий ход игрока и передающий инициативу ПИИ. Строка 1, переменная originally_points_player хранит изначальное количество знаков игрока. Изначальное — имеется ввиду, до его последнего хода. На 4ой строчке подсчитываем знаки игрока, 6 строчка, если их количество увеличилось, значит игрок сделал ход. Передаём инициативу ПИИ. Выбираем пустые блоки (клетки на игровом поле) и если они имеются, даём право хода ПИИ. Функция selectFavoriteRun(), в которую мы передаём пустые клетки, описана в ai.js, её мы рассмотрим позже. А пока скажу, что суть её в определении наиболее выгодного хода для ПИИ. Функция возвращает или ноль, если выгодный ход неопределён, либо ID-номер клетки, в которую надо ставить знак ПИИ. Если возвращённое значение больше нуля, выбираем блок-клетку для хода, если меньше, выбираем случайный блок-клетку из пустых.

И на строчке 21 ставим знак ПИИ на игровое поле. С 24 по 27 строку определяем победный ли ход сделал ПИИ.

setInterval(() => {
	var done = document.getElementById("inscription").innerHTML.length == 0;
	if (emptyBlocks().length == 0 && done) {
		endPlay("draw");
	}
}, 1000);

// Code for select side in the game
var wrapper_button_select = document.getElementById("wrapper-button");
var button_select = wrapper_button_select.getElementsByTagName("button")[0];
button_select.addEventListener("click", function() {
	selectSide();
	// Who goes first
	if (player == "cross") {
		first_run = "player";
	} else if (player == "zero") {
		first_ai();
		first_run = "ai";
	}
});

// Button "Again?"
var button_again = document.getElementById("info-again");
button_again.addEventListener("click", function() {
	location.reload();
});

Далее в main.js менее интересные вещи. Здесь сначала отслеживаем ситуацию ничьей. Если клетки закончились и победитель не был определён, то ничья. Переменная done для того, чтобы определение ничьей не происходило до начала или после окончания игры.

Дальше, код для выбора стороны игроком и код для кнопочки «Again?».

Переходим в ai.js, наверное к самому интересному. Пока он скуден и состоит лишь из 44 строчек. Но мысли и идеи для него есть, отпуск закончился и всё упирается во время…

var selectFavoriteRun = (empty_blocks) => {

	var points_player = document.getElementsByClassName(player);
	var ids_player = getIdsArray(points_player);
	// Do not to give player win 
	var favor_run_no_win_player = determiningPlaceForRun(ids_player);

	var points_ai = document.getElementsByClassName(ai);
	var ids_ai = getIdsArray(points_ai);
	// Run for win AI
	var favor_run_win_ai = determiningPlaceForRun(ids_ai);

	return (favor_run_win_ai > 0) ? favor_run_win_ai : favor_run_no_win_player;
};

Ранее виданная функция selectFavoriteRun. Пока определение выгодного хода для ПИИ, основывается на двух принципах. Первый это, если есть ход который позволит ПИИ выиграть, ну так делаем его. Если такого нет. Тогда вступает в силу второй принцип, если есть опасность, что следующим ходом игрок выиграет, всеми силами пытаемся не дать ему это сделать. Если и такой ситуации не предвидится, возвращаем ноль. И пусть рандом решает куда ходить.

Итак, 3-4 строки, мы определяем все знаки игрока на поле. Берём айдишники этих клеток и передаём в ещё одну функцию determiningPlaceForRun, которую рассмотрим ниже. Получаем из функции либо айдишник выгодной для хода клетки, либо ноль.

В строках с 8 по 11, мы делаем тоже самое со знаками ПИИ, для определения возможно победного хода. Ну и возвращаем результат. Если есть вариант для победы, возвращаем его, нет, тогда возвращаем ход для не допуска победы игрока.

var determiningPlaceForRun = (array_elements_points) => {
	var favorite_run = 0;

	win_lines.forEach((positions) => {
		var points_in_row = 0;
		var point_for_win = 0;
		for (var i = 0; i < array_elements_points.length; i++) {
			if (positions.indexOf(array_elements_points[i]) != -1) {
				points_in_row++;
			}
			if (points_in_row == 2) {
				point_for_win = positions.diff(array_elements_points)[0];
				var is_cross = document.getElementById(point_for_win).classList.contains(player);
				var is_zero  = document.getElementById(point_for_win).classList.contains(ai);
				if (is_zero || is_cross) {
					point_for_win = 0;
				}
			}
		}
		if (point_for_win > 0) {
			favorite_run = point_for_win;
		}
	});

	return favorite_run;
};

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

После запускаем вложенный for, который перебирает все, уже стоящие знаки на поле. Условие на 8 строке проверяет есть ли знак в победной линии. Если таких знаков набирается два. Значит есть возможность выиграть, или проиграть (в зависимости чьи знаки проверяем). На 12 строчке присваиваем оставшуюся клетку из победной линии, переменной для выгодного хода. С 13 по 17 строчку перестраховываемся проверяем свободна ли клетка. Если нет аннулируем выгодный ход. (На самом деле из-за не оптимальности логики, без этой проверки вообще никак. Поскольку мы проверяем наличие только одного вида знаков в клетках, в тоже время «типа-свободно-выгодная» клетка возможно уже занята знаком другого типа).

В конце если выгодный ход больше нуля он вернётся, иначе из функции вылетит ноль.

Вот таков пока мой примитивный Тиктактой. В будущем есть планы, углубится в ES6 и заюзать его в полную мощь. Доработать ai.js чтобы ПИИ не пользовался рандомом при ходе, а сразу стремился выиграть. А также реализация уровней сложности игры. Но всё упирается во время. Всем спасибо за внимание и добра!

Ссылка на GitHub проекта.
Tags:
Hubs:
+2
Comments 17
Comments Comments 17

Articles