Игра Морской бой на JavaScript. Выстрел компьютера.
Вступление.
Заключительная статья из цикла «Игра Морской бой на чистом JavaScript». В этой статье мы рассмотрим:
- Выбор координат для выстрела компьютера.
- Обработка результата выстрела.
- Определение гарантированно пустых клеток, в которых не может быть кораблей противника.
- Вычисление координат следующего выстрела после попадания.
- Анализ количества оставшихся кораблей противника (игрока) и их тип.
Для реализации всех выше перечисленных действий будем использовать Искусственный Интеллект (ИИ) или, другими словами, некий гибкий алгоритм действий, меняющийся в зависимости от обстановки на игровом поле.
Перед изучением JavaScript, реализующего выстрел компьютера, необходимо ознакомится со статьёй «Игра Морской бой на JavaScript. Выстрел игрока.». Ряд функций используется как для выстрела игрока, так и для выстрела компьютера и в данной статье повторно рассматриваться не будут.
Игра «Морской бой». Оптимальная тактика ведения морского боя компьютера.
Для того, чтобы компьютер мог, как минимум, играть на равных с человеком, он (компьютер) должен иметь некую тактику игры. Причём, данная тактика должна быть оптимальна применительно к игре «Морской бой» и обеспечивать высокие шансы на победу над человеком.
Исходя из этого, сразу же откажемся от рандомного обстрела поля игрока в начальной стадии игры. В рандомном формировании координат выстрела компьютера в полной мере присутствует элемент случайности, а нам, для победы компьютера над человеком, нужно свести этот элемент к минимуму.
В чём же будет заключаться оптимальная тактика игры? А в том, что компьютер будет методично обстреливать поле игрока по определённому алгоритму. Посмотрите внимательно на рисунок.
Заштрихованные клетки отображают координаты выстрелов компьютера. При такой тактике, компьютер максимум за 24 выстрела попадёт в четырёхпалубный корабль, и это в худшем варианте. В реальности, будут попадания и в другие корабли. Конечно, если игрок знает про такую тактику, то он может расположить все корабли, за исключением четырёх палубного, в клетках, которые не подвергнутся обстрелу.
Давайте усложним задачу и изменим наклон диагоналей, по которым будет вестись стрельба, и сведём полученные координаты в единый массив.
После такого обстрела, добить оставшиеся корабли игрока не составит труда, т. к. необстрелянных клеток, где может располагаться корабль, практически не останется.
Игра «Морской бой». Инициализация ведения морского боя компьютером.
Для формирования и хранения координат выстрела компьютера нам понадобится несколько массивов и объектов, являющимися свойствами класса Controller
. Рассмотрим подробно их название и назначение:
- coordsFixedHit
- Массив с заранее вычисленными координатами выстрелов для реализации оптимальной тактики игры по алгоритму представленному выше.
- coordsRandomHit
- Массив координат будет использован для рандомного обстрела, после того, как будут использованы все координаты из массива
coordsFixedHit
. Изначально в данном массиве содержаться координаты всех клеток игрового поля. При каждом выстреле компьютера, координата выстрела будет удаляться из данного массива. Так же будут удаляться и координаты гарантированно пустых клеток. В результате, после того, как будут использованы все значения массиваcoordsFixedHit
, останутся лишь те координаты, где реально может быть расположен корабль игрока. Вот эти оставшиеся координаты и будут использоваться для следующего выстрела. - coordsAroundHit
- Массив координат клеток, расположенных вокруг попадания. Эти координаты используются для дальнейшего обстрела корабля при попадании. После использования координаты, она удаляется из массивов
coordsFixedHit
иcoordsRandomHit
. - START_POINTS
- Массив базовых координат для формирования массива
coordsFixedHit
. - tempShip
-
Объект, в котором хранятся:
— координаты первого попадания;
— коэффициентыkx
иky
, определяющие направление расположения корабля, вычисляются после второго попадания;
— количество попаданий в обстреливаемый корабль.
Игра «Морской бой». Формирование массивов с координатами выстрела компьютера.
Для заполнения массивов coordsFixedHit
и coordsRandomHit
координатами выстрелов запускается функция setCoordsShot
, являющаяся методом класса Controller
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
init() { // Рандомно выбираем игрока и его противника const random = Field.getRandom(1); this.player = (random == 0) ? human : computer; this.opponent = (this.player === human) ? computer : human; // генерируем координаты выстрелов компьютера и заносим их в // массивы coordsRandomHit и coordsFixedHit this.setCoordsShot(); // обработчики события для игрока if (!isHandlerController) { //выстрел игрока computerfield.addEventListener('click', this.makeShot.bind(this)); // устанавливаем маркер на заведомо пустую клетку computerfield.addEventListener('contextmenu', this.setUselessCell.bind(this)); isHandlerController = true; } if (this.player === human) { compShot = false; this.text = 'Вы стреляете первым'; } else { compShot = true; this.text = 'Первым стреляет компьютер'; // выстрел компьютера setTimeout(() => this.makeShot(), 2000); } Controller.showServiceText(this.text); } |
Функция setCoordsShot
использует в своей работе данные массива START_POINTS
. Прежде чем рассматривать работу данной функции, давайте сначала более подробно разберём содержание массива START_POINTS
.
Массив START_POINTS
содержит в себе всего лишь два элемента, каждый из которых, в свою очередь, так же является массивом. Первый массив содержит координаты начальных точек четырёх диагоналей направленных вправо-вниз. На приведённом ранее рисунке они изображены красным цветом. Второй — координаты начальных точек четырёх диагоналей направленных вправо-вверх. На рисунке они отображены синим цветом.
1 2 3 4 5 6 |
static START_POINTS = [ [ [6,0], [2,0], [0,2], [0,6] ], [ [3,0], [7,0], [9,2], [9,6] ] ]; |
Добавим в класс Controller
функцию setCoordsShot
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
setCoordsShot() { // получаем координаты каждой клетки игрового поля // и записываем их в массив for (let i = 0; i < 10; i++) { for(let j = 0; j < 10; j++) { this.coordsRandomHit.push([i, j]); } } // рандомно перемешиваем массив с координатами this.coordsRandomHit.sort((a, b) => Math.random() - 0.5); let x, y; // получаем координаты для обстрела по диагонали вправо-вниз for (let arr of Controller.START_POINTS[0]) { x = arr[0]; y = arr[1]; while (x <= 9 && y <= 9) { this.coordsFixedHit.push([x, y]); x = (x <= 9) ? x : 9; y = (y <= 9) ? y : 9; x++; y++; } } // получаем координаты для обстрела по диагонали вправо-вверх for (let arr of Controller.START_POINTS[1]) { x = arr[0]; y = arr[1]; while(x >= 0 && x <= 9 && y <= 9) { this.coordsFixedHit.push([x, y]); x = (x >= 0 && x <= 9) ? x : (x < 0) ? 0 : 9; y = (y <= 9) ? y : 9; x--; y++; }; } // изменим порядок следования элементов на обратный, // чтобы обстрел происходил в очерёдности согласно рисунка this.coordsFixedHit = this.coordsFixedHit.reverse(); } |
Игра «Морской бой». Получение координат для выстрела компьютера.
Выстрел компьютера в игре «Морской бой» представляет из себя извлечение значения двумерного массива игрового поля human.matrix
по координатам, взятым из одного из трёх массивов: coordsAroundHit
, coordsFixedHit
или coordsRandomHit
.
Выстрел компьютера начинается с вызова функции makeShot
из функции init
класса Controller
.
1 2 3 |
setTimeout(() => this.makeShot(), 2000); |
Как видите, выстрел происходит с задержкой, которая нужна для того, чтобы игрок успевал оценить обстановку на игровом поле. Особенно это важно, когда компьютер при попаданиях делает несколько выстрелов подряд.
Подробно функция makeShot
была рассмотрена в статье «Игра Морской бой на JavaScript. Выстрел игрока.» Добавим в эту функцию несколько строк, касающихся выстрела компьютера:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
makeShot(e) { let x, y; // если событие существует, значит выстрел сделан игроком if (e !== undefined) { // если клик не левой кнопкой мыши или установлен флаг compShot, // что значит, должен стрелять компьютер if (e.which != 1 || compShot) return; // координаты выстрела в системе координат матрицы ([x, y] = this.transformCoordsInMatrix(e, this.opponent)); // проверяем наличие иконки 'shaded-cell' по полученым координатам const check = this.checkUselessCell([x, y]); if (!check) return; } else { // получаем координаты для выстрела компьютера ([x, y] = this.getCoordsForShot()); } // показываем и удаляем иконку выстрела this.showExplosion(x, y); const v = this.opponent.matrix[x][y]; switch(v) { case 0: // промах this.miss(x, y); break; case 1: // попадание this.hit(x, y); break; case 3: // повторный обстрел case 4: Controller.showServiceText('По этим координатам вы уже стреляли!'); break; } } |
Как видно из кода, координаты очередного выстрела компьютера будем получать с помощью функции getCoordsForShot
. В зависимости от игровой обстановки, координаты берутся в одном из трёх массивов: coordsAroundHit
, coordsFixedHit
или coordsRandomHit
.
В-первую очередь обращаемся к массиву coordsAroundHit
в котором хранятся координаты выстрелов для обстрела клетки с попаданием, получаем его элемент и считываем из него координаты выстрела.
Если попадания ещё не было или координаты, хранившиеся в данном массиве, уже использованы для предыдущих выстрелов, т. е. массив coordsAroundHit
в данный момент пустой, то обращаемся к массиву coordsFixedHit
и получаем элемент с координатами из него.
Если и массив coordsFixedHit
пустой (все диагонали обстреляны), то координаты выстрела берём из массива coordsRandomHit
.
1 2 3 4 5 6 7 8 |
getCoordsForShot() { const coords = (this.coordsAroundHit.length > 0) ? this.coordsAroundHit.pop() : (this.coordsFixedHit.length > 0) ? this.coordsFixedHit.pop() : this.coordsRandomHit.pop(); // удаляем полученные координаты из всех массивов this.removeCoordsFromArrays(coords); return coords; } |
После того, как координаты выстрела были получены, они удаляются из всех массивов, чтобы исключить повторный обстрел. Для этого используется функция removeCoordsFromArrays
.
1 2 3 4 5 6 7 8 9 10 11 |
removeCoordsFromArrays(coords) { if (this.coordsAroundHit.length > 0) { this.coordsAroundHit = Controller.removeElementArray(this.coordsAroundHit, coords); } if (this.coordsFixedHit.length > 0) { this.coordsFixedHit = Controller.removeElementArray(this.coordsFixedHit, coords); } this.coordsRandomHit = Controller.removeElementArray(this.coordsRandomHit, coords); } |
Как видно из приведённого кода, у нас появилась ещё одна новая функция — removeElementArray
, с помощью которой можно удалить элемент массива содержащий определённые координаты. Данная функции имеет два аргумента:
— массив, из которого нужно удалить элемент;
— массив с координатами выстрела (первый элемент массива — координата по оси X, второй — по оси Y.
Для свой работы функция использует встроенный метод filter()
, который возвращает новый массив координат из которого исключены координаты, переданные в качестве аргумента.
Рассмотрим подробно код функции:
1 2 3 4 5 |
static removeElementArray = (arr, [x, y]) => { return arr.filter(item => item[0] != x || item[1] != y); } |
Итак, координаты для выстрела получены. Теперь рассмотрим сам выстрел и оценку его результатов.
Игра «Морской бой». Выстрел компьютера. Попадание.
Ещё раз настоятельно рекомендую ознакомится со статьёй «Игра Морской бой на JavaScript. Выстрел игрока.», в частности, с разделом «Игра «Морской бой». Обработка результатов выстрела.»
Игра «Морской бой». Алгоритм обработки попадания при выстреле компьютера.
Алгоритм обработки попадания, как я писал в предыдущей статье, реализован в функции hit
. JS-код во многом совпадает с JS-кодом обработки попадания в корабль игрока, но есть существенные дополнения, касающиеся ИИ компьютера.
Алгоритм обработка результата попадания в корабль игрока:
-
1
Визуально отображаем попадание и записываем в массив игрового поля игрока
human.matrix
по этим координатам значение4
. Выводим сообщение о попадании. -
2
Перебираем массив эскадры игрока
human.squadron
, в котором храниться информация о каждом корабле. По координатам выстрела определяем корабль в который произошло попадание. -
3
Увеличиваем счётчик попадания
hits
по данному кораблю на 1. -
4
Отмечаем гарантированно пустые клетки и формируем координаты обстрела вокруг попадания.
-
5
Если количество попаданий равно количеству палуб у корабля, то удаляем корабль из объекта эскадры, но перед этим сохраняем координаты первой палубы удаляемого корабля в объект
tempShip
. Эти координаты понадобятся для отметки заведомо пустых клеток по краям корабля. -
6
Проверяем, все ли корабли игрока потоплены. Если эскадра игрока полностью уничтожена, выводим оставшиеся корабли компьютера на экран и заканчиваем игру. В противном случае, переходим к следующему пункту нашего алгоритма.
-
7
Проверяем, остались ли в эскадре игрока корабли, количество палуб которых больше, чем значение счётчика попаданий. Если таких кораблей не найдено, то:
- считаем, что обстреливаемый корабль потоплен;
- помечаем клетки вокруг корабля, как гарантированно пустые;
- сбрасываем значения свойств объекта
tempShip
в исходное состояние; - переходим к следующему выстрелу по игровому полю игрока.
-
8
Если найдены корабли, у которых количество палуб больше, чем количество попаданий, то:
- увеличиваем счётчик попадания
hits
, являющийся свойством объектаtempShip
на 1; - продолжаем уничтожение текущего корабля путём обстрела вокруг палубы, в которую было сделано попадание.
- увеличиваем счётчик попадания
Алгоритм достаточно сложный и вы, возможно, заметили некоторые противоречия между пунктами 5, 6, 7 и 8. Постараюсь объяснить возможную ситуацию на примере.
Допустим, компьютер обстрелял трехпалубный корабль и попал во все палубы. Мы удаляем этот корабль из эскадры, но компьютер этого не знает. Он предполагает, что это может быть четырёхпалубник и проводит проверку — уничтожен четырёхпалубный корабль ранее или нет. Если не уничтожен, то продолжает обстрел. В противном случае, обстрел трёхпалубника прекращается и производится выстрел по новым координатам.
На первом, втором и третьем пунктах приведённого алгоритма я останавливаться не буду, т. к. JS-код используется тот же самый, что и при выстреле игрока. А вот остальные пункты рассмотрим подробно.
Для облегчения восприятия нового JS-кода, я буду объяснять и показывать отдельные блоки кода, относящиеся к тому или иному пункту алгоритма. В конце будет представлен полный код функции
hit
.
Игра «Морской бой». Отмечаем гарантированно пустые клетки вокруг попадания.
После каждого удачного выстрела компьютер определяет координаты клеток, находящихся по диагонали от клетки с попаданием. По условиям игры в них точно отсутствуют корабли игрока. Таких клеток может быть от одной до четырёх, в зависимости от координат попадания — в центре, на краю или в углу игрового поля.
Полученные координаты клеток передаются в качестве аргумента в функцию markUselessCell
, которая выполняет следующие задачи:
- Перебирает полученный объект с координатами и удаляет из него координаты по которым уже установлены ранее отметки пустых клеток, попаданий или промахов, а так же, если эти координаты оказываются за пределами игрового поля игрока.
- Записывает по этим координатам в двумерный массив игрового поля игрока значение ‘2’, что соответствует отметке пустой клетки.
- Вызывает функцию
showIcons
, для визуального отображения гарантированно пустых клеток на игровом поле игрока. - Используя функцию
removeCoordsFromArrays
, удаляет эти координаты из массивовcoordsAroundHit
,coordsFixedHit
иcoordsRandomHit
, чтобы исключить в дальнейшем стрельбу по этим координатам.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
// все корабли эскадры уничтожены if (Object.keys(this.opponent.squadron).length == 0) { if (this.opponent === human) { ... } else { text = 'Поздравляем! Вы выиграли!'; } Controller.showServiceText(text); // показываем кнопку продолжения игры buttonNewGame.hidden = false; // бой продолжается } else if (this.opponent === human) { let coords; this.tempShip.hits++; // отмечаем клетки по диагонали, где точно не может стоять корабль coords = [ [x - 1, y - 1], [x - 1, y + 1], [x + 1, y - 1], [x + 1, y + 1] ]; // проверяем и отмечаем полученные координаты клеток this.markUselessCell(coords); } |
Ниже представлен полный JS-код функции markUselessCell
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
markUselessCell(coords) { let n = 1, x, y; for (let coord of coords) { x = coord[0]; y = coord[1]; // координаты за пределами игрового поля if (x < 0 || x > 9 || y < 0 || y > 9) continue; // по этим координатам в матрице уже прописан промах или маркер пустой клетки if (human.matrix[x][y] == 2 || human.matrix[x][y] == 3) continue; // прописываем значение, соответствующее маркеру пустой клетки human.matrix[x][y] = 2; // вывоим маркеры пустых клеток по полученным координатам // для того, чтобы маркеры выводились поочерёдно, при каждой итерации // увеличиваем задержку перед выводом маркера setTimeout(() => this.showIcons(human, coord, 'shaded-cell'), 350 * n); // удаляем полученные координаты из всех массивов this.removeCoordsFromArrays(coord); n++; } } |
Обратите внимание на строку:
1 2 3 |
setTimeout(() => this.showIcons(human, coord, 'shaded-cell'), 350 * n); |
Для того, чтобы маркеры выводились поочерёдно, при каждой итерации цикла необходимо увеличивать задержку перед показом маркера.
Игра «Морской бой». Обстрел игрового поля вокруг попадания.
После попадания в корабль игрока, компьютеру необходимо продолжить его обстрел до полного уничтожения. Логично предположить, что следующая палуба будет находиться в соседней клетке. После первого попадания ещё не понятно, как расположен корабль — горизонтально или вертикально. Поэтому необходимо обстрелять клетку с попаданием со всех сторон. Количество координат обстрела может варьироваться от двух до четырёх, в зависимости от координат попадания.
После второго попадания компьютер опять устанавливает маркеры гарантированно пустых клеток по диагонали от попадания. При этом удаляется часть координат возможного обстрела вокруг предыдущего попадания.
Кроме этого, мы можем определить направление расположения корабля и установить значения коэффициентов kx
и ky
. Данная информация понадобится при установке маркеров пустых клеток после полного уничтожения корабля игрока.
Для вычисления направления расположения корабля и координат обстрела вокруг попадания используем функцию setCoordsAroundHit
. Аргументами этой функции будут координаты попадания и объект с координатами выстрелов вокруг попадания.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
// все корабли эскадры уничтожены if (Object.keys(this.opponent.squadron).length == 0) { if (this.opponent === human) { ... } else { text = 'Поздравляем! Вы выиграли!'; } Controller.showServiceText(text); // показываем кнопку продолжения игры buttonNewGame.hidden = false; // бой продолжается } else if (this.opponent === human) { let coords; this.tempShip.hits++; // отмечаем клетки по диагонали, где точно не может стоять корабль coords = [ [x - 1, y - 1], [x - 1, y + 1], [x + 1, y - 1], [x + 1, y + 1] ]; // проверяем и отмечаем полученные координаты клеток this.markUselessCell(coords); // формируем координаты обстрела вокруг попадания coords = [ [x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1] ]; this.setCoordsAroundHit(x, y, coords); } |
В своей работе функция setCoordsAroundHit
использует данные, хранящиеся в объекте tempShip
: массив с координатами первого попадания firstHit
и коэффициенты kx
и ky
. В исходном состоянии массив пустой, а kx и ky имеют значение 0.
Ещё раз напомню значение комбинаций коэффициентов:
tempShip.kx == 1 && tempShip.ky == 0
— корабль расположен вертикально;tempShip.kx == 0 && tempShip.ky == 1
— корабль расположен горизонтально.
Функция setCoordsAroundHit
выполняет следующие задачи:
- Запоминает координаты первого попадания.
- После второго попадания определяет направление расположения корабля.
- Перебирает объект с координатами обстрела, при этом:
— удаляет координаты находящиеся за пределами игрового поля;
— проверяет значение матрицы по оставшимся координатам, значение должно быть равно 0 (пустое место) или 1 (палуба корабля). - Оставшиеся валидные координаты добавляет в массив
coordsAroundHit
.
Игра «Морской бой». Сохранение координаты первого попадания и определение направления расположения корабля.
Теперь можно приступить к написанию функции setCoordsAroundHit
и первое, что мы сделаем — напишем условия, при которых определяется первое и второе попадание. Для этого обратимся к свойствам объекта tempShip
.
Если массив tempShip.firstHit
пустой, в нём нет никаких координат, значит это первое попадание в корабль. Если в массив записаны координаты, но коэффициенты kx и ky равны 0, значит это второе попадание и можно вычислить направление расположения корабля. Данная информация понадобится при установке маркеров пустых клеток после полного уничтожения корабля игрока.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
setCoordsAroundHit(x, y) { let {firstHit, kx, ky} = this.tempShip; // массив пустой, значит это первое попадание в данный корабль if (firstHit.length == 0) { this.tempShip.firstHit = [x, y]; // второе попадание, т.к. оба коэффициента равны 0 } else if (kx == 0 && ky == 0) { // зная координаты первого и второго попадания, // можно вычислить направление расположение корабля this.tempShip.kx = (Math.abs(firstHit[0] - x) == 1) ? 1 : 0; this.tempShip.ky = (Math.abs(firstHit[1] - y) == 1) ? 1 : 0; } } |
В условии коэффициенты kx и ky сравниваются с 0, а не с 1. Это сделано для универсальности вычислений как для первого попадания, так и для последующих, т. к. при первом попадании оба коэффициента равны 0.
Игра «Морской бой». Валидация координат обстрела вокруг попадания.
Валидация координат обстрела проводится в два этапа. Первоначально проверяем, что координата не выходит за пределы игрового поля. Далее, проверяем значение матрицы по оставшимся координатам обстрела. Значения должны быть равны 0 (пустое место) или 1 (палуба корабля).
Оставшиеся после проверки координаты помещаем в массив coordsAroundHit
.
Добавим в функцию setCoordsAroundHit
JS-код, проверяющий полученные координаты обстрела вокруг попадания:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
setCoordsAroundHit(x, y) { let {firstHit, kx, ky} = this.tempShip; // массив пустой, значит это первое попадание в данный корабль if (firstHit.length == 0) { this.tempShip.firstHit = [x, y]; // второе попадание, т.к. оба коэффициента равны 0 } else if (kx == 0 && ky == 0) { // зная координаты первого и второго попадания, // можно вычислить направление расположение корабля this.tempShip.kx = (Math.abs(firstHit[0] - x) == 1) ? 1 : 0; this.tempShip.ky = (Math.abs(firstHit[1] - y) == 1) ? 1 : 0; } // проверяем корректность полученных координат обстрела for (let coord of coords) { x = coord[0]; y = coord[1]; // координаты за пределами игрового поля if (x < 0 || x > 9 || y < 0 || y > 9) continue; // по данным координатам установлен промах или маркер пустой клетки if (human.matrix[x][y] != 0 && human.matrix[x][y] != 1) continue; // валидные координаты добавляем в массив this.coordsAroundHit.push([x, y]); } } |
Игра «Морской бой». Проверяем, потоплен ли корабль.
На первый взгляд, реализация этой проверки не должна вызвать трудностей. Достаточно сравнить в массиве human.squadron
значения свойств decks
(количество палуб) и hits
(количество попаданий) обстреливаемого корабля. Если эти значения равны, то корабль считается потопленным. К сожалению, при таком подходе мы поставим игрока в заведомо неравное положение, ведь он обстреливает корабль до тех пор, пока явно не будет видно, что корабль потоплен. Откажемся от этого способа и научим компьютер самостоятельно определять необходимость дальнейшего обстрела раненого корабля — потоплен корабль или нет.
Определить, потоплен корабль или нет достаточно просто. Для этого необходимо проверить длину массива coordsAroundHit
. Если массив пустой, т. е. обстреляны все возможные координаты вокруг попаданий, значит корабль уничтожен.
В противном случае, необходимо в эскадре игрока найти среди оставшихся кораблей, корабль с максимальным количеством палуб и сравнить это количество с количеством попаданий tempShip.hits
. Если максимальное количество палуб меньше или равно количеству попаданий, то считаем, что корабль потоплен.
В зависимости от результата, после некоторой задержки, компьютер производит новый выстрел, продолжая обстреливать корабль или открывает стрельбу по новым координатам.
Данный алгоритм реализован в функции isShipSunk
.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
// все корабли эскадры уничтожены if (Object.keys(this.opponent.squadron).length == 0) { if (this.opponent === human) { ... } else { text = 'Поздравляем! Вы выиграли!'; } Controller.showServiceText(text); // показываем кнопку продолжения игры buttonNewGame.hidden = false; // бой продолжается } else if (this.opponent === human) { let coords; this.tempShip.hits++; // отмечаем клетки по диагонали, где точно не может стоять корабль coords = [ [x - 1, y - 1], [x - 1, y + 1], [x + 1, y - 1], [x + 1, y + 1] ]; // проверяем и отмечаем полученные координаты клеток this.markUselessCell(coords); // формируем координаты обстрела вокруг попадания coords = [ [x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1] ]; this.setCoordsAroundHit(x, y, coords); // проверяем, потоплен ли корабль, в который было попадание this.isShipSunk(); // после небольшой задержки, компьютер делает новый выстрел setTimeout(() => this.makeShot(), 2000); } |
Код функции isShipSunk
с комментариями:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
isShipSunk() { // max кол-во палуб у оставшихся кораблей let obj = Object.values(human.squadron) .reduce((a, b) => a.arrDecks.length > b.arrDecks.length ? a : b); // определяем, есть ли ещё корабли, с кол-вом палуб больше, чем попаданий if (this.tempShip.hits >= obj.arrDecks.length || this.coordsAroundHit.length == 0) { // корабль потоплен, отмечаем useless cell вокруг него this.markUselessCellAroundShip(); // очищаем массив coordsAroundHit и объект resetTempShip для // обстрела следующего корабля this.coordsAroundHit = []; this.resetTempShip(); } } |
Как видно из кода, корабль считается потопленным, если количество попаданий в него больше или равно максимальному количеству палуб одно из оставшихся в эскадре кораблей или отсутствуют координаты обстрела. В этом случае нужно сделать следующее:
- установить вокруг корабля недостающие маркеры заведомо пустых клеток;
- очистить массив
coordsAroundHit
; - сбросить в исходное состояние объект
tempShip
.
Для реализации выше перечисленного, будем использовать две функции markUselessCellAroundShip()
и resetTempShip()
.
Функция resetTempShip()
рассматривалась в статье Редактирование положения корабля и начало игры..
Игра «Морской бой». Устанавливаем маркеры после уничтожения корабля.
Количество и положение клеток, которые необходимо пометить, зависит от количества палуб у потопленного корабля.
Как видно из приведённого рисунка, для однопалубного корабля, помечаются максимум четыре клетки, расположенные с четырёх сторон.
Рассчитать координаты пустых клеток вокруг такого корабля не сложно:
1 2 3 4 5 6 7 8 9 10 11 12 |
coords = [ // верхняя [x0 - 1, y0], // нижняя [x0 + 1, y0], // левая [x0, y0 - 1], // правая [x0, y0 + 1] ]; |
Для многопалубных кораблей требуются максимум две клетки, расположенные с торцов.
Для расчёта координат потребуются данные объекта tempShip
:
- количество попаданий hits
- в данном случае это значение равно количеству палуб потопленного корабля.
- коэффициенты kx и ky
- определяют, с какой стороны корабля устанавливать маркеры пустых клеток.
Рассчёт координат пустых клеток для многопалубного корабля будет выглядеть так:
1 2 3 4 5 6 7 8 |
coords = [ // левая / верхняя [x0 - kx, y0 - ky], // правая / нижняя [x0 + kx * hits, y0 + ky * hits] ]; |
После получения объекта coords
, вызываем функцию markUselessCell
, которая устанавливает маркеры пустых клеток на игровом поле на основе полученных координат.
Полный код функции markUselessCellAroundShip
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
markUselessCellAroundShip(){ // присваиваем переменным соответствующие значения из объекта tempShip const {hits, kx, ky, x0, y0} = this.tempShip; let coords; // рассчитываем координаты пустых клеток // однопалубный корабль if (this.tempShip.hits == 1) { coords = [ // верхняя [x0 - 1, y0], // нижняя [x0 + 1, y0], // левая [x0, y0 - 1], // правая [x0, y0 + 1] ]; // многопалубный корабль } else { coords = [ // левая / верхняя [x0 - kx, y0 - ky], // правая / нижняя [x0 + kx * hits, y0 + ky * hits] ]; } this.markUselessCell(coords); } |
Игра «Морской бой». Уничтожение эскадры противника и окончание игры.
Эскадра противника считается уничтоженной, если длина массива Object.keys(this.opponent.squadron)
равна 0. При этом, если победил игрок, то ему выводится поздравление с победой. В противном случае, выводится сообщение о проигрыше, а на игровом поле компьютера показываются оставшиеся корабли его эскадры.
Для вывода оставшихся кораблей, необходимо перебрать объект эскадры компьютера, получить данные по каждому оставшемуся кораблю и вызвать функцию showShip()
, относящуюся к классу Ships
. В качестве аргументов функции необходимо передать полученные данные.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
// все корабли эскадры уничтожены if (Object.keys(this.opponent.squadron).length == 0) { if (this.opponent === human) { text = 'К сожалению, вы проиграли.'; // показываем оставшиеся корабли компьютера for (let name in computer.squadron) { const dataShip = computer.squadron[name]; Ships.showShip(computer, name, dataShip.x, dataShip.y, dataShip.kx ); } } else { text = 'Поздравляем! Вы выиграли!'; } Controller.showServiceText(text); // показываем кнопку продолжения игры buttonNewGame.hidden = false; // бой продолжается } else if (this.opponent === human) { let coords; this.tempShip.hits++; // отмечаем клетки по диагонали, где точно не может стоять корабль coords = [ [x - 1, y - 1], [x - 1, y + 1], [x + 1, y - 1], [x + 1, y + 1] ]; // проверяем и отмечаем полученные координаты клеток this.markUselessCell(coords); // формируем координаты обстрела вокруг попадания coords = [ [x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1] ]; this.setCoordsAroundHit(x, y, coords); // проверяем, потоплен ли корабль, в который было попадание this.isShipSunk(); // после небольшой задержки, компьютер делает новый выстрел setTimeout(() => this.makeShot(), 2000); } |
На данный момент мы рассмотрели весь алгоритм работы программы при попадании в корабль игрока. Теперь можно представить полный код функции hit
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
hit(x, y) { let text = ''; // устанавливаем иконку попадания и записываем попадание в матрицу this.showIcons(this.opponent, [x, y], 'red-cross'); this.opponent.matrix[x][y] = 4; // выводим текст, зависящий от стреляющего text = (this.player === human) ? 'Поздравляем! Вы попали. Ваш выстрел.' : 'Компьютер попал в ваш корабль. Выстрел компьютера'; setTimeout(() => Controller.showServiceText(text), 400); // перебираем корабли эскадры противника outerloop: for (let name in this.opponent.squadron) { const dataShip = this.opponent.squadron[name]; for (let value of dataShip.arrDecks) { // перебираем координаты палуб и сравниваем с координатами попадания // если координаты не совпадают, переходим к следующей итерации if (value[0] != x || value[1] != y) continue; dataShip.hits++; if (dataShip.hits < dataShip.arrDecks.length) break outerloop; // код для выстрела компьютера: сохраняем координаты первой палубы if (this.opponent === human) { this.tempShip.x0 = dataShip.x; this.tempShip.y0 = dataShip.y; } // если количество попаданий в корабль равно количеству палуб, // удаляем данный корабль из массива эскадры delete this.opponent.squadron[name]; break outerloop; } } // все корабли эскадры уничтожены if (Object.keys(this.opponent.squadron).length == 0) { if (this.opponent === human) { text = 'К сожалению, вы проиграли.'; // показываем оставшиеся корабли компьютера for (let name in computer.squadron) { const dataShip = computer.squadron[name]; Ships.showShip(computer, name, dataShip.x, dataShip.y, dataShip.kx ); } } else { text = 'Поздравляем! Вы выиграли!'; } Controller.showServiceText(text); // показываем кнопку продолжения игры buttonNewGame.hidden = false; // бой продолжается } else if (this.opponent === human) { let coords; this.tempShip.hits++; // отмечаем клетки по диагонали, где точно не может стоять корабль coords = [ [x - 1, y - 1], [x - 1, y + 1], [x + 1, y - 1], [x + 1, y + 1] ]; this.markUselessCell(coords); // формируем координаты обстрела вокруг попадания coords = [ [x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1] ]; this.setCoordsAroundHit(x, y, coords); // проверяем, потоплен ли корабль, в который было попадание this.isShipSunk(); // после небольшой задержки, компьютер делает новый выстрел setTimeout(() => this.makeShot(), 2000); } } |
Игра «Морской бой». Выстрел компьютера. Промах.
Напомню, что промахом считается выстрел, по координатам которого в матрице игрового поля противника прописано значение 0, т. е. пустая клетка:
1 2 3 |
this.opponent.matrix[x][y] == 0; |
JS-код для обработки промаха является практически общим, как для игрока, так и для компьютера. Единственное, что я сделаю — добавлю в функцию miss
несколько строчек кода, которые ниже рассмотрим подробнее:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
miss(x, y) { let text = ''; // устанавливаем иконку промаха и записываем промах в матрицу this.showIcons(this.opponent, [x, y], 'dot'); this.opponent.matrix[x][y] = 3; // определяем статус игроков if (this.player === human) { text = 'Вы промахнулись. Стреляет компьютер.'; this.player = computer; this.opponent = human; compShot = true; setTimeout(() => this.makeShot(), 2000); } else { text = 'Компьютер промахнулся. Ваш выстрел.'; // обстреляны все возможные клетки для данного корабля if (this.coordsAroundHit.length == 0 && this.tempShip.hits > 0) { // корабль потоплен, отмечаем useless cell вокруг него this.markUselessCellAroundShip(); this.resetTempShip(); } this.player = human; this.opponent = computer; compShot = false; } setTimeout(() => Controller.showServiceText(text), 400); } |
Проверяется следующее условие: массив с координатами клеток вокруг попадания пустой, при этом в свойстве hits
объекта tempShip
содержится некое значение отличное от нуля (количество попаданий). Это значит, что компьютер обстреливал раненый корабль и все координаты, по которым могли находиться палубы корабля обстреляны. Другими словами — корабль игрока потоплен. Теперь необходимо сделать следующее:
- отметить маркерами все клетки вокруг корабля, как заведомо пустые исходя из правил игры;
- обнулить свойства объекта
tempShip
— они нам больше не нужны, т. к. корабль противника уничтожен.
Игра «Морской бой». Запуск новой игры.
Перезапуск игры начинается с нажатия на кнопку «Продолжить», которая становится доступной после полного уничтожения эскадры кого-либо из игроков.
1 2 3 |
buttonNewGame.hidden = false; |
При этом, игру необходимо вернуть в исходное состояние, как при первоначальной загрузке:
- Скрываем кнопку перезапуска игры и игровое поле компьютера.
- Показываем управляющие элементы выбора способа расстановки кораблей.
- Очищаем поле игрока от иконок попаданий, промахов и маркеров пустых клеток.
- Устанавливаем флаги начала игры и выстрела компьютера в исходное состояние.
- Очистить объекты эскадры от всех значений.
- Обнуляем массивы с координатами выстрела.
- Сбрасываем значения временного объекта корабля.
К кнопке перезапуска игры привязан обработчик события, который содержит функционал, реализующий перечисленный выше алгоритм. Рассмотрим его JS-код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
buttonNewGame.addEventListener('click', function(e) { // скрываем кнопку перезапуска игры buttonNewGame.hidden = true; // скрываем игровое поле компьютера computerfield.parentElement.hidden = true; // показываем управляющие элементы выбора способа // расстановки кораблей instruction.hidden = false; // очищаем поле игрока human.cleanField(); toptext.innerHTML = 'Расстановка кораблей'; Controller.SERVICE_TEXT.innerHTML = ''; // устанавливаем флаги в исходное состояние startGame = false; compShot = false; // обнуляем массивы с координатами выстрела control.coordsRandomHit = []; control.coordsFixedHit = []; control.coordsAroundHit = []; // сбрасываем значения объекта tempShip control.resetTempShip(); }); |
Заключение.
В пяти статьях, посвящённых игре «Морской бой», я постарался подробно и доступно рассказать, как написать эту популярную игру на чистом JavaScript. Я не претендую на то, что написанный мною код идеален и полностью оптимизирован. Возможно вы сможете предложить свои варианты каких-либо участков кода, другие пути решения. С удовольствием обсудим их в комментариях.
Комментарии
-
Комментарии должны содержать вопросы и дополнения по статье, ответы на вопросы других пользователей.
Комментарии содержащие обсуждение политики, будут безжалостно удаляться. -
Для удобства чтения Вашего кода, не забываейте его форматировать. Вы его можете подсветить код с помощью тега
<pre>
:
—<pre class="lang:xhtml">
- HTML;
—<pre class="lang:css">
- CSS;
—<pre class="lang:javascript">
- JavaScript. - Если что-то не понятно в статье, постарайтесь указать более конкретно, что именно не понятно.
-
-
Спасибо, поправил.
-
-
В это игре есть один существенный баг. Если в расположить коробли близко друг к други и попытаться повернуть один корабль так, чтобы он попал в зону нахождения второго корабля, то корабль изчезает. Можно начать игру и затащить на изи. Но корабли перед использование этой уязвимости должны ВСЕ находиться на поле.
-
Ошибку исправил. Это не баг логики, а косяк при работе с редактором. В скрипте примера, в функции Instance.prototype.rotationShip, случайно были поменяны местами несколько строк кода.
Код приведённый в статье в разделе «Поворот корабля на 90°» — верный, там править ничего не надо.
Спасибо за указанную ошибку.
-
-
Статьи пока не читал, добавил в закладки, почитаю на свежую голову.
Поиграл немного, нашел много разных багов:
1. Корабли можно перетаскивать даже во время боя.
2. Хотелось бы, чтобы убитые корабли автоматически окружались промахами по периметру.
3. Отсюда вытекает следующий пункт: никогда не знаешь точно убит ли корабль полностью. Соперник добивает корабль и следующий выстрел всегда делает в следующую клетку, как если бы там была еще одна палуба.-
По первому пункту, действительно, баг присутствует — разберусь. А по п.п. 2 и 3 — так и задумывалось.
-
Так это же идёт вразрез с правилами игры. Когда корабль убит полностью сопернику сообщают об этом, и он не должен делать бессмысленный выстрел рядом с полностью убитым кораблём
-
-
-
Еще бывает так, что в ячейке стоит промах, а на самом деле там палуба корабля
-
Вы не правы на счет того, что компьютер с рандомным вариантом стрельбы без тактики не соперник для человека, поиграйте в мою версию игры, там 5 уровней сложности компьютера и на всех пяти уровнях компьютер стреляет рандомно, но я вас уверяю, победить там задача не из легких. Вот скриншот ( https://drive.google.com/file/d/1q12eQyAXhttDgoJa5LWuDx6sSmYW00jY/view?usp=sharing ), счет в его пользу 25:71.
Плюс, по Вашей игре есть замечания, убитый корабль должен как-то обозначаться, и компьютер не должен обстреливать уже потопленный корабль, это противоречит правилам игры.
Добавьте больше интерактива в игру, звуки, кнопки, возможность начать игру заново после раунда.
Вот ссылка на мою версию игры (не работает на Internet Explorer и Microsoft Eagle):
https://eugene-kul.github.io/seaBattle/index.html-
Процитируйте, плиз, где написано «компьютер с рандомным вариантом стрельбы без тактики не соперник для человека». А вообще, по тактике игры в Морской бой есть множество статей с математическими вкладками по теории вероятности, картинками и т.д., где доказывается преимущество тактики перед рандомом.
Уничтоженный корабль видно по попаданиям, а повторных обстрелов быть не может, т.к. из массива возможных выстрелов удаляются координаты, по которым стреляли. Хотя может есть какие-то не выявленные баги, на 100% не буду утверждать.
В игру можно добавить много чего, но тогда статья удлинится в несколько раз и, вряд ли кто-то будет читать её до конца. Я не ставлю перед собой целью распространить (навязать) готовый плагин, скрипт, программу и т.д. Смысл этого блога показать направления и пути решения часто возникающих задач. А окончательное «вылизование» — это на усмотрение читателей. Возможно мои решения не всегда оптимальны и реализованы не лучшим способом, для этого есть блок комментариев — давайте это обсуждать.
А так ваш коммент больше похож на пиар вашей программы.-
Цитата из пункта Введение:
«Для того, чтобы компьютер мог, как минимум, играть на равных с человеком, он (компьютер) должен иметь некую тактику игры. Причём, данная тактика должна быть оптимальна применительно к игре «Морской бой» и обеспечивать высокие шансы на победу над человеком.
Исходя из этого, сразу же откажемся от рандомного обстрела поля игрока в начальной стадии игры. В рандомном формировании координат выстрела компьютера в полной мере присутствует элемент случайности, а нам, для победы компьютера над человеком, нужно свести этот элемент к минимуму.»
Поэтому я Вам показал свой вариант игры, где реализована тактика игры компьютера на уровне человека с рандомным обстрелом поля. Я свое приложение не пиарю, это первое что я сделал на javaScript, и я знаю, что там в коде миллион ошибок. Я только учусь.
По поводу обстрела убитых кораблей компьютером, ссылка на запись: ( https://drive.google.com/file/d/1UPLGqovxXbvAswLvOuAjMnqPBlD6A_Ei/view?usp=sharing ). И я не вижу никаких выделений уничтоженных кораблей и я не могу понять, убил я корабль или нет, и начинаю бессмысленно простреливать вокруг потопленного корабля. Чисто мое мнение, если ты начал что-то делать, то это что-то должно быть похоже на законченную вещь. Я не критикую Вашу игру, Вы молодец, что все это делаете, я просто опровергаю слова сказанные в статье, вот и все, не стоит на меня обижаться)-
Обид никаких нет, для того комменты и прикручены, чтобы можно было обсуждать, указывать на ошибки и предлагать свои варианты реализации.
По поводу обстрела корабля вокруг — именно так я планировал, так сложнее код в выстреле компьютера. Кстати, компьютер тоже обстреливает все возможные клетки рядом с кораблём, так что игрок и комп в этом случае находятся в равном положении.
А с косяком буду разбираться, всё-равно хотел переписать на ES6
-
-
-
-
Только учусь фронтэнд разработке, в 28 лет пришло понимание, что занимаюсь не тем! Сейчас прохожу курсы, верстку осилил, приступил к изучению java-scripta. Отличный у Вас сайт!!! Приятно здесь находиться, очень много полезного! Создать игру такую как морской бой для меня сейчас просто фантастическая задача но статейки читаю!) Исключительно с точки зрения юзера по нраву больше игра Вальдемара, оформление такое тетрадное как в детстве. Я все детство играл в эту игру в таком варианте, что не нужно было говорить — тебя ранили или убили. Просто попал! Ваш вариант очень близок! Конечно не хватает, аудио. В игре Евгения большой минус как по мне это невозможность расставить корабли самостоятельно! А вообще вы виртуозы, это очень круто!!!! Надеюсь доросту до такого…
-
Было бы неплохо если бы не только код в архиве был переписан по новым стандартам, но и само обучение)Нет в планах пункта улучшить сам текст обучалки?)
-
Статьи с описанием создания игры в стандарте ES6+ практически готовы. Осталось дописать последнюю — «Выстрел компьютера». Если есть острая необходимость, то могу выложить первые четыре статьи и новый архив. В процессе переписывания статей в код вносились изменения связанные с оптимизацией.
-
Спасибо за ответ и за труды,очень жду обновление этой интереснейшей статьи.Если не затруднит,то можете выкладывать первые части.Я думаю,что это разумно)
Пока люди проработают первые пару частей у Вас будет время дописать оставшуюся.
-
-
-
Еще раз доброго времени суток)
Все ждемс обновленную игру детства.Как там успехи?Скоро ли увидим обновление?)-
Done
-
В статье присутствует ошибка синтаксиса в коде, в функции getCoordinatesShot (восьмая строка в вашем блоке кода). Поправьте