Привет всем!
В прошлой статье мы начали создавать браузерную 2D игру на языке программирования JavaScript с использованием элемента Canvas. Был создан прототип игры, где вместо главного героя у нас имеется черный прямоугольник, стреляющий желтыми "пулями", а враги представляют из себя движущиеся прямоугольники красного и зеленого цвета. Для победы главный герой должен уничтожить n-ое количество противников за определенное время. Вот как это выглядело:
Давайте продолжим совершенствовать нашу игру.
В комментариях к первой части были даны ценные рекомендации по улучшению алгоритма. О них расскажу в конце статьи.
Поиграть в полную версию можно тут. Проект лежит здесь.
Содержание
Бэкграунд
В файле index.html уже добавлены все необходимые .png-изображения, которые будут использованы в нашей игре. Для удобства они распределены на условные блоки (отделены друг от друга комментариями). В блоке "Environment" находятся картинки для создания игрового фона:
Загружаем картинки в index.html
В файле style.css все изображения нужно "скрыть", чтобы они не отображались в качестве бэкграунда окна браузера, см. код 2.
Скрываем изображения в файле style.css
Если все равно не понятно для чего всем картинкам установлено свойство display со значением none — можно удалить какое-либо изображение (точнее его id) из этого списка, а лучше несколько, и посмотреть что из этого выйдет.
Давайте уже наконец установим фон.
В файл Layer.js добавим такой код:
class Layer {
constructor(game, image, speedModifier) {
this.game = game;
this.image = image;
this.speedModifier = speedModifier;
this.width = 1768;
this.height = 500;
this.x = 0;
this.y = 0;
}
update() {
if (this.x <= -this.width) this.x = 0;
else this.x -= this.game.speed * this.speedModifier;
}
draw(context) {
context.drawImage(this.image, this.x, this.y);
context.drawImage(this.image, this.x + this.width, this.y);
}
}
Как вы уже догадались, класс Layer будет отвечать за определенный слой игрового фона и таких слоев будет несколько (по числу картинок). Свойство game
— это объект нашей игры; image
— картинка для фона; speedModifier
— коэффициент изменения скорости движения, — будет задавать изменение скорости движения слоев относительно скорости движения самой игры, таким образом задний фон можно будет "двигать" быстрее/медленнее остальных объектов игры, а также каждому слою можно будет придать свою скорость движения. Остальные свойства — это размер картинки и ее "стартовые координаты" на игровом поле.
Метод update() заставляет двигаться картинку слоя справа налево. А благодаря второй строчке метода draw() — мы после первого изображения сразу рисуем второе, таким образом, после пересечения левой границы игрового поля — та же самая картинка будет сразу же появляться из правой границы, создавая эффект "бесконечного (непрерывного) слоя".
Еще один момент. Автор видео говорит о некотором "прерывании" (в оригинальном видео stutter — заикание/запинание, время на видео 1:11:00) при движении слоев. И чтобы избавиться от этого прерывания — предлагается удалить слово else
в методе update(), чему мы тоже последуем.
Предлагаю читателю самому поэкспериментировать с методами обновления и прорисовки слоев, чтобы самому понять логику и назначение каждой строчки кода.
В файле Background.js будет одноименный класс со следующим содержимым:
class Background {
constructor(game) {
this.game = game;
this.image1 = document.getElementById('layer1');
this.layer1 = new Layer(this.game, this.image1, 3.2);
this.layers = [this.layer1];
}
update() {
this.layers.forEach(layer => layer.update());
}
draw(context) {
this.layers.forEach(layer => layer.draw(context));
}
}
В качестве свойств, помимо game, у него присутствуют картинка (image1
), полученная с помощью метода getElementById; свойство layer1
, которое представляет собой экземпляр класса Layer, а также массив layers хранящий в себе все слои. В методах update() и draw() вызываем соответствующие методы обновления и рисования класса Layer на всех элементах массива layers
.
Чтобы это все заработало, в конструкторе класса Game необходимо создать экземпляр класса Background:
this.background = new Background(this);
и определить параметр игровой скорости (по умолчанию, очевидно, равный единице):
this.speed = 1;
В метод update() класса Game пропишем обновление бэкграунда:
this.background.update();
а в методе draw() его нарисуем (причем лучше поместить рисование фона перед всеми остальными строками, чтобы фон был позади других объектов):
this.background.draw(context);
После сохранения и обновления страницы вот что у нас должно получиться:
Добавим еще слоев, используя три других изображения и получим итоговый результат:
Но у нас есть проблемка. Главный игрок и враги отображаются впереди всех слоев:
По задуманному, необходимо, чтобы четвертый добавленный слой отображался на первом плане, создавая эффект, как будто игрок и враги двигаются на специально выделенной "дорожке" за ним.
Это можно легко исправить.
Корректировка отображения слоев
В классе Background удаляем последний слой из массива layers:
в методе update() класса Game будем обновлять этот слой отдельно:
а в методе draw() рисуем этот слой в "самом конце", т.е. после отрисовки всех других объектов:
После исправлений получаем ожидаемый результат:
Анимация персонажей. Спрайтшиты
Черный прямоугольник смотрится не очень красиво. Давайте превратим его в механизированного морского конька. Для этой цели будем использовать т.н. спрайтшиты. Если своими словами — то это совокупность картинок (кадров) персонажа, которые создают анимацию данного персонажа, см. рисунок 6.
В этой статье я не буду рассказывать как создавать спрайтшиты. Помимо всем известных Adobe Photoshop и Illustrator, есть и более легкие, специализированные инструменты, предназначенные для создания спрайтшитов для 2D игр. Автор "рекламирует" такие инструменты как dragonbones и spine. Первый из них еще и бесплатный. А вот здесь есть неплохая статья про создание 2D-анимаций в Unity 3D.
Я же возьму готовый спрайтшит, который для нас подготовил автор и буду на основе него создавать анимацию главного персонажа.
Проверим, что в файле index.html уже добавлен спрайтшит игрока:
а в файле style.css не забываем добавить этому элементу свойство display со значением none, как и всем остальным картинкам в нашей игре (об этом я писал выше).
В класс Player добавим следующий код:
В конструкторе класса появились новые свойства: image
— спрайтшит; frameX
, frameY
— координаты "кадра" игрока на спрайтшите (см. рисунок 6). При этом frameX
изменяется от 0 до maxFrame
= 38 (количество кадров по горизонтали, с учетом нумерации от нуля), а frameY
принимает значения 0 или 1. В первом случае у нас будет обычный режим игрока, при котором он стреляет только из носа, а при frameY
= 1 — наш морской конь превращается в заряженного энергией монстра и способен стрелять из носа и хвоста одновременно.
В метод update() добавлен кусок кода, который будет инкрементировать значение frameX
, а в draw() — добавлен метод drawImage() элемента канвас с 9-ю аргументами, который будет из спрайтшита "вырезать" необходимые кадры. Если спрайтшит сформирован правильно, без каких-либо лишних "пробелов", то метод drawImage() отработает четко и анимация получится корректной.
И как итог получаем анимацию главного игрока:
Уберем черный прямоугольник. Для этого в метод draw() того же класса Player внесем изменения. Удалим строку:
context.fillStyle = 'black';
а метод context.fillRect()
заменим на context.strokeRect()
, который будет рисовать просто черную рамку.
Помимо этого добавим возможность "включать/отключать" эту черную рамку для процесса отладки (например, для отладки коллизий между игроком и врагами). Для этого в класс Game добавим свойство debug:
this.debug = true
а в класс InputHandler обработку нажатия клавиши d
для включения/отключения режима дебага:
Вот как будет реагировать наша игра на нажатие клавиши 'd':
Анимация врагов
Пришло время анимировать врагов. В файле index.html у нас уже есть код, который подгружает необходимые изображения:
angler1
и angler2
— это враги обыкновенные, которые просто хотят покалечить нашего морского коня, а вот lucky
— это маленькая рыбка в желтом шарике, которая при столкновении с главным игроком "дает ему сил и энергии" — возможность на короткий промежуток времени стрелять сразу из носа и хвоста, однако, ее можно также уничтожить получив за нее определенные очки.
В класс Angler1 внесем следующие изменения:
здесь как и для главного игрока — мы получаем картинку и рандомно выбираем значение для this.frameY
, чтобы наш враг имел различные вариации.
Аналогичные изменения внесем и в класс Angler2.
Для базового класса врага - Enemy выполним следующие корректировки:
добавили свойства this.frameX
,this.frameY
и this.maxFrame
, в метод update() добавили логику обновления this.frameX, а в методе draw() воспользовались уже знакомым методом context.drawImage()
элемента канвас с 9-ю аргументами для "вычленения" кадров из спрайтшита.
Также перенесем свойства lives и score из базового класса Enemy в дочерние, чтобы мы могли каждому типу врага назначить свои собственные значения жизней и количество очков this.score
Перенос базовых свойств из класса Enemy
Удаляем свойства из класса Enemy:
и добавляем свойства в класс Angler1:
и Angler2:
Рыбка-удача и Power-up режим
Реализуем функционал столкновения главного игрока с рыбкой-удачей (Lucky Fish) и переход морского конька в режим Power-up (честно говоря, я не знаю правильный перевод данного выражения на русский язык, поэтому везде далее буду употреблять английское название, прошу не судить строго).
Создадим класс для нашей "золотой рыбки". В файл LuckyFish.js добавим следующий код:
class LuckyFish extends Enemy {
constructor(game) {
super(game);
this.width = 99;
this.height = 95;
this.y = Math.random() * (this.game.height * 0.95 - this.height);
this.image = document.getElementById('lucky');
this.frameY = Math.floor(Math.random() * 2);
this.lives = 5;
this.score = 15;
this.type = 'lucky';
}
}
почти все свойства этого класса должны быть Вам уже понятны из предыдущих разделов, поэтому останавливаться на них не буду. Единственное отличие — свойство this.type
со значением 'lucky', которое говорит о том, что это не совсем враг, а дружелюбная рыбка приносящая удачу.
К свойствам класса Player добавим три новых свойства:
this.powerUp = false; // говорит о том, активирован ли режим
this.powerUpTimer = 0; // текущий счетчик режима
this.powerUpLimit = 10000; // длительность режима (10 сек.)
В метод update() класса Player добавим такой код:
// power up
if (this.powerUp) {
if (this.powerUpTimer > this.powerUpLimit) {
this.powerUpTimer = 0;
this.powerUp = false;
this.frameY = 0;
} else {
this.powerUpTimer += deltaTime;
this.frameY = 1;
this.game.ammo += 0.1;
}
}
данный блок кода обнуляет счетчик режима Power-up при достижении лимита (в данном случае 10 сек.), изменяет значение this.frameY
, чтобы морской конь менял свой окрас, а также увеличивает скорость пополнения боеприпасов (this.game.ammo += 0.1
).
В класс Player добавим следующие методы:
shootBottom() {
if (this.game.ammo > 0) {
this.projectiles.push(new Projectile(this.game, this.x + 80, this.y + 175));
this.game.ammo--;
}
}
enterPowerUp() {
this.powerUpTimer = 0;
this.powerUp = true;
if (this.game.ammo < this.game.maxAmmo) this.game.ammo = this.game.maxAmmo;
}
метод shootBottom()
позволит стрелять игроку из хвоста, а enterPowerUp()
будет активировать режим Power-up.
В тело метода shootTop()
добавим строку:
// если активирован режим Power-up, то стреляем также и из хвоста
if (this.powerUp) this.shootBottom();
В метод update() класса Game теперь будем передавать параметр deltaTime при обновлении состояния игрока:
this.player.update(deltaTime);
Если игрок столкнулся с рыбкой удачей, то активируем режим Power-up — сделаем это таким образом:
// Если наш игрок столкнулся с Рыбкой-Удачей
if (enemy.type === 'lucky')
this.player.enterPowerUp(); // Активируем режим Power-up
else if (!this.gameOver) this.score--; // Если столкнулся с другим врагом - отнимаем из жизни игрока одну жизнь
Метод addEnemy() тоже претерпит изменения:
addEnemy() {
const randomize = Math.random();
if (randomize < 0.3) this.enemies.push(new Angler1(this));
else if (randomize < 0.6) this.enemies.push(new Angler2(this));
else this.enemies.push(new LuckyFish(this)); // добавляем Рыбку-Удачу
}
В классе UI поменяем цвет количества боеприпасов при активации режима Power-up:
// рисуем количество патронов в левом верхнем углу игрового поля
if (this.game.player.powerUp) context.fillStyle = '#ffffbd'; // устанавливаем цвет
Здесь список изменений по данному разделу.
Пули и улучшенный шрифт
На данный момент у нас вместо пуль из носа и хвоста игрока вылетают простые желтые прямоугольники. Давайте подправим это.
В свойства класса Projectile добавим:
this.image = document.getElementById('projectile');
а метод draw() будет теперь таким:
draw(context) {
context.drawImage(this.image, this.x, this.y);
}
Здесь нам не нужно использовать перегрузку метода drawImage() с 9-ю аргументами, т.к. изображение пули в файле .png состоит всего из одного кадра.
Готово.
Перейдем к шрифтам.
Хотелось бы конечно оставить в игре русские надписи типа "Победа! Отличная работа!", но все-таки сделаю так, как сделал автор оригинала — добавлю Google Fonts с "экзотическим" шрифтом Bangers.
Перейдем на сайт с нужным шрифтом и выполним следующие действия:
Подключаем шрифт:
Копируем ссылки
и добавляем их в index.html
Затем копируем css правила:
и добавляем их в файл style.css к элементу canvas:
В классе UI изменим семейство шрифтов на Bangers:
this.fontFamily = 'Bangers';
и обновим текстовые сообщения:
if (this.game.isWin()) {
message1 = 'Most Wondrous!';
message2 = 'Well done explorer!';
} else {
message1 = 'Blazes!';
message2 = 'Get my repair kit and try again!';
}
Отлетающие шестеренки. Вращение и гравитация
Так как в нашей игре враги представляют собой механизированных рыб, состоящих из различных механизмов и шестеренок (частиц), то будет эффектным реализовать функционал вылета из врагов этих шестеренок при определенных условиях, а именно:
когда во врага попадает пуля — из врага вылетает одна шестеренка;
когда враг сталкивается с морским коньком — враг распадается на количество шестеренок, равное оставшемуся количеству жизней врага;
Итак, создадим класс Particle (частица):
class Particle {
constructor(game, x, y) {
this.game = game;
this.x = x;
this.y = y;
this.image = document.getElementById('gears');
this.frameX = Math.floor(Math.random() * 3);
this.frameY = Math.floor(Math.random() * 3);
this.spriteSize = 50;
this.sizeModifier = (Math.random() * 0.5 + 0.5).toFixed(1);
this.size = this.spriteSize * this.sizeModifier;
this.speedX = Math.random() * 6 - 3;
this.speedY = Math.random() * -15;
this.gravity = 0.5; // коэффициент увеличения скорости (ускорение)
this.markedForDeletion = false;
this.angle = 0; // начальный угол поворота частицы
this.va = Math.random() * 0.2 - 0.1; // скорость поворота частицы
this.bounced = 0; // количество ударов (отскоков) частицы от поверхности "земли"
this.bottomBounceBoundary = Math.random() * 80 + 60; // границы касания частиц с поверхностью земли
}
update() {
this.angle += this.va;
this.speedY += this.gravity;
this.x -= this.speedX + this.game.speed;
this.y += this.speedY;
if (this.y > this.game.height + this.size || this.x < 0 - this.size) this.markedForDeletion = true;
if (this.y > this.game.height - this.bottomBounceBoundary && this.bounced < 2) {
this.bounced++;
this.speedY *= -0.5;
}
}
draw(context) {
context.save();
context.translate(this.x, this.y);
context.rotate(this.angle);
context.drawImage(this.image, this.frameX * this.spriteSize, this.frameY
* this.spriteSize, this.spriteSize, this.spriteSize, this.size * -0.5, this.size * -0.5, this.size, this.size);
context.restore();
}
}
Изображения шестеренок находятся в файле gears.png. Спрайтшит gears.png состоит из девяти кадров (спрайтов), см. рисунок 15.
Несколько комментариев относительно свойств класса Particle. x
и y
— начальные координаты появления частицы на игровом полотне; spriteSize
— размер в пикселях одной частицы (в нашем случае они квадратные); sizeModifier
— рандомный коэффициент изменения размера частиц (чтобы все шестеренки не были одного размера); size
— итоговый размер шестеренки; speedX
, speedY
— скорость движения шестеренки по осям Ox и Oy, соответственно; gravity
— коэффициент ускорения (увеличения скорости) частицы, благодаря которому будет создан эффект притяжения (гравитации).
Чтобы наши шестеренки вращались при падении, — добавим такие свойства, как angle
— угол поворота шестеренки (относительно собственной оси, т.е. центра); va
— скорость вращения. Частицы после ударения о поверхность будут отскакивать от нее несколько раз, — за количество отскоков будет отвечать свойство bounced
, а чтобы частицы не падали в одно и то же место — введем свойство bottomBounceBoundary
. Таким образом шестеренки будут падать в различных местах нашей "дорожки", см. рисунок 16:
В данном классе, как и во многих других, будет только два метода — update() и draw(context).
Тело метода update():
update() {
this.angle += this.va;
this.speedY += this.gravity;
this.x -= this.speedX + this.game.speed;
this.y += this.speedY;
if (this.y > this.game.height + this.size || this.x < 0 - this.size) this.markedForDeletion = true;
if (this.y > this.game.height - this.bottomBounceBoundary && this.bounced < 2) {
this.bounced++;
this.speedY *= -0.5;
}
}
в нем мы увеличиваем угол поворота angle
, рассчитываем скорость и координаты полета частицы, удаляем частицы из игры (this.markedForDeletion = true
), если они пересекли необходимые границы и регулируем количество отскоков с помощью свойства bounced
. Путем изменения величины и направления speedY
(this.speedY *= -0.5
) — наши частицы будут отскакивать, двигаясь вверх-вниз с уменьшением скорости.
В теле draw(context):
draw(context) {
context.save();
context.translate(this.x, this.y);
context.rotate(this.angle);
context.drawImage(this.image, this.frameX * this.spriteSize, this.frameY
* this.spriteSize, this.spriteSize, this.spriteSize, this.size * -0.5, this.size * -0.5, this.size, this.size);
context.restore();
}
воспользуемся методами save() и store() элемента canvas, о которых я рассказывал в первой части для "заморозки" состояния канваса перед поворотом и прорисовкой частиц. С помощью метода translate() мы как бы "переносим" 2D контекст канваса (его левый верхний угол) в центр шестеренки, чтобы вращение шестеренки, которое мы затем выполним с помощью метода rotate() было относительно её центра, а не относительно левого верхнего угла игрового полотна. Ну а про назначение метода drawImage() с 9-ю аргументами Вы уже должны знать.
Также внесем корректировки в класс Game.
Добавим массив для хранения шестеренок:
this.particles = [];
а также метод addParticles(number, enemy)
, который будет добавлять в этот массив number
шестеренок для врага enemy
:
addParticles(number, enemy) {
for (let i = 0; i < number; i++) {
this.particles.push(new Particle(this, enemy.x + enemy.width * 0.5, enemy.y + enemy.height * 0.5));
};
}
в методе update() класса Game будем вызывать метод addParticles() при условиях, перечисленных выше, т.е. когда враг столкнулся с морским коньком и при попадании пули во врага.
Hive Whale класс, дроны и взрывы
Автор в конце видео добавляет еще один тип врага с названием Hive Whale, при уничтожении которого из него вылетают (а точнее выплывают) пять небольших дронов, см. рисунок 17:
При уничтожении врага и при столкновении его с главным игроком — на месте врага появляется эффект взрыва, который бывает двух типов — Dust (пыль) и Fire (огонь).
Я не буду подробно описывать реализацию данного функционала (добавление класса-врага Hive Whale, дронов и взрывов), а оставлю лишь ссылки на изменения в проекте: добавление класса Hive Whale и дронов, реализация взрывов.
Оставлю читателю возможность самому разобраться с данными доработками, тем более что почти весь этот функционал подобен тому, что описано выше.
Адаптация для мобильных устройств
Как справедливо заметили в первой части, играть на мобильных устройствах в эту игру не представляется возможным. Давайте это исправим.
Во-первых, было бы неплохо как-то определять, является ли наше устройство мобильным, чтобы отделить логику для адаптации. Долго думать я не хотел, поэтому нашел в интернете кусочек кода, а точнее функцию, результат выполнения которой даст нам понять является ли наше устройство мобильным или нет.
Во-вторых, нам нужно привязать действия игрока (а именно, движения вверх/вниз и стрельбу) к тачпаду, чтобы можно было легко управлять игроком. Для работы с тачпадом в языке JavaScript необходимо обрабатывать такие события, как Touch events. Для хорошего понимания этих событий нужно написать еще одну статью. Вот тут есть очень хороший материал для тех, кто, как и я, первый раз сталкивается с событиями касания.
Ну и в-третьих, для стрельбы я все-таки решил сделать отдельную кнопку на игровом поле. Вот так вот! =) Мне показалось это достаточно удобным и "играбельным" вариантом.
Соберем теперь все это вместе.
В файле index.html добавим кнопку для стрельбы (по сути — это изображение) и поместим наш канвас и эту кнопку в div-элемент:
<div id="container">
<canvas id="canvas1"></canvas>
<img id="shoot_btn" src="Assets/shoot_button.png"></img>
</div>
саму картинку я нашел на просторах интернета и поместил в папку Assets:
Shoot button
С помощью CSS сделаем так, чтобы эта кнопка всегда отображалась в правом нижнем углу игрового поля:
Изменения в файле style.css
#canvas1 {
border: 5px solid black;
position: absolute;
/* top: 50%; */
/* left: 50%; */
/* transform: translate(-50%, -50%); */
background: #4d79bc;
/* max-width: 100%;
max-height: 100%; */
font-family: 'Bangers', cursive;
}
#description {
text-align: center;
margin: 20px auto 20px auto;
width: 1500px;
}
#container {
position: relative;
margin: auto;
width: 1510px;
height: 510px;
}
#shoot_btn {
position: absolute;
display: none;
width: 120px;
height: 120px;
bottom: 15px;
right: 45px;
}
Как вы заметили, по умолчанию эта кнопка не отображается (display: none;
). Это свойство я буду изменять программно в том случае, если устройство будет определено как мобильное.
Подкорректируем немного файл script.js:
function isMobileOrTablet() {
let check = false;
(function (a) { if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) check = true; })(navigator.userAgent || navigator.vendor || window.opera);
return check;
}
const game = new Game(canvas.width, canvas.height, isMobileOrTablet());
Здесь добавлено определение вышеупомянутой функции, которая определяет является ли устройство мобильным, а результат выполнения этой функции передается в конструктор класса Game.
Далее, создадим файл MobileDeviceAdapter.js с одноименным классом, который и будет отвечать за адаптацию нашей игры к мобильным устройствам. Вот его содержимое:
class MobileDeviceAdapter {
static handleShootButton(game) {
let shootButton = document.getElementById("shoot_btn");
shootButton.addEventListener('click', () => {
game.player.shootTop();
});
}
static handleTouchPad(game) {
let canvas = document.getElementById("canvas1");
let initialY;
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
initialY = e.touches[0].clientY;
});
canvas.addEventListener('touchmove', (e) => {
let deltaY = e.touches[0].clientY - initialY;
initialY = e.touches[0].clientY;
if (deltaY < 0) {
game.keys.add('ArrowUp');
game.keys.delete('ArrowDown');
} else if (deltaY > 0) {
game.keys.add('ArrowDown');
game.keys.delete('ArrowUp');
} else /* if (deltaY === 0) */ {
game.keys.clear();
}
});
canvas.addEventListener('touchend', (e) => {
game.keys.clear();
});
}
static handleUI() {
// показываем кнопку "Shoot"
document.getElementById("shoot_btn").style.display = "Block";
// и меняем надпись с правилами игры
document.getElementById("description").innerText = "Перемещай морского конька с помощью тачпада и используй кнопку в правом нижнем углу для стрельбы. \n" +
"Для победы необходимо за 30 секунд набрать 100 очков";
}
}
в классе находятся три статичных метода. В методе handleShootButton(game)
добавляем обработчик нажатия кнопки "Shoot"; метод handleUI()
корректирует правила игры для мобильных устройств и делает видимой кнопку для стрельбы. А вот самое интересное — это тело метода handleTouchPad(game)
, которое отвечает за обработку событий касания.
События касания будем обрабатывать прямо на элементе canvas. Если кратко — в момент касания (touchstart
) мы запоминаем текущую y-координату точки касания (initialY
). затем при движении вверх/вниз (событие touchmove
), мы находим разность между текущей координатой касания и initialY
и записываем эту разность в переменную deltaY
, а потом переприсваиваем initialY
текущую y-координату. Таким образом, по знаку deltaY
мы сможем понять куда направлено движение на тачпаде (вверх или вниз) и в зависимости от этого добавить/удалить из множестваgame.keys
соответствующие значения (ArrowUp или ArrowDown). Ну а затем в дело вступают уже знакомые вышеописанные методы, которые по набору значений в множествеgame.keys
двигают морского конька вверх или вниз. Как только движение на тачпаде останавливается (событие touchend
), мы очищаем множество game.keys
и игрок останавливается.
Одной из проблем, но не очень критичной, является то, что морской конь не может одновременно двигаться вверх/вниз и стрелять. Хотя это почти не мешает побеждать в игре. После не очень долгих усилий решить эту проблему для мобильных устройств я решил оставить так как есть. Если есть мысли как исправить эту проблему — буду рад услышать.
Ну и в конструкторе класса Game нужно вызвать три этих метода:
constructor(width, height, isMobileOrTablet) {
// ...
if (isMobileOrTablet) {
MobileDeviceAdapter.handleShootButton(this);
MobileDeviceAdapter.handleTouchPad(this);
MobileDeviceAdapter.handleUI();
} else {
this.input = new InputHandler(this);
}
}
Вот как это выглядит на моем мобильном устройстве:
В настройках мобильного браузера необходимо сделать вот такую настройку, чтобы "игра поняла", что мы используем именно мобильную версию. Данная настройка у меня выглядит следующим образом:
Настройка мобильной версии браузера
Заключение
Как и обещал в начале статьи, расскажу об исправлении некоторых проблем, которые заметили читатели первой части.
Для адаптации игры к вашей частоте смены кадров были внесены следующие изменения. Теперь параметр deltaTime передается во все методы update() для корректного расчета координат/скоростей/угла поворота объектов. Насчет смены спрайтовых анимаций, к сожалению, ничего не придумал.
Массив this.keys
в классе Game заменил на множество Set()
.
Для определения коллизий (столкновений) возможно и стоит использовать такой инструмент как Intersection Observer API, но в данной игре я решил все-таки оставить свою реализацию.
Надеюсь статья была для Вас полезной!
Всем добра и чистого кода!
Комментарии (11)
7ckingbest
11.01.2024 09:06Если я правильно понял, скорость вашей игры зависит от частоты обновления экрана. Это плохо, так как персонаж будет двигаться на экранах 360Гц в 6 раз быстрее, чем у пользователей с экранами 60Гц. Обычно, разделяют циклы игровой логики и отрисовки, чтобы пользователи получали одинаковый геймлей и нагрузку на CPU, вне зависимости от характеристик монитора.
john_samilin
Каждый раз, читая такие статьи, удивляюсь: вот у кого-то в компуктере есть видеокарта, способная рендерить сверх-реалистичные материалы и кучу полигонов, ему даже нейросети доступны для апскейла, а он пишет 2d игру со спрайтами.
Это как писать масляными красками неприличное слово на заборе.
В контексте браузера есть WebGL, WebGPU, которые могут сделать нереальные штуки типа подповерхностного рассеивания, гора готовых шейдеров, куча удобных библиотек к ним. Только бери и компонуй все это вместе
Tasmanjan
Не душни, может ему и не нужны сверх-реалик материалы и полигоны, а он больше по пикселям и 90м.
Фу таким быть чувак, он хоть что то интересное пишет, а не тесты отечественных автомобильных аккумуляторов.
john_samilin
не удивлюсь, если и телефон у него еще дисковый
это, кстати, абсолютно дженерик-статья, каких появляется по десятку в день, что тут интересного?
red-cat-fat
Так не все же статьи только для вас пишут, тут и начинающие программисты имеются
Rio
Тут всё просто, некоторым 3D неинтересно, не вштыривает, а 2D - интересно. Я сам такой, в свободное время балуюсь написанием (в стол) 2D-игрушки со спрайтами.
И, кстати, даже если отложить вкусы и привязанности, некоторое рациональное зерно здесь есть. В нише 2D гораздо проще можно сделать что-то крутое или особенное, "переплюнуть" других, условно говоря. А в 3D больших игроков не "переплюнешь", тем более в одиночку.
john_samilin
3d давно уже перестало быть атрибутом больших компаний, благо есть куча маркетплейсов (даже бесплатных) с 3d-ассетами
Algrinn
Не нужно делать 3D если у вас нет большой команды. Сложность вырастает в разы. Это в 90-стых кого-то можно было удивить 3D графикой и полностью игнорировать сюжет и т.д. Сейчас это уже не сработает. Максимум мод можно к другой игре написать. Другой вопрос, что люди даже 2D делают полный хлам. Некачественная графика, анимация, геймдизайн, саундизайн, сюжет.
john_samilin
в том-то и дело, что чаще всего приверженность пикселям и ламповости - это очень удобный способ отвлечь внимание от некачественных составляющих игры
usamovara
Цель тут была обучающая, на чистом джаваскрипте написать без библиотек:
Creative coding experiments, generative art & game development with vanilla JavaScript & HTML canvas.
No frameworks and no libraries. Every project is explained line by line as I write the code using just plain vanilla JavaScript & HTML5 canvas element.
Важное тут как раз - без библиотек! И за это Автору и Переводчику - спасибо!