DevGang
Авторизоваться

Учебное пособие по разработке игр на JavaScript — создание Gorillas с помощью HTML Canvas + JavaScript

В этом уроке по игре на JavaScript вы узнаете, как создать современную версию классической игры Gorillas 1991 года, используя простой JavaScript и элемент холста HTML.

В этой игре две гориллы бросают друг в друга взрывные бананы, и побеждает тот, кто первым ударит другого.

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

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

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

В этой статье мы упростим пару шагов. Для более подробной информации вы также можете посмотреть расширенное руководство на YouTube. В версии для YouTube мы также рассказываем, как сделать здания разрушаемыми, как анимировать руку гориллы, чтобы она следовала за движением перетаскивания во время прицеливания, сделать более приятную графику и добавить логику искусственного интеллекта, чтобы вы могли играть против компьютера.

Если вы застряли, вы также можете найти окончательный исходный код игры, которую мы собираемся создать, на GitHub.

Предыстория игры

Gorillas — игра 1991 года. В этой игре две гориллы стоят на вершинах случайно сгенерированных зданий и по очереди бросают друг в друга взрывные бананы.

В каждом раунде игроки устанавливают угол и скорость броска и продолжают его совершенствовать, пока не попадут в другую гориллу. На летающую банановую бомбу действует гравитация и ветер.

Оригинальная игра Gorillas 1991 года (источник: Retrogames.cz)
Оригинальная игра Gorillas 1991 года (источник: Retrogames.cz)

Мы собираемся реализовать современную версию этой игры. Оригинальная игра не поддерживала мышь. Каждый раунд игрокам приходилось вводить угол и скорость с помощью клавиатуры. Мы собираемся реализовать его с поддержкой мыши и более красивой графикой.

Вы можете опробовать расширенную версию игры на CodePen. Попробуйте, прежде чем мы углубимся в это.

Настройка проекта

Для реализации этой игры нам понадобится простой файл HTML, CSS и JavaScript. Если хотите, вы можете разбить логику JavaScript на несколько файлов, но для простоты мы собрали все в одном месте.

Поскольку мы используем простой JavaScript и не используем никаких библиотек и сторонних инструментов, нам не нужны никакие компиляторы или сборщики. Мы можем запускать все прямо в браузере.

Чтобы упростить процесс, я рекомендую установить расширение Live Server VS Code. Установив это расширение, вы можете просто щелкнуть правой кнопкой мыши HTML-файл и выбрать «Open with Live Server’». Это запустит живую версию игры в браузере.

Это означает, что нам не нужно нажимать кнопку «Обновить» в браузере каждый раз, когда мы вносим изменения в код. Достаточно сохранить изменения в файле и браузер обновится автоматически.

Запуск игры с расширением Live Server VS Code
Запуск игры с расширением Live Server VS Code

Обзор игровой логики

Прежде чем мы перейдем к деталям, давайте пройдемся по основным частям игры.

Игра движима объектом state. Это объект JavaScript, который служит метаданными для игры. От размера зданий до текущего положения бомбы — оно включает в себя множество вещей.

Состояние игры включает в себя такие вещи, как чья сейчас очередь, целимся ли мы сейчас или бомба уже летит в воздухе? И так далее. Это все переменные, которые нам нужно отслеживать. Когда мы рисуем игровую сцену, мы рисуем ее на основе состояния игры.

Тогда у нас есть draw функция. Эта функция будет рисовать практически все, что есть на экране. Он рисует фон, здания, горилл и банановые бомбы. Эта функция рисует весь экран сверху вниз каждый раз, когда мы ее вызываем.

Мы добавим обработку событий для прицеливания бомб и реализуем функцию throwBomb, запускающую цикл анимации. Функция animate перемещает бомбы по небу.

Эта функция будет отвечать за расчет точного положения бомбы, когда она летит по воздуху в каждом цикле анимации. Кроме того, ему также необходимо выяснить, когда движение закончится. При каждом движении мы проверяем, не попали ли мы в здание или врага, не попала ли бомба за пределы экрана. Мы также добавим обнаружение попаданий.

Теперь давайте пройдемся по нашим исходным файлам.

Исходный HTML-файл

Наш первоначальный HTML-файл будет очень простым. В заголовок мы добавим ссылку на нашу таблицу стилей и наш файл JavaScript. Обратите внимание, что я использую ключевое слово defer, чтобы гарантировать, что сценарий будет выполнен только после анализа остальной части документа.

В тело мы добавим элемент canvas. Мы собираемся рисовать этот элемент с помощью JavaScript. Почти все, что мы видим на экране, будет находиться в этом элементе холста. Вот код:

Исходный index.html файл
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Gorillas</title>
    <link rel="stylesheet" href="index.css" />
    <script src="index.js" defer></script>
  </head>
  <body>
    <canvas id="game"></canvas>
  </body>
</html>

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

Исходный CSS-файл

Изначально наш CSS тоже очень простой. Мы не можем ничего стилизовать внутри элемента холста, поэтому здесь мы стилизуем только другие имеющиеся у нас элементы.

Исходный index.css файл
body {
  margin: 0;
  padding: 0;
}

‌Позже, когда мы добавим больше элементов в HTML, мы также обновим этот файл. А пока давайте убедимся, что наш холст может поместиться на весь экран. По умолчанию браузеры имеют тенденцию добавлять небольшие поля или отступы вокруг тела. Давайте удалим это.

Основные части нашего файла JavaScript

Большая часть логики будет в нашем файле JavaScript. Давайте пройдемся по основным частям этого файла и определим несколько функций-заполнителей:

  • Мы объявляем игровой объект state и кучу служебных переменных. Он будет содержать метаданные нашей игры. На данный момент это пустой объект. Мы инициализируем его значение, когда доберемся до функции newGame.
  • Затем у нас есть ссылки на каждый элемент HTML, к которому нам нужен доступ из JavaScript. На данный момент у нас есть только ссылка на элемент <canvas>. Мы получаем доступ к этому элементу по идентификатору.
  • Мы инициализируем состояние игры и рисуем сцену, вызывая функцию newGame. Это единственный вызов функции верхнего уровня. Эта функция отвечает как за инициализацию игры, так и за ее сброс.
  • Мы определяем функцию draw, которая рисует всю сцену на элементе холста в зависимости от состояния игры. Нарисуем фон, здания, горилл и бомбу.
  • Мы настраиваем обработчики событий для событий mousedownmousemove и mouseup. Мы собираемся использовать их для прицеливания.
  • Событие mouseup запустит throwBomb функцию, которая запускает основной цикл анимации. Функция animate будет манипулировать состоянием в каждом цикле анимации и вызывать draw функцию для обновления экрана.
Исходный index.js файл
// The state of the game
let state = {};
// ...

// References to HTML elements
const canvas = document.getElementById("game");
// ...

newGame();

function newGame() {
  // Initialize game state
  state = {
    // ...
  };

  // ...

  draw();
}

function draw() {
  // ...
}

// Event handlers
// ...

function throwBomb() {
  // ...
}

function animate(timestamp) {
  // ...
}

‌У нас будет еще пара полезных функций, но они менее важны. Мы обсудим их по ходу дела.

Фазы игры

На следующем этапе мы настроим нашу начальную игру state. Прежде чем мы перейдем к различным частям state, давайте поговорим об одном из наиболее важных его свойств: игровом свойстве phase.

aiming фаза
aiming фаза
in flight фаза&nbsp;&nbsp;
in flight фаза  
celebrating фаза&nbsp;&nbsp;
celebrating фаза  

В игре есть три разных этапа. Игра начинается на этапе aiming, когда бомба находится в руке гориллы и обработчики событий активны. ‌‌‌‌Затем, как только вы бросите бомбу, игра перейдет в in flight фазу. На этом этапе обработчики событий деактивируются, и функция animate перемещает бомбу по небу. Мы также добавляем обнаружение попаданий, чтобы знать, когда следует остановить анимацию.

Эти две игровые фазы повторяют друг друга снова и снова, пока одна из горилл не столкнется с другой. Как только мы поражаем врага, игра переходит в фазу celebrating. Рисуем гориллу-победителя, показываем экран поздравления и кнопку перезапуска игры.

Как инициализировать игру

Игра инициализируется функцией newGame. Это сбрасывает игру state, генерирует новый уровень и вызывает функцию draw для отрисовки всей сцены.

Давайте пройдемся по тому, что у нас есть изначально в объекте state:

  • Во-первых, у нас есть свойство игры phase, которое может иметь значение aimingin flight или celebrating.
  • Затем свойство currentPlayer сообщает нам, чья сейчас очередь — игрока слева или игрока справа.
  • Объект bomb описывает текущее положение бомбы и ее скорость. Его исходное положение должно быть совмещено со вторым зданием, поэтому мы устанавливаем его только после создания уровня.
  • Массив buildings определяет положение и размер зданий, которые появляются на экране. Мы генерируем метаданные зданий с помощью функции полезности, которую мы обсудим позже.
// The state of the game
let state = {};

. . .

newGame();

function newGame() {
  // Initialize game state
  state = {
    phase: "aiming", // aiming | in flight | celebrating
    currentPlayer: 1,
    bomb: {
      x: undefined,
      y: undefined,
      velocity: { x: 0, y: 0 },
    },
    buildings: generateBuildings(),
  };
    
  initializeBombPosition();
    
  draw();
}

Мы обсудим функции полезности, использованные выше (generateBuildings и initializeBombPosition) в следующей главе, когда будем рисовать здания и бомбу. А пока давайте просто добавим несколько функций-заполнителей, чтобы убедиться, что мы не получим ошибку от JavaScript.  

function generateBuildings() {
  // ...
}

function initializeBombPosition() {
  // ...
}

Теперь, когда у нас есть скелет нашего приложения и мы инициализировали некоторые состояния, давайте переключимся и начнем рисовать на холсте, заполняя недостающие части состояния.

Как нарисовать сцену

Функция draw рисует весь холст в зависимости от состояния. Он рисует фон и здания, рисует горилл и рисует бомбу. Функция также может рисовать различные варианты горилл в зависимости от состояния. Горилла выглядит по-другому, когда целится, празднует или ожидает удара.

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

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

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

Мы определили элемент <canvas> в HTML. Как мы на нем рисуем?

. . .

<body>
  <canvas id="game"></canvas>
</body>

. . .

В JavaScript сначала мы получаем элемент холста по идентификатору. Затем мы устанавливаем размер холста, чтобы заполнить все окно браузера. И, наконец, мы получаем контекст рисования.

Это встроенный API со множеством методов и свойств, которые мы можем использовать для рисования на холсте. Давайте посмотрим несколько примеров использования этого API.

. . . 

// The canvas element and its drawing context 
const canvas = document.getElementById("game"); 
canvas.width = window.innerWidth; 
canvas.height = window.innerHeight; 
const ctx = canvas.getContext("2d"); 

. . . 

function draw() {
  // ... 
} 

. . . 

Пример: рисование прямоугольника

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

Самое простое, что мы можем сделать, это залить прямоугольник.

Использование&nbsp;fillRect метода для заполнения прямоугольника&nbsp;&nbsp;
Использование fillRect метода для заполнения прямоугольника  
const canvas = document.getElementById("game");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const ctx = canvas.getContext("2d");

ctx.fillStyle = "#58A8D8";

ctx.fillRect(200, 200, 440, 320);

С помощью этого fillRect метода мы указываем верхнюю левую координату нашего прямоугольника (200, 200) и устанавливаем его ширину и высоту (440, 320).

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

Принцип работы холста заключается в том, что мы должны настроить параметры рисования перед тем, как рисовать, а не наоборот. Это не значит, что мы рисуем прямоугольник, а затем можем изменить его цвет. Если что-то оказывается на холсте, оно остается таким, какое есть.

Вы можете думать об этом как о настоящем холсте, где вы также выбираете цвет кистью, прежде чем начать ею рисовать. Затем, как только вы что-то нарисовали, вы можете либо покрыть это, нарисовав что-нибудь поверх этого, либо попытаться очистить холст. Но на самом деле вы не можете изменить существующие части. Вот почему мы устанавливаем цвет здесь заранее, а не потом.

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

Фон и здания представляют собой простые прямоугольники.
Фон и здания представляют собой простые прямоугольники.

Пример: Заполнение пути

Конечно, мы можем рисовать и более сложные формы. Мы можем определить путь следующим образом:

Заполнение пути
Заполнение пути
const canvas = document.getElementById("game");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const ctx = canvas.getContext("2d");

ctx.fillStyle = "#58A8D8";

ctx.beginPath();
ctx.moveTo(200, 200);
ctx.lineTo(500, 350);
ctx.lineTo(200, 500);
ctx.fill();

Пути начинаются с метода beginPath и заканчиваются вызовом fill метода или stroke— или того и другого. В промежутке мы строим путь, вызывая методы построения пути.

В этом примере мы рисуем треугольник. Переходим к координате 300,300 с помощью метода moveTo. Затем мы вызываем метод lineTo для перемещения к правой стороне нашей фигуры. А затем продолжаем путь, снова вызывая метод lineTo 300,400.

Ничего из этого не было бы видно, если бы мы не закончили метод заполнения fill только что построенного пути.

Мы собираемся заполнить дорожки, чтобы нарисовать нашу гориллу и нашу бомбу.

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

‌Пример: рисование Stroke

Точно так же мы можем нарисовать линию. Здесь мы снова начнем с метода beginPath. Мы также построим форму с помощью методов moveTo и lineTo. Координаты здесь те же. Но, в конце концов, мы вызываем метод stroke, а не fill. Это вместо заполнения формы нарисует построенную нами линию.

Рисование штриха
Рисование штриха
const canvas = document.getElementById("game");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const ctx = canvas.getContext("2d");

ctx.strokeStyle = "#58A8D8";
ctx.lineWidth = 30;

ctx.beginPath();
ctx.moveTo(200, 200);
ctx.lineTo(500, 350);
ctx.lineTo(200, 500);
ctx.stroke();

Штрихи имеют разные свойства стиля. Вместо свойства fillStyle мы устанавливаем strokeStyle. Этому свойству, а также ему fillStyle, мы можем присвоить любое значение цвета, допустимое в CSS. Чтобы установить толщину линии, мы используем lineWidth свойство.

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

Рисование кривой
Рисование кривой
const canvas = document.getElementById("game");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const ctx = canvas.getContext("2d");

ctx.strokeStyle = "#58A8D8";
ctx.lineWidth = 30;

ctx.beginPath();
ctx.moveTo(200, 300);
ctx.quadraticCurveTo(500, 400, 800, 300);
ctx.stroke();

Мы собираемся использовать этот метод stroke, чтобы нарисовать руки и лицо гориллы.  

&nbsp; Используем&nbsp;stroke метод рисования рук и лица гориллы.&nbsp;&nbsp;
  Используем stroke метод рисования рук и лица гориллы.  

‌Теперь, когда мы закончили с этим введением, давайте вернемся к нашей игре и посмотрим, что находится внутри функции draw.

Как перевернуть систему координат вверх дном

Когда мы используем холст, у нас есть система координат с началом координат в верхнем левом углу окна браузера, которая растет вправо и вниз. Это соответствует тому, как работают веб-сайты в целом. Все идет слева направо и сверху вниз. Это значение по умолчанию, но мы можем это изменить.

Система координат по умолчанию
Система координат по умолчанию

Когда мы говорим об играх, удобнее идти снизу вверх. Например, когда мы рисуем здания, они могут начинаться снизу, и нам не нужно выяснять, где находится нижняя часть окна.

Мы можем использовать метод translate для смещения всей системы координат в левый нижний угол. Нам просто нужно сдвинуть систему координат вниз по оси Y на размер окна браузера.

Как только мы это сделаем, координата Y по-прежнему будет расти вниз. Мы можем перевернуть его, используя метод scale. Установка отрицательного числа для вертикального направления перевернет всю систему координат вверх дном.

Система координат перевернута
Система координат перевернута
Функция draw
// The canvas element and its drawing context 
const canvas = document.getElementById("game"); 
canvas.width = window.innerWidth; 
canvas.height = window.innerHeight; 
const ctx = canvas.getContext("2d"); 

. . . 

function draw() { 
  ctx.save(); 
  // Flip coordinate system upside down 
  ctx.translate(0, window.innerHeight); 
  ctx.scale(1, -1); 
  
  // Draw scene 
  drawBackground(); 
  drawBuildings();
  drawGorilla(1);
  drawGorilla(2);
  drawBomb(); 
  
  // Restore transformation 
  ctx.restore(); 
}

‌Теперь давайте реализуем функцию draw. Первое, что мы делаем, — это вызываем методы translate и scale, чтобы перевернуть систему координат вверх дном.

Мы должны сделать это, прежде чем рисовать что-либо на холсте, потому что методы translate и scaleна самом деле ничего не перемещают на холсте. Если бы мы раньше нарисовали что-нибудь на холсте, оно бы осталось таким, каким оно было.

Технически эти методы меняют матрицу преобразования. Вы можете думать об этом как об изменении системы координат. Все, что мы рисуем после этих методов, будет нарисовано в соответствии с этой новой системой координат.

Нам также необходимо восстановить эти преобразования после рисования, вызвав метод restore. Метод restore поставляется в паре с методом savesave служит контрольной точкой, к которой может вернуться метод restore.

Обычно блок рисования начинается с вызова метода save и заканчивается restore, когда мы используем преобразования между ними.

Нам нужно вызвать эти две функции, потому что методы translate и scale накапливаются. Мы собираемся вызвать функцию draw несколько раз. Без методов save и restoreсистема координат продолжала бы двигаться вниз каждый раз, когда мы вызываем функцию draw, и в конечном итоге она полностью вышла бы за пределы экрана.

Рисование всей сцены включает в себя множество частей. Мы разобьем его на отдельные функции рисования. Теперь давайте начнем рисовать, реализовав эти функции:

function drawBackground() {
  // ...
}

function drawBuildings() {
  // ...
}

function drawGorilla(player) {
  // ...
}

function drawBomb() {
  // ...
}

Как нарисовать элементы игры

Как нарисовать фон

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

Фоновое небо
Фоновое небо
function drawBackground() {
  ctx.fillStyle = "#58A8D8";
  ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
}

Мы рисуем этот прямоугольник так же, как мы это делали во введении. Сначала мы задаем стиль заливки, затем методом рисуем прямоугольник fillRect. Здесь мы устанавливаем начальные координаты в углу и задаем размер, чтобы заполнить все окно браузера.

Мы также могли бы добавить луну на небо. К сожалению, не существует метода заливки круга, с помощью которого можно было бы легко это сделать, поэтому мы пока его пропустим. На CodePen вы можете найти версию, которая также рисует в drawBackground функции луну.

Как рисовать здания

Рисование зданий состоит из двух частей. Во-первых, нам нужно сгенерировать метаданные для зданий при инициализации уровня. Затем мы реализуем функцию drawBuildings, которая рисует здания на основе этих метаданных.

Здания и их метаданные
Здания и их метаданные

Метаданные зданий

В функции newGame мы вызвали функцию generateBuildings для инициализации свойства buildings в нашей игре state. Эту функцию мы еще не реализовали.

В newGame функции мы вызвали generateBuildings функцию
 . . .

  state = {
    phase: "aiming", // aiming | in flight | celebrating
    currentPlayer: 1,
    bomb: {
      x: undefined,
      y: undefined,
      velocity: { x: 0, y: 0 },
    },
    buildings: generateBuildings(),
  };

  . . .

‌Давайте посмотрим, как работает эта функция. Каждое здание определяется своим положением x, своим width и своим height.

Координата x всегда привязана к предыдущему зданию. Проверяем, где заканчивается предыдущее здание, и добавляем небольшой зазор. Если предыдущего здания нет – поскольку мы добавляем первое – мы начинаем с начала экрана с 0.

function generateBuildings() {
  const buildings = [];
  for (let index = 0; index < 8; index++) {
    const previousBuilding = buildings[index - 1];
    
    const x = previousBuilding
      ? previousBuilding.x + previousBuilding.width + 4
      : 0;

    const minWidth = 80;
    const maxWidth = 130;
    const width = minWidth + Math.random() * (maxWidth - minWidth);

    const platformWithGorilla = index === 1 || index === 6;

    const minHeight = 40;
    const maxHeight = 300;
    const minHeightGorilla = 30;
    const maxHeightGorilla = 150;

    const height = platformWithGorilla
      ? minHeightGorilla + Math.random() * (maxHeightGorilla - minHeightGorilla)
      : minHeight + Math.random() * (maxHeight - minHeight);

    buildings.push({ x, width, height });
  }
  return buildings;
}

Затем функция генерирует случайное здание width в заранее заданном диапазоне. Мы устанавливаем минимальную и максимальную ширину и выбираем случайное число между ними.

Случайное здание генерируем height аналогичным образом, с одним отличием: размер height здания зависит еще и от того, стоит на нем горилла или нет.

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

Мы узнаем, стоит ли на здании горилла, потому что они всегда стоят на вершинах одних и тех же зданий. Второй слева и предпоследний. Если индекс здания соответствует этим позициям, мы устанавливаем высоту на основе другого диапазона.

Затем мы помещаем эти три значения как объект в buildings массив и в последней строке возвращаем этот массив из функции. Это будет buildings массив в нашей игре state.

Как рисовать здания

Теперь, когда у нас есть метаданные зданий, мы можем нарисовать их на экране.

Функция drawBuildings очень простая. Мы перебираем только что сгенерированный массив и рисуем для каждого простой прямоугольник. Мы используем тот же fillRect метод, который использовали для рисования неба. Мы вызываем эту функцию с атрибутами здания (положение Y равно 0, поскольку здание начинается внизу экрана).

function drawBuildings() {
  state.buildings.forEach((building) => {
    ctx.fillStyle = "#152A47";
    ctx.fillRect(building.x, 0, building.width, building.height);
  });
}

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

Случайно генерируемые здания
Случайно генерируемые здания

‌Как нарисовать гориллу

Рисование горилл — одна из самых сложных и увлекательных частей этой игры. Наконец, мы не просто рисуем прямоугольники – мы рисуем пути.

У горилл также есть разные вариации в зависимости от состояния игры. Горилла выглядит по-другому, когда целится, ждет летящую бомбу и празднует успешное попадание.

В функции draw мы вызываем функцию drawGorilla дважды. Рисуем двух горилл: одну на второй крыше и одну на предпоследней крыше. Они в основном идентичны, но при прицеливании отражают друг друга. Когда левый целится, он поднимает левую руку, а когда целится правый, он поднимает правую руку.

Далее в функции draw мы вызываем drawGorilla функцию дважды
function draw() {
  ctx.save();
  // Flip coordinate system upside down
  ctx.translate(0, window.innerHeight);
  ctx.scale(1, -1);

  // Draw scene
  drawBackground();
  drawBuildings();
  drawGorilla(1);
  drawGorilla(2);
  drawBomb();

  // Restore transformation
  ctx.restore();
}

‌Мы также разобьем рисование гориллы на несколько этапов. Мы будем использовать различные функции, чтобы нарисовать основное тело, руки и лицо гориллы.

Чтобы упростить задачу, мы снова воспользуемся нашей системой координат. Мы переносим систему координат на середину крыши, на которой стоит горилла. Таким образом, мы сможем нарисовать обеих горилл одинаково. Нам нужно только перенести начало нашей системы координат в другое здание.

Переносим начало системы координат на верх здания, на котором стоит горилла.
Переносим начало системы координат на верх здания, на котором стоит горилла.

В качестве аргумента функция drawGorilla получает то, что мы сейчас рисуем player. Чтобы нарисовать гориллу слева, мы переносим ее на верх второго здания, а чтобы нарисовать гориллу справа, мы переносим ее на верх предпоследнего здания.  

function drawGorilla(player) {
  ctx.save();
  const building =
    player === 1
      ? state.buildings.at(1) // Second building
      : state.buildings.at(-2); // Second last building

  ctx.translate(building.x + building.width / 2, building.height);

  drawGorillaBody();
  drawGorillaLeftArm(player);
  drawGorillaRightArm(player);
  drawGorillaFace();
  
  ctx.restore();
}

‌Поскольку мы используем этот метод translate, эта функция начинается с сохранения текущей системы координат и заканчивается ее восстановлением.

Теперь давайте рассмотрим функции, рисующие различные части гориллы.

function drawGorillaBody() {
  // ...
}

function drawGorillaLeftArm(player) {
  // ...
}

function drawGorillaRightArm(player) {
  // ...
}

function drawGorillaFace() {
  // ...
}

Как нарисовать тело гориллы

Рисуем тело гориллы в виде тропинки. Мы нарисовали путь в нашем введении в рисование на холсте. Мы используем moveTo и множество методов lineTo, чтобы нарисовать основную часть гориллы.

Мы устанавливаем черный стиль заливки и начинаем путь. Переходим к координате посередине, а затем рисуем прямые линии, чтобы нарисовать силуэт гориллы. Закончив, мы заполняем фигуру методом fill.

function drawGorillaBody() {
  ctx.fillStyle = "black";
    
  ctx.beginPath(); 
  
  // Starting Position
  ctx.moveTo(0, 15); 
    
  // Left Leg
  ctx.lineTo(-7, 0);
  ctx.lineTo(-20, 0); 
    
  // Main Body
  ctx.lineTo(-13, 77);
  ctx.lineTo(0, 84);
  ctx.lineTo(13, 77); 
  
  // Right Leg
  ctx.lineTo(20, 0);
  ctx.lineTo(7, 0);
  
  ctx.fill();
}

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

Как нарисовать руки гориллы

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

Начнем с левой руки. Основная часть на самом деле состоит всего из двух строк. Мы воспользуемся этим методом moveTo, чтобы перейти к плечу гориллы, а затем с помощью этого метода нарисуем руку в виде квадратичной кривой quadraticCurveTo.

Квадратичная кривая — это простая кривая с одной контрольной точкой. Когда кривая идет от начальной точки (которую мы установим с помощью moveTo), кривая изгибается к этой контрольной точке (заданной как первые два аргумента метода quadraticCurveTo), когда она достигает конечного положения (заданного как последние два аргумента).

Различные координаты кривой
Различные координаты кривой
function drawGorillaLeftArm(player) {
  ctx.strokeStyle = "black";
  ctx.lineWidth = 18;

  ctx.beginPath();
  ctx.moveTo(-13, 50);

  if (
    (state.phase === "aiming" && state.currentPlayer === 1 && player === 1) ||
    (state.phase === "celebrating" && state.currentPlayer === player)
  ) {
    ctx.quadraticCurveTo(-44, 63, -28, 107);
  } else {
    ctx.quadraticCurveTo(-44, 45, -28, 12);
  }
  
  ctx.stroke();
}

Эту функцию усложняет то, что она имеет две вариации одной и той же кривой. По умолчанию руки опускаются рядом с телом (второй случай выше).

Если мы находимся в фазе aiming, это игрок currentPlayer номер 1 и мы вытягиваем player1, то левая рука поднимается вверх (первый случай выше). Левая рука также поднимается вверх, если мы рисуем гориллу celebrating (также первый случай выше).

В этих случаях мы начинаем с одной и той же точки (кривая начинается с одного и того же метода moveTo), но задаем разные координаты для контрольной точки и конечной точки кривой.

Руки рисуем штрихами. Поэтому вместо того, чтобы заканчивать путь методом fill, мы используем метод stroke.

Мы также настроили его по-другому. Вместо использования свойства fillStyle здесь мы задаем цвет strokeStyle и толщину руки с помощью этого свойства lineWidth.

aiming фаза
aiming фаза
in flight фаза
in flight фаза
celebrating фаза
celebrating фаза

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

function drawGorillaRightArm(player) {
  ctx.strokeStyle = "black";
  ctx.lineWidth = 18;

  ctx.beginPath();
  ctx.moveTo(+13, 50);

  if (
    (state.phase === "aiming" && state.currentPlayer === 2 && player === 2) ||
    (state.phase === "celebrating" && state.currentPlayer === player)
  ) {
    ctx.quadraticCurveTo(+44, 63, +28, 107);
  } else {
    ctx.quadraticCurveTo(+44, 45, +28, 12);
  }
  
  ctx.stroke();
}

В результате наши гориллы должны начать обретать форму. Лица у них еще нет, но руки уже есть. И, чтобы отразить наше игровое состояние, тот, кто слева, поднимает руки вверх и готовится бросить бомбу.

Вы можете протестировать наше решение, изменив состояние игры. Вы можете изменить currentPlayer и свойства игры phase, чтобы увидеть различные варианты.

Как нарисовать морду гориллы

Лицо гориллы состоит из нескольких прямых линий. Мы нарисуем два глаза и рот прямой линией. Для каждого мы будем использовать пару moveTo и lineTo метод. Поскольку каждый сегмент линии использует один и тот же strokeStyle и lineWidth, мы можем нарисовать их как один путь.

Готовые гориллы
Готовые гориллы
function drawGorillaFace() {
  ctx.strokeStyle = "lightgray";
  ctx.lineWidth = 3;
  
  ctx.beginPath();

  // Left Eye
  ctx.moveTo(-5, 70);
  ctx.lineTo(-2, 70);

  // Right Eye
  ctx.moveTo(2, 70);
  ctx.lineTo(5, 70);

  // Mouth
  ctx.moveTo(-5, 62);
  ctx.lineTo(5, 62);

  ctx.stroke();
}

Таким образом, у нас есть готовые гориллы со всеми вариациями. На экране не хватает только одного: банановой бомбы.

Как нарисовать бомбу

Теперь мы нарисуем бомбу. Бомба будет представлять собой простой круг. Но прежде чем мы начнем его рисовать, сначала нам нужно выяснить, где он находится.

Как инициализировать положение бомбы

Если мы вернемся к нашей функции newGame, то увидим, что метаданные бомбы имеют положение и скорость. Позиция пока остается неизменной undefined. Прежде чем мы приступим к рисованию бомбы, давайте сначала выясним ее положение.

В newGame функции мы вызвали initializeBombPosition функцию
function newGame() {
  // Initialize game state
  state = {
    phase: "aiming", // aiming | in flight | celebrating
    currentPlayer: 1,
    bomb: {
      x: undefined,
      y: undefined,
      velocity: { x: 0, y: 0 },
    },
    buildings: generateBuildings(),
  };
    
  initializeBombPosition();
    
  draw();
}

‌В конце функции newGame мы также вызываем функцию initializeBombPosition перед рисованием сцены. Давайте реализуем эту функцию.

Он initializeBombPosition помещает бомбу в руку гориллы, которая бросает бомбу в этом ходу. Мы должны вызвать эту функцию после того, как сгенерируем метаданные нашего здания, потому что положение бомбы зависит от положения гориллы, а это зависит от здания, на котором она стоит.

Сначала мы ищем то, building с чем нам нужно согласоваться. Если настал ход первого игрока, то бомба должна находиться в левой руке гориллы слева. А если очередь второго игрока, то он должен быть в правой руке гориллы справа.

Сначала мы рассчитаем нужную нам среднюю точку крыши (gorillaX и gorillaY), затем сместим положение, чтобы оно соответствовало левой или правой руке гориллы.

Расчет положения бомбы
Расчет положения бомбы
function initializeBombPosition() {
  const building =
    state.currentPlayer === 1
      ? state.buildings.at(1) // Second building
      : state.buildings.at(-2); // Second last building

  const gorillaX = building.x + building.width / 2;
  const gorillaY = building.height;

  const gorillaHandOffsetX = state.currentPlayer === 1 ? -28 : 28;
  const gorillaHandOffsetY = 107;
  
  state.bomb.x = gorillaX + gorillaHandOffsetX;
  state.bomb.y = gorillaY + gorillaHandOffsetY;
  state.bomb.velocity.x = 0;
  state.bomb.velocity.y = 0;
}

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

Как нарисовать бомбу

Теперь, когда бомба находится в нужном месте, давайте нарисуем ее. К сожалению, у нас нет простого метода заполнения круга, как в случае с прямоугольниками. Вместо этого нам нужно нарисовать arc.

Метод arc может быть вызван как часть пути. Мы начнем с beginPath метода и закончим методом fill.

Метод arc имеет множество свойств. Это может показаться немного пугающим, но при рисовании кругов нам нужно сосредоточиться только на первых трёх:

  • Первые два аргумента — это x и y, координаты центра дуги. Мы установим их так, чтобы они соответствовали положению бомбы, известному из файла state.
  • Третий аргумент — это radius. Здесь мы установим его на 6.
  • Тогда последние два аргумента — это startAngle и endAngle дуга в радианах. Поскольку здесь нам нужен полный круг, а не дуга, мы начнем с 0 и закончим полным кругом. Полный круг в радианах равен удвоенному числу Пи.

Если эти два последних свойства сбивают с толку, не беспокойтесь об этом. Важно то, что когда мы рисуем круги, они всегда 0 и 2 * Math.Pi.

function drawBomb() {
  ctx.fillStyle = "white";
  ctx.beginPath();
  ctx.arc(state.bomb.x, state.bomb.y, 6, 0, 2 * Math.PI);
  ctx.fill();
}
Финальная сцена с бомбой на месте
Финальная сцена с бомбой на месте

Теперь у нас все на экране. Мы нарисовали фон, здания, горилл и бомбу. Но все не сосредоточено на экране. Давайте это исправим.

Как подогнать размер города под размер окна браузера

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

Чтобы это исправить, давайте сопоставим размер города с шириной окна браузера.

Для этого давайте добавим свойство scale в наше состояние. В функции newGame добавим свойство scale к объекту state и вызовем новую функцию с именем calculateScale, чтобы установить его значение. Этот вызов функции должен произойти после того, как мы сгенерируем наши здания, поскольку масштабирование зависит от размера города. Это также должно произойти до того, как мы инициализируем положение бомбы, потому что позже это будет зависеть от масштабирования.

Добавляем scale свойство в state и вызываем calculateScale функцию
function newGame() {
  // Initialize game state
  state = {
    scale: 1,
    phase: "aiming", // aiming | in flight | celebrating
    currentPlayer: 1,
    bomb: {
      x: undefined,
      y: undefined,
      velocity: { x: 0, y: 0 },
    },
    buildings: generateBuildings(),
  };

  calculateScale();

  initializeBombPosition();

  draw();
}

Функция calculateScale относительно проста. Вычисляем общую ширину города и делим внутреннюю ширину окна браузера на это значение. Это даст нам соотношение. Он расскажет, как ширина нашего города связана с шириной окна браузера.  

Вычисление соотношения размера города к размеру окна браузера
Вычисление соотношения размера города к размеру окна браузера
function calculateScale() {
  const lastBuilding = state.buildings.at(-1);
  const totalWidthOfTheCity = lastBuilding.x + lastBuilding.width;
  
  state.scale = window.innerWidth / totalWidthOfTheCity;
}

Затем нам придется использовать это новое scale свойство в нескольких местах. Самое главное — изменить в функции масштабирование всей игры draw.

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

Применение нашего нового scale свойства в draw функции
function draw() {
  ctx.save();
  // Flip coordinate system upside down
  ctx.translate(0, window.innerHeight);
  ctx.scale(1, -1);
  ctx.scale(state.scale, state.scale);

  // Draw scene
  drawBackground();
  drawBuildings();
  drawGorilla(1);
  drawGorilla(2);
  drawBomb();

  // Restore transformation
  ctx.restore();
}

И, наконец, нам нужно настроить способ рисования фона. Ранее, когда мы рисовали фон, мы заполняли весь экран, устанавливая его ширину и высоту в зависимости от окна innerWidth и innerHeight свойств. Теперь, когда все масштабировано, они больше не соответствуют размеру окна браузера. Всякий раз, когда мы используем эти свойства, нам необходимо корректировать их с помощью нашего коэффициента масштабирования.  

Отрегулируйте размер фона с помощью нашего масштабирования.
function drawBackground() {
  ctx.fillStyle = "#58A8D8";
  ctx.fillRect(
    0,
    0,
    window.innerWidth / state.scale,
    window.innerHeight / state.scale
  );
}
Соответствие размера города размеру окна браузера
Соответствие размера города размеру окна браузера

Как изменить размер окна

Теперь 8 зданий, которые мы нарисовали, прекрасно вписываются в размер нашего экрана, но что произойдет, если мы изменим размер окна? Нас ждут те же проблемы, что и раньше.

В качестве завершающего штриха давайте обработаем resize событие окна. Этот обработчик событий изменяет размер элемента холста в соответствии с новым размером окна, пересчитывает масштаб, корректирует положение бомбы и перерисовывает всю сцену на основе нового масштабирования.

Изменение положения бомбы пока не имеет значения, но позже мы обновим эту функцию, и она будет опираться на новое масштабирование.

window.addEventListener("resize", () => {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
  calculateScale();
  initializeBombPosition();
  draw();
});

До сих пор мы только что нарисовали статическую сцену. Пришло время сделать вещи интерактивными.

Как горилла может бросить бомбу

Бросание бомбы состоит из двух разных частей. Во-первых, нам нужно прицелиться. Хватаем бомбу мышкой и перетаскиваем ее, чтобы задать угол броска и скорость. Во время перетаскивания нам нужно отображать угол и скорость на информационных панелях в верхней части экрана. Также нарисуем на экране траекторию броска. Это aiming фаза.

Этап&nbsp;aiming
Этап aiming

Затем, как только мы отпустим мышь, бомба полетит по небу. У нас будет цикл анимации, который будет перемещать бомбу по небу. Это in flight фаза.  

Этап&nbsp;in flight
Этап in flight

На этом этапе этот цикл анимации также будет включать обнаружение попаданий. Нам нужно знать, попала ли бомба во врага, в здание или вышла за пределы экрана. Если мы ударим по зданию или бомба уйдет за пределы экрана, мы поменяем игроков и снова вернемся к aiming фазе. Если мы попали во врага, то попадаем в celebrating фазу. Затем мы рисуем праздничный вариант гориллы текущего игрока и показываем экран поздравлений.  

Этап&nbsp;celebrating
Этап celebrating

Теперь давайте пройдемся по этим частям подробно.

Как прицелиться

На этой aiming фазе мы можем перетаскивать бомбу, чтобы установить ее угол и скорость — как мы бросаем птицу в Angry Birds. Для этого мы собираемся настроить обработчики событий.

Прицеливание
Прицеливание

Во время прицеливания мы будем показывать текущий угол и скорость в левом верхнем или правом верхнем углу экрана (в зависимости от игрока). Мы также собираемся нарисовать на экране траекторию броска, чтобы показать, в каком направлении полетит бомба.

Информационные панели, показывающие угол и скорость

В оригинальной игре 1991 года нам приходилось вводить угол и скорость с помощью клавиатуры, поскольку она не имела поддержки мыши. Здесь мы собираемся целиться с помощью мыши, но мы по-прежнему добавляем на экран те же элементы пользовательского интерфейса. Мы будем обновлять эти поля по мере перемещения мыши во время перетаскивания.

Информационные панели в левом верхнем и правом верхнем углах экрана.
Информационные панели в левом верхнем и правом верхнем углах экрана.

Мы добавим эти информационные панели в HTML. Мы также могли бы нарисовать эти текстовые поля на холсте, но, возможно, было бы проще использовать старый добрый HTML и CSS.

Мы добавим два элемента div: один для игрока 1 и один для игрока 2. Оба содержат заголовок с номером игрока. Мы также добавим два абзаца для угла и скорости. Информационные панели должны располагаться после элемента холста, поскольку в противном случае холст закроет их.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Gorillas</title>
    <link rel="stylesheet" href="index.css" />
    <script src="index.js" defer></script>
  </head>
  <body>
    <canvas id="game"></canvas>
      
    <div id="info-left">
      <h3>Player 1</h3>
      <p>Angle: <span class="angle">0</span>°</p>
      <p>Velocity: <span class="velocity">0</span></p>
    </div>
      
    <div id="info-right">
      <h3>Player 2</h3>
      <p>Angle: <span class="angle">0</span>°</p>
      <p>Velocity: <span class="velocity">0</span></p>
    </div>
  </body>
</html>

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

В CSS мы переместим эти панели на место и зададим стили для текста. Мы также хотим гарантировать, что пользователь не сможет выбирать текст на экране. В противном случае вы можете случайно выбрать эти текстовые поля во время прицеливания.

body {
  margin: 0;
  padding: 0;
  font-family: monospace;
  font-size: 14px;
  color: white;
  user-select: none;
  -webkit-user-select: none;
}
#info-left {
  position: absolute;
  top: 20px;
  left: 25px;
}
#info-right {
  position: absolute;
  top: 20px;
  right: 25px;
  text-align: right;
}

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

. . .

// Left info panel
const angle1DOM = document.querySelector("#info-left .angle");
const velocity1DOM = document.querySelector("#info-left .velocity");

// Right info panel
const angle2DOM = document.querySelector("#info-right .angle");
const velocity2DOM = document.querySelector("#info-right .velocity");

. . .

Когда мы добавим обработку событий, мы будем обновлять содержимое этих элементов движением мыши. Пока оставим это так.

Зона захвата бомбы

Мы собираемся настроить обработчики событий для перетаскивания бомбы. Но что мы можем вообще тащить? Весь элемент холста представляет собой один элемент. Мы могли бы прикрепить к нему обработчик событий, но тогда мы могли бы начать перетаскивать бомбу, щелкнув в любом месте экрана. Мы этого не хотим.

Зона захвата бомбы
Зона захвата бомбы

Вместо этого мы определяем в HTML еще один элемент, который будет служить областью захвата. Добавляем bomb-grab-area элемент. Сюда не будет включаться бомба, которую мы видим на экране (которая уже является частью холста), но это будет невидимая область вокруг нее, служащая целью события.  

Добавляем bomb-grab-area элемент в body HTML
 . . .

  <body>
    <canvas id="game"></canvas>
    
    <div id="info-left">
      <h3>Player 1</h3>
      <p>Angle: <span class="angle">0</span>°</p>
      <p>Velocity: <span class="velocity">0</span></p>
    </div>

    <div id="info-right">
      <h3>Player 2</h3>
      <p>Angle: <span class="angle">0</span>°</p>
      <p>Velocity: <span class="velocity">0</span></p>
    </div>

    <div id="bomb-grab-area"></div>
  </body>

  . . .

Мы также добавим к нему стили с помощью CSS. Мы хотим, чтобы она имела абсолютную позицию и представляла собой невидимый круг с немного большим радиусом, чем у бомбы (чтобы ее было легче схватить). Мы можем установить точную позицию в JavaScript.

Мы также изменим курсор, когда на него наведена мышь, на grab. Хотя этот элемент невидим, вы все равно можете заметить, что ваша мышь находится в нужном месте по меняющемуся курсору.

. . .

#bomb-grab-area {
  position: absolute;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background-color: transparent;
  cursor: grab;
}

Наконец, в JavaScript нам нужно сделать две вещи, прежде чем мы настроим обработку событий. Во-первых, получите ссылку на него где-нибудь в начале файла.

. . .

// The bomb's grab area 
const bombGrabAreaDOM = document.getElementById("bomb-grab-area");

. . .

Затем нам нужно переместить его в правильное положение. У нас уже есть функция, которая инициализирует положение бомбы на холсте. Мы также можем расширить функцию initializeBombPosition, чтобы позиционировать этот элемент HTML.

Мы переместим область захвата в то же место, где находится бомба на экране, но ее координаты немного другие. Содержимое холста масштабируется, а любой другой элемент HTML — нет. Нам нужно скорректировать координаты путем масштабирования. Также обратите внимание, что мы установим grabAreaRadius переменную равной половине размера, определенного для этого элемента в CSS.

Расширьте initializeBombPosition функцию, чтобы позиционировать область захвата в HTML.
function initializeBombPosition() {
  const building =
    state.currentPlayer === 1
      ? state.buildings.at(1) // Second building
      : state.buildings.at(-2); // Second last building

  const gorillaX = building.x + building.width / 2;
  const gorillaY = building.height;

  const gorillaHandOffsetX = state.currentPlayer === 1 ? -28 : 28;
  const gorillaHandOffsetY = 107;

  state.bomb.x = gorillaX + gorillaHandOffsetX;
  state.bomb.y = gorillaY + gorillaHandOffsetY;
  state.bomb.velocity.x = 0;
  state.bomb.velocity.y = 0;

  // Initialize the position of the grab area in HTML
  const grabAreaRadius = 15;
  const left = state.bomb.x * state.scale - grabAreaRadius;
  const bottom = state.bomb.y * state.scale - grabAreaRadius;
  bombGrabAreaDOM.style.left = `${left}px`;
  bombGrabAreaDOM.style.bottom = `${bottom}px`;
}

‌Как только мы это добавим, мы не увидим никакой разницы на экране, потому что этот элемент невидим. Но как только мы наведем курсор на бомбу, мы увидим, что курсор изменится на захват.

Обработка событий

Теперь, когда все настроено, мы наконец можем настроить обработку событий. Это будет простая реализация перетаскивания, в которой мы будем слушать события mousedownmousemove и mouseup.

Во-первых, мы установим несколько переменных верхнего уровня где-нибудь в начале файла. У нас есть логическая переменная, которая сообщает нам, перетаскиваем мы в данный момент или нет, и две переменные, которые сообщают нам, где началось перетаскивание, на случай, если мы перетаскиваем.

. . .

let isDragging = false;
let dragStartX = undefined;
let dragStartY = undefined;

. . .

Мы добавляем первый обработчик событий mousedown в области захвата бомбы. Возможность установить этот обработчик событий — причина, по которой мы добавили этот bomb-grab-area элемент ранее.

Этот обработчик событий делает что-либо только в том случае, если мы находимся в aiming фазе. Если это правда, мы устанавливаем isDragging для переменной значение true, сохраняем текущую позицию мыши и постоянно устанавливаем курсор мыши grabbing(чтобы курсор оставался захватывающим, даже если мы покинем область захвата).

bombGrabAreaDOM.addEventListener("mousedown", function (e) {
  if (state.phase === "aiming") {
    isDragging = true;

    dragStartX = e.clientX;
    dragStartY = e.clientY;
    
    document.body.style.cursor = "grabbing";
  }
});

Затем мы добавим обработчик события mousemove. Обратите внимание, что теперь целью события является не область захвата бомбы, а сам window объект. Это связано с тем, что во время перетаскивания мы можем легко переместить мышь за пределы области захвата — или даже за пределы окна браузера — и мы все равно хотим, чтобы этот обработчик событий работал.

Этот обработчик событий сначала проверяет, осуществляем ли мы перетаскивание в данный момент. Если нет, то это ничего не делает. Если мы перетаскиваем, то он вычисляет дельту положения мыши с момента mousedown события и устанавливает ее как скорость бомбы.

window.addEventListener("mousemove", function (e) {
  if (isDragging) {
    let deltaX = e.clientX - dragStartX;
    let deltaY = e.clientY - dragStartY;

    state.bomb.velocity.x = -deltaX;
    state.bomb.velocity.y = +deltaY;
    setInfo(deltaX, deltaY);

    draw();
  }
});

Когда мы отпускаем мышь, мы хотим, чтобы бомба двигалась в противоположном направлении по мере того, как мы перетаскиваем мышь, поэтому нам нужно установить горизонтальную и вертикальную скорость с отрицательным знаком. Но затем двойным поворотом мы переключаем обратно вертикальную скорость (координату Y), чтобы получить положительный знак, потому что мы перевернули систему координат.

Обработчики событий по-прежнему предполагают, что система координат растет вниз. Внутри холста он идет в противоположном направлении.

Этот обработчик событий также вызывает служебную функцию, чтобы показать угол и скорость на информационных панелях, которые мы только что добавили в HTML. Затем мы снова вызываем draw функцию. Пока вызов draw функции здесь ничего не меняет на экране, но скоро мы обновим функцию drawBomb для рисования траектории броска.

Кнопка setInfo обновляет элементы пользовательского интерфейса, которые мы определили в HTML. У нас уже есть ссылки на эти элементы в верхней части нашего файла, поэтому здесь нам нужно только обновлять их содержимое по мере перетаскивания бомбы.

Ранее мы добавляли ссылки на поля информационных панелей.
. . .

// Left info panel
const angle1DOM = document.querySelector("#info-left .angle");
const velocity1DOM = document.querySelector("#info-left .velocity");

// Right info panel
const angle2DOM = document.querySelector("#info-right .angle");
const velocity2DOM = document.querySelector("#info-right .velocity");

. . .

Но есть небольшая сложность. Из этого mousemove события мы получили вертикальную и горизонтальную составляющие скорости. Но в пользовательском интерфейсе мы хотим отображать угол и общую скорость броска. Нам нужно использовать некоторую тригонометрию для расчета этих значений.  

Вычисление суммы&nbsp;velocity и суммы&nbsp;angle по горизонтальному и вертикальному движению мыши&nbsp;&nbsp;
Вычисление суммы velocity и суммы angle по горизонтальному и вертикальному движению мыши  

Для скорости мы рассчитаем hypotenuse воображаемый треугольник, состоящий из горизонтальной и вертикальной составляющих скорости. Для angle мы будем использовать функцию арксинуса (обратную функции синуса). Мы также обязательно обновим правильную информационную панель в зависимости от того, чья очередь, и округлим значения.  

function setInfo(deltaX, deltaY) {
  const hypotenuse = Math.sqrt(deltaX ** 2 + deltaY ** 2);
  const angleInRadians = Math.asin(deltaY / hypotenuse);
  const angleInDegrees = (angleInRadians / Math.PI) * 180;
  
  if (state.currentPlayer === 1) {
    angle1DOM.innerText = Math.round(angleInDegrees);
    velocity1DOM.innerText = Math.round(hypotenuse);
  } else {
    angle2DOM.innerText = Math.round(angleInDegrees);
    velocity2DOM.innerText = Math.round(hypotenuse);
  }
}

На этом этапе, хотя сцена должна оставаться такой же, как она есть, значения на левой информационной панели должны обновляться при перетаскивании.

Наконец, мы добавим обработчик события mouseup снова на window объект. Он что-то делает, только если мы сейчас перетаскиваем. Затем он замечает, что мы больше не перетаскиваем, меняет курсор на значение по умолчанию и вызывает функцию throwBomb.

window.addEventListener("mouseup", function () {
  if (isDragging) {
    isDragging = false;

    document.body.style.cursor = "default";
    
    throwBomb();
  }
});

Функция throwBomb переключает фазу игры in flight и запускает анимацию бомбы. Мы собираемся рассмотреть эту функцию в следующей главе, но прежде чем мы доберемся до нее, давайте обновим еще одну вещь.

Как нарисовать траекторию броска

Пока мы целимся, мы также можем нарисовать траекторию броска. Траектория броска — это визуализированная форма скорости и угла.

Траектория броска показывает угол и скорость в визуальной форме.
Траектория броска показывает угол и скорость в визуальной форме.

Для этого вернемся к drawBomb функции и внесем некоторые изменения. Если мы находимся в aiming фазе, мы проведем прямую линию от центра бомбы до скорости.  

function drawBomb() {
  // Draw throwing trajectory
  if (state.phase === "aiming") {
    ctx.strokeStyle = "rgba(255, 255, 255, 0.7)";
    ctx.setLineDash([3, 8]);
    ctx.lineWidth = 3;
    
    ctx.beginPath();
    ctx.moveTo(state.bomb.x, state.bomb.y);
    ctx.lineTo(
      state.bomb.x + state.bomb.velocity.x,
      state.bomb.y + state.bomb.velocity.y
    );
    ctx.stroke();
  }

  // Draw circle
  ctx.fillStyle = "white";
  ctx.beginPath();
  ctx.arc(state.bomb.x, state.bomb.y, 6, 0, 2 * Math.PI);
  ctx.fill();
}

Мы нарисуем эту линию как путь, как делали раньше. Мы начнем с beginPath метода и закончим методом stroke. Между ними мы будем использовать метод moveTo и lineTo.

Однако здесь есть одна новая вещь: setLineDash метод. С помощью этой настройки мы можем нарисовать пунктирную линию. Мы установим это через каждые 3 пикселя линии и хотим, чтобы промежуток составлял 8 пикселей. 3-пиксельное тире соответствует lineWidth, поэтому тире будет выглядеть как точки.

Теперь мы закончили все, что касается aiming фазы. Пришло время бросить бомбу.

Как анимировать приближающуюся бомбу

Как только мы отпустим мышь после прицеливания, бомба полетит по небу. Мы добавим цикл анимации, который перемещает бомбу, вычисляет ее положение при каждом цикле анимации и проверяет, не наткнулись ли мы на что-нибудь.

Анимация бомбы
Анимация бомбы

Ранее мы видели, что обработчик события mouseup заканчивается вызовом throwBomb функции. Давайте реализуем эту функцию.

Эта функция первой запускает in flight фазу. Затем он сбрасывает previousAnimationTimestamp переменную. Это новая служебная переменная, необходимая для цикла анимации. Мы рассмотрим это через секунду. Затем мы запускаем цикл анимации, вызывая requestAnimationFrame функцию animate в качестве аргумента. Давайте углубимся в эту функцию анимации.

Давайте добавим новую служебную переменную и реализуем throwBomb функцию
. . . 

let previousAnimationTimestamp = undefined; 

. . .

function throwBomb() {
  state.phase = "in flight";
  previousAnimationTimestamp = undefined;
  requestAnimationFrame(animate);
}

Цикл анимации

Функция animate перемещает бомбу и вызывает draw функцию, которая перерисовывает экран снова и снова, пока мы не ударим по врагу, зданию или бомба не уйдет за пределы экрана.

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

Цикл анимации
Цикл анимации

‌Эта функция отслеживает, сколько времени прошло с момента ее последнего вызова. Мы собираемся использовать эту информацию, чтобы точно рассчитать, насколько нам следует переместить бомбу.

Функции, вызываемые с помощью requestAnimationFrame функции, получают timestamp как атрибут. В конце каждого цикла мы сохраняем это значение timestamp в previousAnimationTimestamp переменную, чтобы в следующем цикле можно было посчитать, сколько времени прошло между двумя циклами. В приведенном ниже коде это переменная elapsedTime.

Первый цикл является исключением, поскольку на тот момент у нас еще не было предыдущего цикла. В начале каждого броска значение равно previousAnimationTimestamp (undefinedмы убедились, что оно есть undefined в throwBomb функции). В этом случае мы пропускаем рендер и будем рендерить сцену только во втором цикле, где у нас уже есть все необходимые нам значения. Это часть в самом начале функции animate.

function animate(timestamp) {
  if (previousAnimationTimestamp === undefined) {
    previousAnimationTimestamp = timestamp;
    requestAnimationFrame(animate);
    return;
  }
  const elapsedTime = timestamp - previousAnimationTimestamp;

  moveBomb(elapsedTime); 
  
  // Hit detection
  let miss = false; // Bomb hit a building or got out of the screen
  let hit = false; // Bomb hit the enemy

  // Handle the case when we hit a building or the bomb got off-screen
  if (miss) {
    // ...
    return;
  } // Handle the case when we hit the enemy
  if (hit) {
    // ... 
    return;
  }

  draw();
  
  // Continue the animation loop
  previousAnimationTimestamp = timestamp;
  requestAnimationFrame(animate);
}

‌Внутри функции мы будем перемещать бомбу каждый цикл, вызывая функцию moveBomb. Мы передадим переменную elapsedTime, чтобы она могла точно рассчитать, насколько она должна сдвинуть бомбу.

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

Как переместить бомбу

Мы перемещаем бомбу, вызывая moveBomb функцию в цикле анимации. Эта функция вычисляет новое положениеx и yбомбы в каждом цикле.

Новые значения x и yрассчитываются на основе скорости. Но вертикальная и горизонтальная скорость могут быть относительно высокими.

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

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

function moveBomb(elapsedTime) {
  const multiplier = elapsedTime / 200; // Adjust trajectory by gravity

  state.bomb.velocity.y -= 20 * multiplier; // Calculate new position
  
  state.bomb.x += state.bomb.velocity.x * multiplier;
  state.bomb.y += state.bomb.velocity.y * multiplier;
}

Обнаружение попаданий

Наш цикл анимации продолжает перемещать бомбу в бесконечность. Но как только мы попали в здание, врага или бомба вышла за пределы экрана, мы должны остановить это движение.

Нам необходимо выявлять такие случаи и обрабатывать их соответствующим образом. Если бомба выходит за пределы экрана или мы попали в здание, нам следует перейти к следующему игроку и вернуться в фазу aiming. В случае, если мы попали во врага, нам следует перейти к celebrating фазе и объявить победителя.

Во всех этих случаях мы можем остановить цикл, досрочно вернувшись из animate функции. В этих случаях функция анимации не достигает своей последней строки, которая запускает новый цикл анимации.

Как повысить точность обнаружения попаданий

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

Когда бомба находится в полете, она может перемещаться более чем на 10 пикселей за раз. Если мы детектируем попадание только один раз за цикл анимации, то мы совершенно не видим, что происходит во время этих 10-пиксельных движений. Бомба может легко пройти сквозь части гориллы, и мы не заметим, что должны были попасть в нее, или пройти через угол здания без какого-либо удара.

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

Чтобы решить эту проблему, мы будем выполнять обнаружение попаданий не только один раз за каждый цикл анимации, но и несколько раз. Мы разобьем каждое движение на более мелкие сегменты, и с каждым крошечным движением нам нужно будет проверять, есть ли у нас попадание.

Мы по-прежнему будем рендерить сцену один раз за цикл анимации, но перед вызовом функции draw разобьем движение на 10 сегментов с помощью цикла.

Разбейте движение и обнаружение попаданий на 10 сегментов в каждом цикле анимации.
Разбейте движение и обнаружение попаданий на 10 сегментов в каждом цикле анимации.

Если мы разобьем каждый цикл анимации на десять сегментов, это также означает, что теперь мы вызываем функцию moveBomb еще десять раз. Нам нужно замедлить его еще больше. Поскольку эта функция перемещает бомбу в соответствии с прошедшим временем, достаточно разделить ее атрибут времени также на десять.

Таким образом, бомба движется по небу с той же скоростью, но мы имеем в десять раз большую точность обнаружения попадания. В приведенном ниже примере мы помещаем вызов moveBomb функции и логику обнаружения попаданий в цикл for.

function animate(timestamp) {
  if (!previousAnimationTimestamp) {
    previousAnimationTimestamp = timestamp;
    requestAnimationFrame(animate);
    return;
  }

  const elapsedTime = timestamp - previousAnimationTimestamp;

  const hitDetectionPrecision = 10;
  for (let i = 0; i < hitDetectionPrecision; i++) {
    moveBomb(elapsedTime / hitDetectionPrecision);

    // Hit detection
    const miss = checkFrameHit() || checkBuildingHit();
    const hit = checkGorillaHit();

    // Handle the case when we hit a building or the bomb got off-screen
    if (miss) {
      // ...
      return;
    }

    // Handle the case when we hit the enemy
    if (hit) {
      // ...
      return;
    }
  }

  draw();
  
  // Continue the animation loop
  previousAnimationTimestamp = timestamp;
  requestAnimationFrame(animate);
}

В приведенном выше примере мы также представили некоторые служебные функции для обнаружения попаданий. На следующих шагах давайте реализуем эти функции.

function checkFrameHit() {
  // ...
}

function checkBuildingHit() {
  // ...
}

function checkGorillaHit() {
  // ...
}

Как определить, что бомба находится за пределами экрана

Мы промахнулись по цели, если дошли до края экрана или попали в здание. Проверить, достигли ли мы края экрана, очень легко.

Проверка, вышла ли бомба за пределы экрана
Проверка, вышла ли бомба за пределы экрана

‌Нам нужно только проверить, взорвалась ли бомба в левой, нижней или правой части экрана. Если бомба пробьет верхнюю часть экрана, ничего страшного. Гравитация в конечном итоге потянет его обратно.

Обратите внимание, что из-за масштабирования правая часть экрана не совпадает со innerWidth значением окна. Нам нужно скорректировать это с помощью коэффициента масштабирования.

function checkFrameHit() {
  if (
    state.bomb.y < 0 ||
    state.bomb.x < 0 ||
    state.bomb.x > window.innerWidth / state.scale
  ) {
    return true; // The bomb is off-screen
  }
}

Если бомба вышла за пределы экрана, мы возвращаем true, чтобы сигнализировать animate функции, что мы можем остановить цикл анимации.

Как определить, попала ли бомба в здание

Мы также не попадаем в цель, если бомба попадает в здание. Мы можем перебирать массив зданий и проверять, находится ли какая-либо сторона бомбы внутри прямоугольника здания. Нам нужно это проверить:

  • Правая часть бомбы находится справа от левой стороны здания.
  • Левая часть бомбы находится слева от правой стороны здания.
  • И что нижняя часть бомбы находится ниже верха здания.
function checkBuildingHit() {
  for (let i = 0; i < state.buildings.length; i++) {
    const building = state.buildings[i];
    if (
      state.bomb.x + 4 > building.x &&
      state.bomb.x - 4 < building.x + building.width &&
      state.bomb.y - 4 < 0 + building.height
    ) {
      return true; // Building hit
    }
  }
}

Если все это правда, то мы знаем, что бомба попала в здание. Если мы получим попадание, функция завершается возвратом true. Это также сообщит функции animate, что мы можем остановить цикл анимации.

Как определить, попала ли бомба в противника

Теперь, когда мы можем определить, вышла ли бомба за пределы экрана, и у нас есть обнаружение попаданий в здания, пришло время определить, попали ли мы во врага.

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

Скриншот игры после того, как мы попали во врага
Скриншот игры после того, как мы попали во врага

Вместо этого мы можем использовать isPointInPath метод, который удобно сообщает нам, находится ли точка (в данном случае центр бомбы) внутри пути.

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

Удобно – и при грамотном планировании – у нас уже есть функция, рисующая тело гориллы как единый контур. Мы вызываем drawGorillaBody функцию непосредственно перед isPointInPath. Но drawGorillaBody функция рисует по координатам относительно крыши здания, поэтому перед ее вызовом нам необходимо перевести систему координат.

В зависимости от текущего игрока мы посчитаем, на каком здании стоит враг, и перенесём систему координат на его вершину. Из-за этого перевода мы также используем методы save и restore.

function checkGorillaHit() {
  const enemyPlayer = state.currentPlayer === 1 ? 2 : 1;
  const enemyBuilding =
    enemyPlayer === 1
      ? state.buildings.at(1) // Second building
      : state.buildings.at(-2); // Second last building

  ctx.save();

  ctx.translate(
    enemyBuilding.x + enemyBuilding.width / 2,
    enemyBuilding.height
  );

  drawGorillaBody();
  let hit = ctx.isPointInPath(state.bomb.x, state.bomb.y);

  drawGorillaLeftArm(enemyPlayer);
  hit ||= ctx.isPointInStroke(state.bomb.x, state.bomb.y);

  drawGorillaRightArm(enemyPlayer);
  hit ||= ctx.isPointInStroke(state.bomb.x, state.bomb.y);

  ctx.restore();
  
  return hit;
}

Точно так же мы можем обнаружить, ударились ли мы по одной из рук гориллы. Руки рисуются штрихом, поэтому в данном случае isPointInPath мы воспользуемся isPointInStroke методом. Это обнаружение не сработало бы, если бы мы не увеличили точность обнаружения попадания раньше, потому что бомба могла легко перепрыгнуть через руку.

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

Как обработать результат обнаружения попадания

Как только мы наладим правильное обнаружение попаданий, пришло время, наконец, разобраться со случаями, когда мы поражаем здание, врага или бомба выходит за пределы экрана. Мы обновим animate функцию в последний раз. Единственное новое, что представлено ниже, — это блок кода двух if операторов внутри цикла.

Если мы попали в край экрана или в здание, это значит, что мы промахнулись. В этом случае мы поменяем игроков и вернемся к aiming фазе. Мы также повторно инициализируем позицию бомбы, чтобы переместить ее в руку нового игрока.

Как только мы пропустили врага, мы меняем игроков.
Как только мы пропустили врага, мы меняем игроков.

Затем мы просто вызовем draw функцию, чтобы обработать все остальное. Прелесть этой структуры в том, что нам нужно всего лишь изменить игру state, а draw функция сможет перерисовать всю сцену в соответствии с ней.

Затем мы воспользуемся return функцией animate, чтобы остановить цикл анимации. Обработчики событий снова активны (потому что мы снова в aiming фазе), и следующий игрок может сделать выстрел.

function animate(timestamp) {
  if (!previousAnimationTimestamp) {
    previousAnimationTimestamp = timestamp;
    requestAnimationFrame(animate);
    return;
  }
  
  const elapsedTime = timestamp - previousAnimationTimestamp;

  const hitDetectionPrecision = 10;
  for (let i = 0; i < hitDetectionPrecision; i++) {
    moveBomb(elapsedTime / hitDetectionPrecision); // Hit detection

    const miss = checkFrameHit() || checkBuildingHit();
    const hit = checkGorillaHit();

    // Handle the case when we hit a building or the bomb got off-screen
    if (miss) {
      state.currentPlayer = state.currentPlayer === 1 ? 2 : 1; // Switch players
      state.phase = "aiming";
      initializeBombPosition();
      draw();
      return;
    }

    // Handle the case when we hit the enemy
    if (hit) {
      state.phase = "celebrating";
      announceWinner();
      draw();
      return;
    }
  }

  draw();

  // Continue the animation loop
  previousAnimationTimestamp = timestamp;
  requestAnimationFrame(animate);
}

Если мы попали во врага, нам нужно переключиться на celebrating фазу. Мы объявим победителя с помощью функции, которую собираемся рассмотреть в следующем разделе. Затем мы перерисовываем сцену с празднующей гориллой и возвращаемся, чтобы остановить цикл анимации.  

Как только мы поражаем врага,&nbsp;draw функция перерисовывает сцену с изображением празднующей гориллы.&nbsp;&nbsp;
Как только мы поражаем врага, draw функция перерисовывает сцену с изображением празднующей гориллы.  

Чтобы убедиться, что наш код не выдает ошибку, давайте добавим заполнитель для функции announceWinner.  

function announceWinner() { 
    // ... 
} 

Как объявить победителя

Как только мы поразим врага, мы должны объявить победителя. Для этого мы добавим еще одну информационную панель в HTML, в которой будет указано, кто выиграл игру, и кнопка перезапуска. В нижней части готового HTML-файла вы можете найти панель congratulations.

Панель&nbsp;congratulations объявляет победителя&nbsp;&nbsp;
Панель congratulations объявляет победителя  
Добавляем congratulations панель в наш HTML-файл
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Gorillas</title>
    <link rel="stylesheet" href="index.css" />
    <script src="index.js" defer></script>
  </head>
  <body>
    <canvas id="game"></canvas>

    <div id="info-left">
      <h3>Player 1</h3>
      <p>Angle: <span class="angle">0</span>°</p>
      <p>Velocity: <span class="velocity">0</span></p>
    </div>

    <div id="info-right">
      <h3>Player 2</h3>
      <p>Angle: <span class="angle">0</span>°</p>
      <p>Velocity: <span class="velocity">0</span></p>
    </div>

    <div id="bomb-grab-area"></div>

    <div id="congratulations">
      <h1><span id="winner">?</span> won!</h1>
      <button id="new-game">New Game</button>
    </div>
  </body>
</html>

По умолчанию эта панель скрыта и появляется только в конце игры. Когда он появится, он должен находиться в центре экрана. Мы обновим наш CSS-файл в соответствии с этим.

Обратите внимание, что мы добавили display: flex к элементу body еще несколько свойств, позволяющих центрировать все на экране. Затем мы устанавливаем position: absolute элемент congratulations и скрываем его по умолчанию.

Расширьте стиль элемента body и добавьте стиль панели #congratulations.
body {
  margin: 0;
  padding: 0;
  font-family: monospace;
  font-size: 14px;
  color: white;
  user-select: none;
  -webkit-user-select: none;
  display: flex;
  justify-content: center;
  align-items: center;
}

#info-left {
  position: absolute;
  top: 20px;
  left: 25px;
}

#info-right {
  position: absolute;
  top: 20px;
  right: 25px;
  text-align: right;
}

#bomb-grab-area {
  position: absolute;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background-color: transparent;
  cursor: grab;
}

#congratulations {
  position: absolute;
  visibility: hidden;
}

Затем, наконец, мы можем использовать эту панель, чтобы объявить победителя после завершения игры на JavaScript. Мы уже вызываем announceWinner функцию в нашей animate функции, поэтому пришло время ее реализовать.

Сначала где-то в начале файла мы настроим ссылки на congratulations саму панель и winner поле.

. . .

// Congratulations panel
const congratulationsDOM = document.getElementById("congratulations");
const winnerDOM = document.getElementById("winner");

. . .

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

function announceWinner() {
  winnerDOM.innerText = `Player ${state.currentPlayer}`;
  congratulationsDOM.style.visibility = "visible";
}

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

Как сбросить настройки на следующий раунд

В качестве последнего штриха давайте добавим обработчик событий для кнопки «New Game» на нашей панели поздравлений, чтобы иметь возможность перезагрузить игру. Мы уже добавили кнопку с идентификатором new-game в наш HTML.

Сначала в JavaScript мы создадим ссылку на эту кнопку, а затем добавим для нее обработчик событий. Этот обработчик событий просто вызывает newGame функцию.

Добавьте ссылку на кнопку новой игры и прикрепите к ней обработчик событий.
. . .

// Congratulations panel
const congratulationsDOM = document.getElementById("congratulations");
const winnerDOM = document.getElementById("winner");
const newGameButtonDOM = document.getElementById("new-game");

. . .

newGameButtonDOM.addEventListener("click", newGame);

Функция newGame должна сбросить все и сгенерировать новый уровень, чтобы мы могли начать новую игру. Однако на данный момент наша newGame функция не сбрасывает все. Он не сбрасывает элементы HTML, которые мы тем временем представили.

В качестве самого последнего шага мы убедимся, что congratulations элемент скрыт после начала новой игры, и сбросим значения угла и скорости на левой и правой информационных панелях до 0.

Сбросить HTML-элементы, как только мы начнем новую игру
function newGame() {
  // Initialize game state
  state = {
    scale: 1,
    phase: "aiming", // aiming | in flight | celebrating
    currentPlayer: 1,
    bomb: {
      x: undefined,
      y: undefined,
      velocity: { x: 0, y: 0 },
    },
    buildings: generateBuildings(),
  };

  calculateScale();

  initializeBombPosition();

  // Reset HTML elements
  congratulationsDOM.style.visibility = "hidden";
  angle1DOM.innerText = 0;
  velocity1DOM.innerText = 0;
  angle2DOM.innerText = 0;
  velocity2DOM.innerText = 0;
  
  draw();
}

Источник:

#JavaScript #HTML
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу