Привет всем!

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

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

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

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

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

Содержание

Бэкграунд

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

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

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

Скрываем изображения в файле style.css
Код 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 слой).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

context.fillStyle = 'black';

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

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

this.debug = true

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

и Angler2:

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

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

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

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

В свойства класса 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.

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

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

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

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

и добавляем их в файл style.css к элементу 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. Шестеренки.

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

Чтобы наши шестеренки вращались при падении, — добавим такие свойства, как angle — угол поворота шестеренки (относительно собственной оси, т.е. центра); va — скорость вращения. Частицы после ударения о поверхность будут отскакивать от нее несколько раз, — за количество отскоков будет отвечать свойство bounced, а чтобы частицы не падали в одно и то же место — введем свойство bottomBounceBoundary. Таким образом шестеренки будут падать в различных местах нашей "дорожки", см. рисунок 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 класс, дроны и взрывы.

При уничтожении врага и при столкновении его с главным игроком — на месте врага появляется эффект взрыва, который бывает двух типов — 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
Рисунок 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. Мобильная версия игры.

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

Настройка мобильной версии браузера
Рисунок 20. Убираем галочку в опции "Версия для ПК".

Заключение

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

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

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

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

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

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