[Перевод] Пишем 2D игру на JavaScript и Canvas. Часть 2. Графика

Привет всем!

В прошлой статье мы начали создавать браузерную 2D игру на языке программирования JavaScript с использованием элемента Canvas. Был создан прототип игры, где вместо главного героя у нас имеется черный прямоугольник, стреляющий желтыми «пулями», а враги представляют из себя движущиеся прямоугольники красного и зеленого цвета. Для победы главный герой должен уничтожить n-ое количество противников за определенное время. Вот как это выглядело:

Рисунок 1. Прототип игры

Рисунок 1. Прототип игры

Давайте продолжим совершенствовать нашу игру.

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

Поиграть в полную версию можно тут. Проект лежит здесь.

Содержание

Бэкграунд

В файле index.html уже добавлены все необходимые .png-изображения, которые будут использованы в нашей игре. Для удобства они распределены на условные блоки (отделены друг от друга комментариями). В блоке «Environment» находятся картинки для создания игрового фона:

Загружаем картинки в index.html

Код 1. В блоке Environment находятся изображения для создания игрового бэкграунда

Код 1. В блоке Environment находятся изображения для создания игрового бэкграунда

В файле style.css все изображения нужно «скрыть», чтобы они не отображались в качестве бэкграунда окна браузера, см. код 2.

Скрываем изображения в файле style.css

Код 2. Скрываем изображения.

Код 2. Скрываем изображения.

Если все равно не понятно для чего всем картинкам установлено свойство 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);

После сохранения и обновления страницы вот что у нас должно получиться:

Рисунок 2. Игровой фон (1 слой).

Рисунок 2. Игровой фон (1 слой).

Добавим еще слоев, используя три других изображения и получим итоговый результат:

Рисунок 3. Многослойный игровой фон (4 слоя). Параметр изменения скорости speedModifier для каждого слоя имеет свое собственное значение.

Рисунок 3. Многослойный игровой фон (4 слоя). Параметр изменения скорости speedModifier для каждого слоя имеет свое собственное значение.

Но у нас есть проблемка. Главный игрок и враги отображаются впереди всех слоев:

Рисунок 4. Некорректное отображение слоев. Все они располагаются позади игрока и врагов.

Рисунок 4. Некорректное отображение слоев. Все они располагаются позади игрока и врагов.

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

Это можно легко исправить.

Корректировка отображения слоев

В классе Background удаляем последний слой из массива layers:

Код 3. Удаление слоя из массива.

Код 3. Удаление слоя из массива.

в методе update () класса Game будем обновлять этот слой отдельно:

Код 4. Отдельное обновление последнего слоя.

Код 4. Отдельное обновление последнего слоя.

а в методе draw () рисуем этот слой в «самом конце», т.е. после отрисовки всех других объектов:

Код 5. Отрисовка объектов.

Код 5. Отрисовка объектов.

После исправлений получаем ожидаемый результат:

Рисунок 5. Корректное расположение объектов позади 4-ого слоя.

Рисунок 5. Корректное расположение объектов позади 4-ого слоя.

Анимация персонажей. Спрайтшиты

Черный прямоугольник смотрится не очень красиво. Давайте превратим его в механизированного морского конька. Для этой цели будем использовать т.н. спрайтшиты. Если своими словами — то это совокупность картинок (кадров) персонажа, которые создают анимацию данного персонажа, см. рисунок 6.

Рисунок 6. Спрайтшит для нашего игрока. Меняя кадры - получим анимацию персонажа.

Рисунок 6. Спрайтшит для нашего игрока. Меняя кадры — получим анимацию персонажа.

В этой статье я не буду рассказывать как создавать спрайтшиты. Помимо всем известных Adobe Photoshop и Illustrator, есть и более легкие, специализированные инструменты, предназначенные для создания спрайтшитов для 2D игр. Автор «рекламирует» такие инструменты как dragonbones и spine. Первый из них еще и бесплатный. А вот здесь есть неплохая статья про создание 2D-анимаций в Unity 3D.

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

Проверим, что в файле index.html уже добавлен спрайтшит игрока:

Код 6. Добавляем спрайтшит к проекту.

Код 6. Добавляем спрайтшит к проекту.

а в файле style.css не забываем добавить этому элементу свойство display со значением none, как и всем остальным картинкам в нашей игре (об этом я писал выше).

В класс Player добавим следующий код:

Код 7. Создание анимации игрока.

Код 7. Создание анимации игрока.

В конструкторе класса появились новые свойства: image — спрайтшит; frameX, frameY — координаты «кадра» игрока на спрайтшите (см. рисунок 6). При этом frameX изменяется от 0 до maxFrame = 38 (количество кадров по горизонтали, с учетом нумерации от нуля), а frameY принимает значения 0 или 1. В первом случае у нас будет обычный режим игрока, при котором он стреляет только из носа, а при frameY = 1 — наш морской конь превращается в заряженного энергией монстра и способен стрелять из носа и хвоста одновременно.

В метод update () добавлен кусок кода, который будет инкрементировать значение frameX, а в draw () — добавлен метод drawImage () элемента канвас с 9-ю аргументами, который будет из спрайтшита «вырезать» необходимые кадры. Если спрайтшит сформирован правильно, без каких-либо лишних «пробелов», то метод drawImage () отработает четко и анимация получится корректной.

И как итог получаем анимацию главного игрока:

Рисунок 7. Анимированный морской конь. Но пока еще в черном прямоугольнике.

Рисунок 7. Анимированный морской конь. Но пока еще в черном прямоугольнике.

Уберем черный прямоугольник. Для этого в метод draw () того же класса Player внесем изменения. Удалим строку:

context.fillStyle = 'black';

а метод context.fillRect() заменим на context.strokeRect(), который будет рисовать просто черную рамку.

Помимо этого добавим возможность «включать/отключать» эту черную рамку для процесса отладки (например, для отладки коллизий между игроком и врагами). Для этого в класс Game добавим свойство debug:

this.debug = true

а в класс InputHandler обработку нажатия клавиши d для включения/отключения режима дебага:

Код 8. При нажатии клавиши 'd' включаем/отключаем режим дебага.

Код 8. При нажатии клавиши 'd' включаем/отключаем режим дебага.

Вот как будет реагировать наша игра на нажатие клавиши 'd':

Рисунок 8. Включение/отключение рамки вокруг игрока.

Рисунок 8. Включение/отключение рамки вокруг игрока.

Анимация врагов

Пришло время анимировать врагов. В файле index.html у нас уже есть код, который подгружает необходимые изображения:

Код 9. Подгружаем изображения для анимации врагов.

Код 9. Подгружаем изображения для анимации врагов.

angler1 и angler2 — это враги обыкновенные, которые просто хотят покалечить нашего морского коня, а вот lucky — это маленькая рыбка в желтом шарике, которая при столкновении с главным игроком «дает ему сил и энергии» — возможность на короткий промежуток времени стрелять сразу из носа и хвоста, однако, ее можно также уничтожить получив за нее определенные очки.

В класс Angler1 внесем следующие изменения:

Код 10. Изменения для анимации врага Angler1.

Код 10. Изменения для анимации врага Angler1.

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

Аналогичные изменения внесем и в класс Angler2.

Для базового класса врага — Enemy выполним следующие корректировки:

Код 11. Изменения в классе Enemy.

Код 11. Изменения в классе Enemy.

добавили свойства this.frameX, this.frameY и this.maxFrame, в метод update () добавили логику обновления this.frameX, а в методе draw () воспользовались уже знакомым методом context.drawImage() элемента канвас с 9-ю аргументами для «вычленения» кадров из спрайтшита.

Также перенесем свойства lives и score из базового класса Enemy в дочерние, чтобы мы могли каждому типу врага назначить свои собственные значения жизней и количество очков this.score

Перенос базовых свойств из класса Enemy

Удаляем свойства из класса Enemy:

и добавляем свойства в класс Angler1:

c2aa8f120f437053304ab786495e3534.png

и Angler2:

fe42ce69779dafbce7c36e7c05351c98.png

Рисунок 9. Анимация врагов.

Рисунок 9. Анимация врагов.

Рыбка-удача и 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'; // устанавливаем цвет

Рисунок 10. Рыбка-Удача и переход в режим Power-up.

Рисунок 10. Рыбка-Удача и переход в режим Power-up.

Здесь список изменений по данному разделу.

Пули и улучшенный шрифт

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

В свойства класса Projectile добавим:

this.image = document.getElementById('projectile');

а метод draw () будет теперь таким:

draw(context) {
    context.drawImage(this.image, this.x, this.y);
}

Здесь нам не нужно использовать перегрузку метода drawImage () с 9-ю аргументами, т.к. изображение пули в файле .png состоит всего из одного кадра.

Готово.

Перейдем к шрифтам.

Хотелось бы конечно оставить в игре русские надписи типа «Победа! Отличная работа!», но все-таки сделаю так, как сделал автор оригинала — добавлю Google Fonts с «экзотическим» шрифтом Bangers.

Перейдем на сайт с нужным шрифтом и выполним следующие действия:

Подключаем шрифт:

Копируем ссылки

Рисунок 11. Копируем links.

Рисунок 11. Копируем links.

и добавляем их в index.html

Рисунок 12. Подключаем шрифт.

Рисунок 12. Подключаем шрифт.

Затем копируем css правила:

Рисунок 13. Копируем css правило для шрифта.

Рисунок 13. Копируем css правило для шрифта.

и добавляем их в файл style.css к элементу canvas:

Рисунок 14. Добавляем правило для элемента canvas.

Рисунок 14. Добавляем правило для элемента 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!';
}

Отлетающие шестеренки. Вращение и гравитация

Так как в нашей игре враги представляют собой механизированных рыб, состоящих из различных механизмов и шестеренок (частиц), то будет эффектным реализовать функционал вылета из врагов этих шестеренок при определенных условиях, а именно:

  1. когда во врага попадает пуля — из врага вылетает одна шестеренка;

  2. когда враг сталкивается с морским коньком — враг распадается на количество шестеренок, равное оставшемуся количеству жизней врага;

Итак, создадим класс 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.

Рисунок 15. Шестеренки.

Рисунок 15. Шестеренки.

Несколько комментариев относительно свойств класса Particle. x и y — начальные координаты появления частицы на игровом полотне; spriteSize — размер в пикселях одной частицы (в нашем случае они квадратные); sizeModifier — рандомный коэффициент изменения размера частиц (чтобы все шестеренки не были одного размера); size — итоговый размер шестеренки; speedX, speedY — скорость движения шестеренки по осям Ox и Oy, соответственно; gravity — коэффициент ускорения (увеличения скорости) частицы, благодаря которому будет создан эффект притяжения (гравитации).

Чтобы наши шестеренки вращались при падении, — добавим такие свойства, как angle — угол поворота шестеренки (относительно собственной оси, т.е. центра); va — скорость вращения. Частицы после ударения о поверхность будут отскакивать от нее несколько раз, — за количество отскоков будет отвечать свойство bounced, а чтобы частицы не падали в одно и то же место — введем свойство bottomBounceBoundary. Таким образом шестеренки будут падать в различных местах нашей «дорожки», см. рисунок 16:

Рисунок 16. Места падения шестеренок и игровая дорожка.

Рисунок 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:

Рисунок 17. Hive Whale класс, дроны и взрывы.

Рисунок 17. Hive Whale класс, дроны и взрывы.

При уничтожении врага и при столкновении его с главным игроком — на месте врага появляется эффект взрыва, который бывает двух типов — Dust (пыль) и Fire (огонь).

Я не буду подробно описывать реализацию данного функционала (добавление класса-врага Hive Whale, дронов и взрывов), а оставлю лишь ссылки на изменения в проекте: добавление класса Hive Whale и дронов, реализация взрывов.

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

Адаптация для мобильных устройств

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

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

Во-вторых, нам нужно привязать действия игрока (а именно, движения вверх/вниз и стрельбу) к тачпаду, чтобы можно было легко управлять игроком. Для работы с тачпадом в языке JavaScript необходимо обрабатывать такие события, как Touch events. Для хорошего понимания этих событий нужно написать еще одну статью. Вот тут есть очень хороший материал для тех, кто, как и я, первый раз сталкивается с событиями касания.

Ну и в-третьих, для стрельбы я все-таки решил сделать отдельную кнопку на игровом поле. Вот так вот! =) Мне показалось это достаточно удобным и «играбельным» вариантом.

Соберем теперь все это вместе.

В файле index.html добавим кнопку для стрельбы (по сути — это изображение) и поместим наш канвас и эту кнопку в div-элемент:

саму картинку я нашел на просторах интернета и поместил в папку Assets:

Shoot button

Рисунок 18. Кнопка для стрельбы.

Рисунок 18. Кнопка для стрельбы.

С помощью 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);
    }
}

Вот как это выглядит на моем мобильном устройстве:

Рисунок 19. Мобильная версия игры.

Рисунок 19. Мобильная версия игры.

В настройках мобильного браузера необходимо сделать вот такую настройку, чтобы «игра поняла», что мы используем именно мобильную версию. Данная настройка у меня выглядит следующим образом:

Настройка мобильной версии браузера

Рисунок 20. Убираем галочку в опции

Рисунок 20. Убираем галочку в опции «Версия для ПК».

Заключение

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

Для адаптации игры к вашей частоте смены кадров были внесены следующие изменения. Теперь параметр deltaTime передается во все методы update () для корректного расчета координат/скоростей/угла поворота объектов. Насчет смены спрайтовых анимаций, к сожалению, ничего не придумал.

Массив this.keys в классе Game заменил на множество Set().

Для определения коллизий (столкновений) возможно и стоит использовать такой инструмент как Intersection Observer API, но в данной игре я решил все-таки оставить свою реализацию.

Надеюсь статья была для Вас полезной!

Всем добра и чистого кода!

© Habrahabr.ru