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

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

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

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

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

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

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

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

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

NPM - Самурай
NPM - Самурай

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

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

Движки для 2D

Врум-врум
Врум-врум

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

Пиу-пиу
Пиу-пиу

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

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

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

Движки для 3D

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

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

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

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

Three.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 - «сердце проекта»
папочка /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. 

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

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

Комментарии (4)


  1. gpt74
    14.04.2023 13:48

    Автор, а подкажите зачем и как в прикрутили к Фазер Тапскрипт - ведь врродебы Фазер на чиоом Ява-скрипт сделан, да и звуковой фрейм-ворк вроде тоже на чистом Ява-скрипт? Я смотрел на Фазер и Бабилон - на мой взгляд Бабилон имеет огромное преимущество - импорт готовых фигурок из других дизайнеров и автоматиеский "кокон" для детекции столкновений. Имхо, без этого разработка превращается в Ад. И еще как решили пробему на мобилах, где активация звуковых фейм-ворков возможна только по тапу по экрану, в фоне не работает...?


    1. SimonBryatov Автор
      14.04.2023 13:48

      Зачем тайпскрипт?

      Статическая типизация typescript мне очень нравится - помогает писать более структурированный код :) Создатели phaser поддерживают типизацию на ts - у них даже туториал есть официальный с использованием ts

      Babylon vs Phaser

      В статье я описал, что Babylon рассчитан больше на работу с 3d, в то время как Phaser - "царь" в 2d сегменте. Если я правильно понимаю термин "автоматиеский кокон" и вы имеете ввиду коллизии, то Phaser их тоже поддерживает "из коробки"

      Как решили проблему на мобилах

      На мобилы не экспортил - только написал о такой потенциальной возможности. Вообще, с мобильного браузера запускается, но что-то с вводом с микрофона действительно есть проблемы + я не оптимизировал адаптивность странички. Основная задумка была под веб с ПК


      1. gpt74
        14.04.2023 13:48

        автоматический кокон я в фазере не нашел, автоматский значит например у вас есть сложная фигурка - человек с растопыренными руками, самолет и тонкими и динными крыльями, змея длинная и с извивами и т.д. - так вот в бабилон оно само автоматиески надевает на нее кокон (СЛОЖНЫЙ И ТОЧНЫЙ!!!) для детекции касаний с другими такими же сложными объектам - в фазере как я понял это адский труд самому прорисовыать ломаными линиями в 2д сложный конкон или одеваь окружность или квадрат что будет рабтать плохо и убого.


    1. SimonBryatov Автор
      14.04.2023 13:48

      https://phaser.io/news/2021/04/phaser-3-typescript-starter

      вот ссылка с их сайта на демо-проект на ts