Учебное пособие по разработке игр на JavaScript — создание Gorillas с помощью HTML Canvas + JavaScript
В этом уроке по игре на JavaScript вы узнаете, как создать современную версию классической игры Gorillas 1991 года, используя простой JavaScript и элемент холста HTML.
В этой игре две гориллы бросают друг в друга взрывные бананы, и побеждает тот, кто первым ударит другого.
Здесь мы создадим всю игру с нуля. Сначала вы научитесь рисовать на элементе холста с помощью JavaScript. Вы увидите, как нарисовать фон, здания, горилл и бомбу. Мы не будем здесь использовать изображения — мы будем рисовать все с помощью кода.
Затем мы добавим некоторые взаимодействия и обработчики событий. Мы также расскажем, как прицеливаться, как анимировать бомбу по небу и как определить, попала ли бомба в другую гориллу или в здание.
На протяжении всего руководства мы будем использовать простой JavaScript. Чтобы получить максимальную пользу от этого руководства, вам необходимо иметь базовое понимание JavaScript. Но даже если вы новичок, вы все равно можете следовать инструкциям и учиться по ходу дела.
В этой статье мы упростим пару шагов. Для более подробной информации вы также можете посмотреть расширенное руководство на YouTube. В версии для YouTube мы также рассказываем, как сделать здания разрушаемыми, как анимировать руку гориллы, чтобы она следовала за движением перетаскивания во время прицеливания, сделать более приятную графику и добавить логику искусственного интеллекта, чтобы вы могли играть против компьютера.
Если вы застряли, вы также можете найти окончательный исходный код игры, которую мы собираемся создать, на GitHub.
Предыстория игры
Gorillas — игра 1991 года. В этой игре две гориллы стоят на вершинах случайно сгенерированных зданий и по очереди бросают друг в друга взрывные бананы.
В каждом раунде игроки устанавливают угол и скорость броска и продолжают его совершенствовать, пока не попадут в другую гориллу. На летающую банановую бомбу действует гравитация и ветер.
Мы собираемся реализовать современную версию этой игры. Оригинальная игра не поддерживала мышь. Каждый раунд игрокам приходилось вводить угол и скорость с помощью клавиатуры. Мы собираемся реализовать его с поддержкой мыши и более красивой графикой.
Вы можете опробовать расширенную версию игры на CodePen. Попробуйте, прежде чем мы углубимся в это.
Настройка проекта
Для реализации этой игры нам понадобится простой файл HTML, CSS и JavaScript. Если хотите, вы можете разбить логику JavaScript на несколько файлов, но для простоты мы собрали все в одном месте.
Поскольку мы используем простой JavaScript и не используем никаких библиотек и сторонних инструментов, нам не нужны никакие компиляторы или сборщики. Мы можем запускать все прямо в браузере.
Чтобы упростить процесс, я рекомендую установить расширение Live Server VS Code. Установив это расширение, вы можете просто щелкнуть правой кнопкой мыши HTML-файл и выбрать «Open with Live Server’». Это запустит живую версию игры в браузере.
Это означает, что нам не нужно нажимать кнопку «Обновить» в браузере каждый раз, когда мы вносим изменения в код. Достаточно сохранить изменения в файле и браузер обновится автоматически.
Обзор игровой логики
Прежде чем мы перейдем к деталям, давайте пройдемся по основным частям игры.
Игра движима объектом state
. Это объект JavaScript, который служит метаданными для игры. От размера зданий до текущего положения бомбы — оно включает в себя множество вещей.
Состояние игры включает в себя такие вещи, как чья сейчас очередь, целимся ли мы сейчас или бомба уже летит в воздухе? И так далее. Это все переменные, которые нам нужно отслеживать. Когда мы рисуем игровую сцену, мы рисуем ее на основе состояния игры.
Тогда у нас есть draw
функция. Эта функция будет рисовать практически все, что есть на экране. Он рисует фон, здания, горилл и банановые бомбы. Эта функция рисует весь экран сверху вниз каждый раз, когда мы ее вызываем.
Мы добавим обработку событий для прицеливания бомб и реализуем функцию throwBomb
, запускающую цикл анимации. Функция animate
перемещает бомбы по небу.
Эта функция будет отвечать за расчет точного положения бомбы, когда она летит по воздуху в каждом цикле анимации. Кроме того, ему также необходимо выяснить, когда движение закончится. При каждом движении мы проверяем, не попали ли мы в здание или врага, не попала ли бомба за пределы экрана. Мы также добавим обнаружение попаданий.
Теперь давайте пройдемся по нашим исходным файлам.
Исходный HTML-файл
Наш первоначальный HTML-файл будет очень простым. В заголовок мы добавим ссылку на нашу таблицу стилей и наш файл JavaScript. Обратите внимание, что я использую ключевое слово defer
, чтобы гарантировать, что сценарий будет выполнен только после анализа остальной части документа.
В тело мы добавим элемент canvas
. Мы собираемся рисовать этот элемент с помощью JavaScript. Почти все, что мы видим на экране, будет находиться в этом элементе холста. Вот код:
<!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 тоже очень простой. Мы не можем ничего стилизовать внутри элемента холста, поэтому здесь мы стилизуем только другие имеющиеся у нас элементы.
body {
margin: 0;
padding: 0;
}
Позже, когда мы добавим больше элементов в HTML, мы также обновим этот файл. А пока давайте убедимся, что наш холст может поместиться на весь экран. По умолчанию браузеры имеют тенденцию добавлять небольшие поля или отступы вокруг тела. Давайте удалим это.
Основные части нашего файла JavaScript
Большая часть логики будет в нашем файле JavaScript. Давайте пройдемся по основным частям этого файла и определим несколько функций-заполнителей:
- Мы объявляем игровой объект
state
и кучу служебных переменных. Он будет содержать метаданные нашей игры. На данный момент это пустой объект. Мы инициализируем его значение, когда доберемся до функцииnewGame
. - Затем у нас есть ссылки на каждый элемент HTML, к которому нам нужен доступ из JavaScript. На данный момент у нас есть только ссылка на элемент
<canvas>
. Мы получаем доступ к этому элементу по идентификатору. - Мы инициализируем состояние игры и рисуем сцену, вызывая функцию
newGame
. Это единственный вызов функции верхнего уровня. Эта функция отвечает как за инициализацию игры, так и за ее сброс. - Мы определяем функцию
draw
, которая рисует всю сцену на элементе холста в зависимости от состояния игры. Нарисуем фон, здания, горилл и бомбу. - Мы настраиваем обработчики событий для событий
mousedown
,mousemove
иmouseup
. Мы собираемся использовать их для прицеливания. - Событие
mouseup
запуститthrowBomb
функцию, которая запускает основной цикл анимации. Функцияanimate
будет манипулировать состоянием в каждом цикле анимации и вызыватьdraw
функцию для обновления экрана.
// 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
, когда бомба находится в руке гориллы и обработчики событий активны. Затем, как только вы бросите бомбу, игра перейдет в in flight
фазу. На этом этапе обработчики событий деактивируются, и функция animate
перемещает бомбу по небу. Мы также добавляем обнаружение попаданий, чтобы знать, когда следует остановить анимацию.
Эти две игровые фазы повторяют друг друга снова и снова, пока одна из горилл не столкнется с другой. Как только мы поражаем врага, игра переходит в фазу celebrating
. Рисуем гориллу-победителя, показываем экран поздравления и кнопку перезапуска игры.
Как инициализировать игру
Игра инициализируется функцией newGame
. Это сбрасывает игру state
, генерирует новый уровень и вызывает функцию draw
для отрисовки всей сцены.
Давайте пройдемся по тому, что у нас есть изначально в объекте state
:
- Во-первых, у нас есть свойство игры
phase
, которое может иметь значениеaiming
,in 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() {
// ...
}
. . .
Пример: рисование прямоугольника
Давайте рассмотрим несколько быстрых примеров. Они пока не являются частью нашей игры, они просто послужат введением.
Самое простое, что мы можем сделать, это залить прямоугольник.
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
, чтобы нарисовать руки и лицо гориллы.
Теперь, когда мы закончили с этим введением, давайте вернемся к нашей игре и посмотрим, что находится внутри функции draw
.
Как перевернуть систему координат вверх дном
Когда мы используем холст, у нас есть система координат с началом координат в верхнем левом углу окна браузера, которая растет вправо и вниз. Это соответствует тому, как работают веб-сайты в целом. Все идет слева направо и сверху вниз. Это значение по умолчанию, но мы можем это изменить.
Когда мы говорим об играх, удобнее идти снизу вверх. Например, когда мы рисуем здания, они могут начинаться снизу, и нам не нужно выяснять, где находится нижняя часть окна.
Мы можем использовать метод translate
для смещения всей системы координат в левый нижний угол. Нам просто нужно сдвинуть систему координат вниз по оси Y на размер окна браузера.
Как только мы это сделаем, координата Y по-прежнему будет расти вниз. Мы можем перевернуть его, используя метод scale
. Установка отрицательного числа для вертикального направления перевернет всю систему координат вверх дном.
// 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
поставляется в паре с методом save
. save
служит контрольной точкой, к которой может вернуться метод 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
. Эту функцию мы еще не реализовали.
. . .
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
дважды. Рисуем двух горилл: одну на второй крыше и одну на предпоследней крыше. Они в основном идентичны, но при прицеливании отражают друг друга. Когда левый целится, он поднимает левую руку, а когда целится правый, он поднимает правую руку.
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 и мы вытягиваем player
1, то левая рука поднимается вверх (первый случай выше). Левая рука также поднимается вверх, если мы рисуем гориллу celebrating
(также первый случай выше).
В этих случаях мы начинаем с одной и той же точки (кривая начинается с одного и того же метода moveTo
), но задаем разные координаты для контрольной точки и конечной точки кривой.
Руки рисуем штрихами. Поэтому вместо того, чтобы заканчивать путь методом fill
, мы используем метод stroke
.
Мы также настроили его по-другому. Вместо использования свойства fillStyle
здесь мы задаем цвет strokeStyle
и толщину руки с помощью этого свойства lineWidth
.
Рисование правой руки такое же, за исключением того, что горизонтальные координаты и некоторые условия перевернуты. Мы могли бы объединить эти две функции, но для ясности я оставил их отдельно.
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
. Прежде чем мы приступим к рисованию бомбы, давайте сначала выясним ее положение.
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
, чтобы установить его значение. Этот вызов функции должен произойти после того, как мы сгенерируем наши здания, поскольку масштабирование зависит от размера города. Это также должно произойти до того, как мы инициализируем положение бомбы, потому что позже это будет зависеть от масштабирования.
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
, все, что мы будем рисовать после этого, будет масштабироваться.
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
фаза.
Затем, как только мы отпустим мышь, бомба полетит по небу. У нас будет цикл анимации, который будет перемещать бомбу по небу. Это in flight
фаза.
На этом этапе этот цикл анимации также будет включать обнаружение попаданий. Нам нужно знать, попала ли бомба во врага, в здание или вышла за пределы экрана. Если мы ударим по зданию или бомба уйдет за пределы экрана, мы поменяем игроков и снова вернемся к aiming
фазе. Если мы попали во врага, то попадаем в 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
элемент. Сюда не будет включаться бомба, которую мы видим на экране (которая уже является частью холста), но это будет невидимая область вокруг нее, служащая целью события.
. . .
<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.
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`;
}
Как только мы это добавим, мы не увидим никакой разницы на экране, потому что этот элемент невидим. Но как только мы наведем курсор на бомбу, мы увидим, что курсор изменится на захват.
Обработка событий
Теперь, когда все настроено, мы наконец можем настроить обработку событий. Это будет простая реализация перетаскивания, в которой мы будем слушать события mousedown
, mousemove
и 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
события мы получили вертикальную и горизонтальную составляющие скорости. Но в пользовательском интерфейсе мы хотим отображать угол и общую скорость броска. Нам нужно использовать некоторую тригонометрию для расчета этих значений.
Для скорости мы рассчитаем 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
в качестве аргумента. Давайте углубимся в эту функцию анимации.
. . .
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 сегментов с помощью цикла.
Если мы разобьем каждый цикл анимации на десять сегментов, это также означает, что теперь мы вызываем функцию 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
фазу. Мы объявим победителя с помощью функции, которую собираемся рассмотреть в следующем разделе. Затем мы перерисовываем сцену с празднующей гориллой и возвращаемся, чтобы остановить цикл анимации.
Чтобы убедиться, что наш код не выдает ошибку, давайте добавим заполнитель для функции announceWinner
.
function announceWinner() {
// ...
}
Как объявить победителя
Как только мы поразим врага, мы должны объявить победителя. Для этого мы добавим еще одну информационную панель в HTML, в которой будет указано, кто выиграл игру, и кнопка перезапуска. В нижней части готового HTML-файла вы можете найти панель congratulations
.
<!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 {
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.
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();
}