Приключения Дино продолжаются...

Привет, Хабр! Как-то раз после работы мне захотелось взять и написать небольшую компьютерную игру. А почему бы и нет? Играть я люблю, программировать — тоже. Захотелось проверить, можно ли сделать что-то прикольное на уровне современных AAA-игр, не изучая дополнительных языков программирования, а также избежать банального повторения тех же «велосипедов», которые уже 100500 раз выложены на различных стримах и, конечно, не раз разбирались на Хабре. В этом посте я хотел бы поделиться с вами своим небольшим экспериментом в области GameDev на базе JS и обсудить возможности, которые есть у любознательного программиста с бэкграундом в сфере JavaScript.

Меня зовут Семен Брятов, я занимаюсь разработкой фронтэнда в команде Леруа Мерлен. При необходимости иногда немножко работаю fullstack-программистом на базе Node JS, но случается это пока что редко (хотя кто знает, куда меня занесет судьба завтра ?). Периодически занимаюсь всякими экспериментами и интересными пет-проектами. И хотя являюсь практически нубом в этой сфере, сегодня хочу поделиться своим опытом GameDev’а. Может быть, мой пост вдохновит ещё кого-нибудь заняться чем-то интересным в свободное от работы время. 

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

Все это привело к мысли: пора уже сделать свою собственную игру на JS! ???

Выбираем фреймворк

У любого разработчика с опытом при подходе к новому проекту неизбежно возникает мысль: «Зачем городить костыли и велосипеды, ведь их уже нагородили?» Нет, конечно, написать свой собственный фреймворк на JS — задача интересная, но мне хотелось получить игровой результат в какие-то более-менее адекватные сроки. 

Когда я впервые подступался к подобной задаче(а было это давно, я тогда только погружался в мир веба), у меня возникли опасения, что найти подходящий движок под JS будет не так просто. На тот момент я был уже знаком с движками Unreal и Unity и даже немного пробовал в них ковыряться. Но я прекрасно понимал, что при всей своей крутости Unreal Engine сильно использует С++. Да, в нем в последнее время добавились Blueprints, которые позволяют заниматься визуальным программированием (примерно как в нашей Platformeco). Но во всем этом нужно еще разбираться и разбираться… да и все равно есть подозрение, что без С++ далеко на Unreal не уедешь.

Движок Unity плотно сидит на C#. В нем была (это ключевое слово) поддержка JS. Но, судя по всему, разработчики решили от нее отказаться. Думаю, они правильно сделали: лучше специализироваться на чем-то одном, но хорошо. Когда-то в универские годы у меня был опыт разработки как на С++, так и на С#. Но кривая дорожка судьбы привела меня в мир веба. По этой причине, отбросив два самых популярных игровых движка в геймдеве, я решил отправиться в свободное плавание… 

NPM - Самурай

Я знал, чувствовал в глубине души, что ситуация не может быть такой мрачной. Сейчас, будучи опытным юзером веб-технологий, я знаю что для JS написано не просто много либ, а СЛИШКОМ много либ. Среди них просто обязано быть подходящее мне решение! И выяснилось, что так оно и есть. Ассортимент действительно большой — и это поначалу даже пугает. Так сказать, рождает большую ответственность разработчика за выбор «правильного», подходящего инструмента.

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

Движки для 2D

Врум-врум

Один из наиболее beginner-friendly вариантов —  GDevelop. В этом фреймворке есть своя IDE. В ней можно программировать не только с использованием JS, но также при помощи блоков — снова приведу в пример решение Platformeco от моей компании. Ну а если ближе к геймдеву, то здесь стоить вспомнить про упомянутую ранее Blueprints от Unreal Engine. Вообще, визуальное программирование активно развивается, и, думаю, работать с GDevelop будет удобно многим, кто уже имеет подобный опыт.

Пиу-пиу

Phaser, пожалуй, самый популярный фреймворк разработки для 2D на JS. Насколько я знаю, Phaser уже лет 10 существует и развивается. И такая история обеспечивает ему огромное количество дочерних библиотек и дополнительных примочек от огромного сообщества. 

melon (с англ. «дыня»). При чём тут дыня? :)

Melonjs — фреймворк-конструктор, в котором просто собирать проекты из различных модулей. Его тоже активно используют разработчики 2D-игр. Глубоко в него не копал, но довольно часто слышал его упоминание в различных топ-обзорах популярных фреймворков для JS.

Движки для 3D

Ммм, текстурочки

Когда мы говорим про 3D, сразу же на ум приходит PlayCanvas. Как мне показалось во время исследования, это настоящий гигант в мире разработки игр на веб-технологиях. На базе PlayCanvas можно прямо в браузере запустить специализированную IDE и уже в ней работать с текстурами и модельками. Платформа широко поддерживает 3D Web-рендеринг и показывает, кстати, отличную производительность. Пока я проводил свой ресерч, запускал пару демок, и на PlayCanvas получился вполне нормальный уровень графики. Это ведь в браузере! В общем, штука хорошая.

Почти как знакомый всем фронтам babel.js, хех

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

Three.js - ОМГ, сколько демок на их веб-страничке

ThreeJS — еще одна популярная библиотека для работы с графикой в браузере (думаю, что самая популярная). Ее можно свободно использовать, она относительно простая и, самое главное, бесплатная! Но если говорить о геймдеве, с ней придется много что писать самому с нуля. Ведь это больше инструмент рендеринга 3D для веба, а не полноценный движок с физикой, готовыми контроллерами и прочими прелестями игровых фреймворков. Так что инструмент хорош для тех, кто любит «заходить с кода и продолжать кодом», не боясь «получить Нобелевскую за изобретение очередного велосипеда». Зато какие красивые сайты-визитки можно с ее помощью делать! Я вдохновился! И вам советую посмотреть примеры с официальной страницы библиотеки https://threejs.org/.

Что выбираем?

Повторю то, что сказал в самом начале. Я — нуб. Поэтому решил выбрать что-то попроще, самое популярное и подробно разжеванное. После недолгих раздумий я решил начать с 2D, взял Phaser и погнал работать…

Но куда работать-то? Стоит ли реально закапываться или лучше сделать что-то простенькое? В голову пришел бесконечный раннер… Но вот смотришь на YouTube 1001 туториал о том, как индусы кодят тот же самый раннер, и начинаешь думать: «Какая посредственность… зачем мне это нужно? Даже для учебы брать уже как-то скучно».

Тут, к счастью, у меня состоялась беседа с коллегой, Вадимом Жуковым (руководитель разработки на одном из проектов, тоже интересуется гейм-девом), который предложил мне кое-что изменить и сделать управление в раннере… с помощью звука! И вот в этот момент у меня в голове, наконец, загорелась лампочка: «Отличная идея, нужно попробовать!»

И я начал делать свой сверхзвуковой раннер.

Под капотом — серьезные вещи!

Итак, я взял базовый сборщик на базе Vite и TypeScript, а также использовал пакеты audiomotion — analyzer v3 (помогает распознавать звуки) и phaser v3. Деплой реализовал через gh-pages (быстро-модно-молодежно). Ссылочка на проект, ридми и демку тут — https://github.com/SimonBryatov/Phaser3-RunnerGame

Итак, как все это выглядит в phaser?

Есть сам проект. Я назвал его… Phaser3-RunnerGame. Мы создаем в нем instance: определяем ширину и высоту нашего экрана, настраиваем зум. Можно добавить физику (кстати, аркадная физика лучше всего подходит для 2D), назначаем гравитацию — по оси Y, чтобы динозаврика тянуло обратно на землю. 

папочка /src - «сердце проекта»

Объект игры ссылается на сцены — это вторая core-составляющая Phaser. Сцены — это отдельные классы, в которых мы описываем логику для каждого конкретного момента. 

Кукловод :)

Сцены бывают активные игровые, а бывает preloader или, к примеру, gameover. Я использую preloader, чтобы загрузить все текстуры, картинки, музыку и прочее (так было рекомендовано в туториале, и я решил следовать лучшим практикам!). По факту, если в игре ресурсы будут жирными, то надо сделать экран загрузки, где потенциальный игрок будет весело проводить время в ожидании подготовки основной сцены игры. Для моего проекта речь идет про какие-то доли секунды, однако на будущее в голове надо держать этот концепт. Плюс данная сцена — некая абстракция для инкапсуляции неигровой «скучной» логики подгрузки ассетов, чтобы не засорять лишними букафффами основную игровую сцену.

Когда все подгрузится, мы переходим на этап сцены Game: 

import Phaser from "phaser";
import { AnimationKey } from "../consts/AnimationKey";
import { SceneKey } from "../consts/SceneKey";
import { TextureKey } from "../consts/TextureKey";
import AudioMotionAnalyzer from "audiomotion-analyzer";

// Наша основная сцена
export class Game extends Phaser.Scene {
  private background!: Phaser.GameObjects.TileSprite;
  private bush!: Phaser.GameObjects.Image;
  private copCar!: Phaser.Types.Physics.Arcade.SpriteWithDynamicBody;
  private prevRunVelocity = 0;
  private dino!: Phaser.Types.Physics.Arcade.SpriteWithDynamicBody;
  private audiomotion!: AudioMotionAnalyzer;
  private isGameOver: boolean = false;
  private scoreText!: Phaser.GameObjects.Text;
  private music!: any;

  constructor() {
    super(SceneKey.game);

    this.loadAudioAnalyzer();
  }

  private loadAudioAnalyzer = () => {
    //...
    // Зашружаем нашу библиотеку по работе с входным звуком
  }; 
  private setPlayer(sceneWidth: number, sceneHeight: number) {
    //...
    // Обрабатываем модельку персонажа и настраиваем игровую камеру
  }
  private setWorld(sceneWidth: number, sceneHeight: number) {
    //...
    // Рисуем игровой мир
  }
  private setSound() {
    //...
    // Врубаем хайповый "музон"
  }
  private onObstacleHit = () => {
    //...
    // Обрабатываем столкновения с препятствиями тут
  };
  private handleJumping = () => {
    //...
    // Обрабатываем логику прыжков
  };
  private handleRunning = () => {
    //...
    // Обрабатываем логику бега
  };
  private spawnDecorations = () => {
    //...
    // Рисуем рандомно декоративные элементы (кусты)
  };
  private spawnObstacles = () => {
    //...
    // Рисуем рандомно препятсвия (коповские тачки)
  };

  create() {
    this.setSound();

    this.prevRunVelocity = 0;
    this.isGameOver = false;

    const sceneWidth = this.scale.width;
    const sceneHeight = this.scale.height;

    this.setWorld(sceneWidth, sceneHeight);

    this.setPlayer(sceneWidth, sceneHeight);

    this.physics.add.collider(this.dino, this.copCar, this.onObstacleHit);

    // Всякие прелести UI добавляем. Можно было б тоже в функу вынести
    this.scoreText = this.add
      .text(0, 0, "Score: ", {
        fontSize: "16px",
        color: "white",
        backgroundColor: "black",
      })
      .setScrollFactor(0);

    this.add
      .text(90, 60, "Run frequencies (0-60)", {
        fontSize: "10px",
        color: "white",
        backgroundColor: "black",
      })
      .setScrollFactor(0);

    this.add
      .text(360, 60, "Jump frequencies (2k-16k)", {
        fontSize: "10px",
        color: "white",
        backgroundColor: "black",
      })
      .setScrollFactor(0);
  }

  // Тик-так, а вот и новый такт игры подъехал - пора обработать
  update(): void {
    if (this.isGameOver) {
      return;
    }

    this.handleRunning();
    this.handleJumping();

    this.background.setTilePosition(this.cameras.main.scrollX);
    this.spawnDecorations();
    this.spawnObstacles();

    this.scoreText.text =
      "Score: " + Math.round(this.cameras.main.scrollX * 0.25 - 40);
  }
}

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

init — аналог on mount

create — что-то среднее между mount и первым update 

update — как render, только подвязанный на знакомый многим Window.requestAnimationFrame() — каждый очередной момент времени движок вызывает данный метод, и происходит вычисление следующего состояния сцены.

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

Тут же определяется вся логика взаимодействия динозаврика с миром. Если динозавр сталкивается с машиной, сцена переходит в сцену GameOver, которая кладется «поверх» сцены Game. Когда мы совершаем клик мышкой, происходит сброс основной сцены Game и таким образом производится перезапуск игры с начальными параметрами.

import Phaser from "phaser";
import { SceneKey } from "../consts/SceneKey";

// Сцена Гейм овер, "аста ла виста, Бейби"
export class GameOver extends Phaser.Scene {
  constructor() {
    super(SceneKey.gameOver);
  }

  resetGame = () => {
    this.scene.get(SceneKey.game).scene.restart();
    this.scene.stop();
  };

  create() {
    this.add
      .text(120, 200, "Game over, Dino!", {
        fontSize: "38px",
        color: "white",
        fontStyle: "bold",
        backgroundColor: "black",
      })
      .setScrollFactor(0);

    this.add
      .text(300, 250, "Try Again ?", {
        fontSize: "20px",
        color: "white",
        backgroundColor: "black",
      })
      .setScrollFactor(0)
      .setInteractive()
      .on("pointerdown", this.resetGame);
  }
}

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

Ключевой момент — управление звуком ?️?️

Но вернемся к тому, что делает мой раннер чем-то особенным. И это управление звуком. Для этого я обратился к крутой либе audioMotion. Это полноценный музыкальный плеер, который содержит множество функций. Только вот сам плеер мне не был нужен и тут мне несказанно повезло. Эти ребята также выпустили отдельно пакет audioMotion-analyzer, который содержит в себе только вычислительные функции для работы с звуковым входом в браузере. На его основе можно проводить как визуализации (визуализатор работает на технологии canvas прямо из коробки!), так и получать конкретные значения амплитуды звукового сигнала в выбранном частотном диапазоне. 

Туц-туц-туц

Предоставляемый библиотекой метод getEnergy(...) как раз таки нам и нужен. В нашем случае он вызывается при каждом вызове update-метода сцены Phaser, то есть на каждый обрабатываемый игрой такт, и возвращает «силу сигнала» в указанном диапазоне частот. Я использовал эти значения, чтобы вычислить скорость, с которой наш динозаврик должен бежать и прыгать.

Фактически, под капотом программных абстракций происходит очень интересная штука — звук от входного аудиоустройства и аудиокарты компьютера передается браузеру и библиотеке, в которой и происходит вся магия по обработке сигнала. Благо эту «магию» делаем не мы — за нас уже постарались физики, низкоуровневые ЯП и разработчики библиотеки audiomotion :) Наше дело несложное — направить «нужные циферки» в «нужное русло» игрового контроллера под управлением движка Phaser и заставить героя выполнять примитивные действия, ради нашей забавы.

Как я уже описал выше, метод GetEnergy выдает результаты от 0 до 1, оценивая «силу» входного звука (не знаю, какой радиотехнический термин тут корректнее употребить, так что прошу меня простить). При этом можно выделить только определенный диапазон частотного спектра и получить значение для конкретных типов входящих сигналов. Для движения динозавра я решил выбрать частоты от 0 до 60 Гц. Это характерный звук от, скажем, стука кулаком по столу или топанья по полу. Библиотека оценивает «среднюю энергию звука», а я уже перемножаю это число от 0 до 1 на определенный коэффициент, нормализуя его до подходящих значений для физики движка. В общем, немного уже нашей магии — и становится понятно, когда динозавр начинает бежать ? 

С прыжком то же самое, но на более высоких частотах. Я выбрал диапазон от 2 до 16 КГц — это уровень звука «хлопка в ладоши». Но тест-драйв показал, что туда же ложатся крики и даже шмыганье носом. Так что можно устроить себе очень интересную игру, например, выкрикивая «Ко-ко!», чтобы динозавр прыгнул. 

Что еще потребовалось?

Даже на таком простом проекте нужно еще кое-что, чтобы игра действительно заиграла. Я использовал программу Aseprite. Она пригодилась, чтоб нарисовать динозаврика, а также анимировать его. Кстати, создатели Aseprite реально старались. Прога получилась отличная, и я нарисовал отличного Дино, и даже трассу сам нарисовал (хотя и не очень старался). За этот удобный инструментарий для PixelArt я даже решил заплатить аж 350 рублей. Никаких вам «йо-хо-хо» и «бутылки рома»! ?‍☠️ ?

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

Что получилось?

В общем, получилась забавная игруха, в которой я даже сам пока не очень преуспел, потому что наверное... медленно шмыгаю носом и хлопаю в ладоши! Сразу скажу, что играть в нее лучше в наушниках, чтобы музыка (а там есть саундтрек просто супертематический!) не мешала библиотеке audioMotion-analyzer определять «энергию ваших звуков» правильно. Хотя если сделать музыку потише, а микрофон при этом у вас относительно нормальный, можно играть и без наушников. Как раз те самые «страшные коэффициенты» для умножения значений уровня входного звука я старался подобрать так, чтобы минимизировать ложные срабатывания. Но не переоценивайте мою экспертизу в этом вопросе — решал задачу методом тыка, конечно же!

Судя по всему, игру уже можно экспортировать как Android- и iOS-приложение — кажется, я встречал такие возможности во всех (или почти всех) упомянутых при рисерче библиотеках игровых движков для JS. Что и говорить, сейчас Web пытается встраиваться куда угодно. Может быть, скоро мой раннер получится запустить даже на «вашем тостере». Ну, вы понимаете :)

В любом случае, когда будете запускать, сделайте предварительно потише громкость в системе, чтобы нечаянно не оглохнуть) Конечно, по-хорошему нужно бы еще настраивать и настраивать эту игруху, но я свою задачу выполнил и действительно сделал свой первый раннер, причем полностью на JS. Если вам интересно или хотите сделать что-то свое по образу и подобию, дублирую ссылочку на GitHub тут. Там же в ридми есть ссылочка на демку, развернутую на gh-pages. 

А вообще, спасибо за внимание, буду рад вашим комментариям!

Всем хорошего настроения и творческого подхода! ?