Разработка игр на JavaScript

Web — удобная платформа для быстрого старта разработки игр, особенно если вы знакомы с языком JavaScript. Эта платформа обладает богатыми возможностями по отрисовке содержимого и обработке ввода с различных источников.

C чего же начать разработку игры для web? Определим для себя следующие шаги:
— Разобраться с game loop и отрисовкой;
— Научиться обрабатывать пользовательский ввод;
— Создать прототип основной сцены игры;
— Добавить остальные сцены игры.

Game loop

Игровой цикл — это сердце игры, в котором происходит обработка пользовательского ввода и логики игры, а также отрисовка текущего состояния игры. Схематически game loop можно описать следующим образом:

А в виде кода простейшая реализация игрового цикла на JavaScript может выглядеть так:

// game loop
setInterval(() => {
  update();
  render();
}, 1000 / 60);

Функция update() отвечает за логику игрового процесса и обновление состояния игры в зависимости от пользовательского ввода. Функция render() отрисовывает текущее состояние игры. При этом абсолютно неважно, с помощью каких технологий происходит отрисовка (Canvas, DOM, SVG, console etc).

Следует помнить, что окружение браузера накладывает свои ограничения на работу этого кода:
— Стабильный интервал таймера не гарантируется, а это значит, что игровой процесс может происходить с различной скоростью;
— В неактивных вкладках браузера таймер может быть приостановлен, а при активации вкладки многократно запущен, что может привести к странному поведению игры;
— SetInterval уже устарел для таких задач. Сейчас рекомендуется использовать метод requestAnimationFrame, так как он позволяет добиться улучшения производительности и уменьшения энергопотребления.

Реализация game loop в браузере

Рассмотрим несколько способов реализации игрового цикла c помощью метода requestAnimationFrame. В самой простой реализации мы столкнемся с определенными проблемами, которые постараемся решить в последующих реализациях.

Простой и ненадежный способ. Просто используем requestAnimationFrame и надеемся на стабильные 60 FPS. Код для такого игрового цикла мог бы выглядеть так:

requestAnimationFrame(() => {
  angle++;   // изменяем угол на 1 градус
  render();  // отрисовываем текущее состояние
  ...        // повторяем вызов requestAnimationFrame
});

Следует отличать плавность отрисовки игровой сцены (так называемые «тормоза» в играх) и скорость изменений в сцене (скорость событий в игре).

Согласно спецификации метод requestAnimationFrame должен позволять отрисовку с частотой, равной частоте обновления дисплея. Сейчас зачастую это 60 FPS, однако в будущем, возможно, он позволит отрисовывать кадры и на более высокой частоте. Также следует помнить, что некоторые браузеры сейчас поддерживают режим экономии батареи, одной из оптимизаций которого является уменьшение частоты requestAnimationFrame.

Получается, что указанный FPS может не только быть нестабильным, но ещё и в некоторых ситуациях выдавать частоту, в 2 раза отличающуюся от «идеальных» 60 FPS — как в положительную, так и в отрицательную сторону.

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

Совсем плохо — скорость игровой логики зависит от мощности и загруженности устройства

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

Использовать RAF и рассчитывать время между кадрами. Сделаем наш код более гибким — будем считать, сколько времени прошло между предыдущим и текущим вызовами requestAnimationFrame:

let last = performance.now();   // в этой переменной сохраняем время вызова предыдущего кадра

requestAnimationFrame(() => {
  let now = performance.now(),  // определяем текущее время
      dt = now - last;          // вычисляем время, прошедшее между кадрами

  angle += dt * 60 / 1000;      // изменяем угол пропорционально прошедшему времени
  last = now;                   // сохраняем время отрисовки последнего кадра
  render();                     // отрисовываем текущее состояние
  ...                           // повторяем вызов requestAnimationFrame
});

Теперь при проседании или изменении производительности скорость игры не изменится, а изменится только плавность отрисовки:

Данный подход работает, но решает ли он все проблемы?

Использовать фиксированный интервал для update(). Предыдущий подход действительно сделал наш код более устойчивым к различной частоте вызовов requestAnimationFrame, но с таким подходом нужно будет каждое свойство игровой логики изменять пропорционально прошедшему времени. Это не только не очень удобно, но и не подойдет для многих игр, использующих физику или расчет пересечения объектов, ведь в случае различной частоты вызовов update() нельзя гарантировать полную детерминированность сцены.

Можно ли добиться фиксированного интервала, если подобное не поддерживается браузером? Есть способ, но код придется немного усложнить:

let dt   = 0,                   // определяем текущее время
    step = 1 / 60,              // количество времени на один кадр
    last = performance.now();   // в этой переменной сохраняем время вызова предыдущего кадра

requestAnimationFrame(() => {
  let now = performance.now();  // определяем текущее время
  dt += (now - last) / 1000;    // добавляем прошедшую разницу во времени
  while(dt > step) {
    dt -= step;                 // вложенный цикл может вызывать обновление состояния несколько раз подряд
    angle++;                    // если прошло больше времени, чем выделено на один кадр
  }
  last = now;                   // сохраняем время отрисовки последнего кадра
  render(dt);                   // отрисовываем текущее актуальное состояние
  ...                           // повторяем вызов requestAnimationFrame
});

Демо работы аналогично предыдущему примеру:

Постоянный интервал для update()

Таким образом, фиксированный временной шаг дает следующие преимущества:
— Упрощение кода логики игры update();
— Предсказуемость поведения игры, а соответственно, и возможность создания replay игровой сцены;
— Возможность легкого замедления/ускорения игры (slomo);
— Стабильная работа физики.

Зависимость физических движков от FPS

Если вы планируете использовать физические движки, то следует помнить, что чем больше кадров в секунду они просчитывают, тем выше будет точность симуляции. Некоторые движки очень сильно не любят низкий FPS. В примере ниже, используя ползунок, можно сравнить результаты симуляции при низких и, наоборот, чрезмерно высоких FPS:

Ядро игрового движка

Осталась еще одна проблема, которую нужно решить — неактивные вкладки браузера. С текущим кодом, если пользователь на несколько минут сделает вкладку неактивной, а потом вернется, код для update() будет вызван очень много раз за все время отсутствия, и игровая логика может убежать далеко вперёд. Конечно, можно продумать механизмы вроде паузы состояния игры, но все равно стоит избавиться от многократного вызова update().

Подобные случаи можно проконтролировать и разрешить максимальную задержку между вызовами не более, чем 1 секунда. Собрав всё вышесказанное вместе, получаем код, который можно использовать как заготовку для создания игры:

let last = performance.now(),
    step = 1 / 60,
    dt = 0,
    now;

let frame = () => {
  now = performance.now();
  dt = dt + Math.min(1, (now - last) / 1000); // исправление проблемы неактивных вкладок
  while(dt > step) {
    dt = dt - step;
    update(step);
  }
  last = now;

  render(dt);
  requestAnimationFrame(frame);
}

requestAnimationFrame(frame);

Этот код можно взять как основу для игрового цикла, и останется только реализовать две функции —update() и render().

Различный FPS для update() и render()

Game loop с фиксированным временным шагом позволяет контролировать желаемое количество FPS для игровой логики. Это очень полезно, так как позволяет снизить нагрузку на устройство в играх, где нет необходимости просчитывать логику 60 раз в секунду. Тем не менее, даже при низком FPS для игровой логики возможно продолжать рендеринг с высоким FPS:

Оба квадрата изменяют свое положение и угол на частоте 10 FPS и рендерятся на частоте 60 FPS

В примере выше для второго квадрата используется линейная интерполяция (LERP). Она позволяет рассчитать промежуточные значения между кадрами, что придает плавность при отрисовке.

Использовать линейную интерполяцию очень просто — достаточно знать значение определенного свойства игрового объекта для двух кадров, предыдущего и текущего, а также рассчитать, в каком промежутке времени между двумя кадрами выполняется рендеринг:

LERP дает возможность получить промежуточные значения для отрисовки при указании процента от 0 до 1

Реализация функции линейной интерполяции:

let lerp = (start, finish, time) => {
  return start + (finish - start) * time;
};

Добавление поддержки slow motion

Совсем немного изменив код игрового цикла, можно добиться поддержки slow motion без изменения остального кода игры:

let last = performance.now(),
    fps = 60,
    slomo = 1, // slow motion multiplier
    step = 1 / fps,
    slowStep = slomo * step,
    dt = 0,
    now;

let frame = () => {
  now = performance.now();
  dt = dt + Math.min(1, (now - last) / 1000);
  while(dt > slowStep) {
    dt = dt - slowStep;
    update(step);
  }
  last = now;

  render(dt / slomo * fps);
  requestAnimationFrame(frame);
}

requestAnimationFrame(frame);

Добавим slow motion в предыдущее демо. Используя ползунок, можно регулировать скорость игровой сцены:

Обработка пользовательского ввода

Обработка ввода в играх отличается от классических web-приложений. Основное отличие состоит в том, что мы не сразу реагируем на различные события, вроде keydown или click, а сохраняем состояние клавиш в обычный объект:

let inputState = {
  UP: false,
  DOWN: false,
  LEFT: false,
  RIGHT: false,
  ROTATE: false
};

Пока определенная кнопка нажата, значение будет true, а как только пользователь отпустит кнопку, значение вернется на false.

Затем, когда будет вызван очередной update(), мы можем отреагировать на пользовательский ввод и изменить игровое состояние:

let update = (step) => {
  if (inputState.LEFT)   posX--;
  if (inputState.RIGHT)  posX++;
  if (inputState.UP)     posY--;
  if (inputState.DOWN)   posY++;
  if (inputState.ROTATE) angle++;
};

Примечание: не используйте пиксели как единицу измерения для логики игры. Правильнее создать константу, например, const METER = 100; и от нее рассчитывать все остальные значения, такие как высота персонажа, скорость и т. п. Таким образом, можно отвязаться от рендеринга и сделать рендеринг для retina-устройств без лишней головной боли. В примерах кода этой статьи для простоты значение модели напрямую привязано к рендерингу.

Ниже приведен пример реализации пользовательского ввода, используйте кнопки W, S, A, D и R для движения и вращения квадрата:

Структура игры. Сцены

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

В большинстве игр можно наблюдать следующий набор сцен:

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

class GameScene {
  constructor(game) {
    this.game = game;
    this.angle = 0;
    this.posX = game.canvas.width / 2;
    this.posY = game.canvas.height / 2;
  }
  update(dt) {
    if (this.game.keys['87']) this.posY--; // W
    if (this.game.keys['83']) this.posY++; // S
    if (this.game.keys['65']) this.posX--; // A
    if (this.game.keys['68']) this.posX++; // D
    if (this.game.keys['82']) this.angle++; // R
    if (this.game.keys['27']) this.game.setScene(MenuScene); // Back to menu
  }
  render(dt, ctx, canvas) {
    ...
    ctx.fillStyle = '#0d0';
    ctx.fillRect(posX, posY, rectSize, rectSize);
  }
}

Обратите внимание на вызов метода setScene — он находится в основном объекте игры и позволяет сменить текущую сцену на другую:

class Game {
  constructor() {
    this.setScene(IntroScene);
    this.initInput();
    this.startLoop();
  }
  initInput() {
    this.keys = {};
    document.addEventListener('keydown', e => { this.keys[e.which] = true; });
    document.addEventListener('keyup', e => { this.keys[e.which] = false; });
  }
  setScene(Scene) {
    this.activeScene = new Scene(this);
  }
  update(dt) {
    this.activeScene.update(dt);
  }
  render(dt) {
    this.activeScene.render(dt, this.ctx, this.canvas);
  }
}

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

Используйте W, S, A, D, R и ENTER для управления

Добавляем звук

Ранее для воспроизведения звуков приходилось использовать HTML5 <audio> теги, и приходилось решать проблемы синхронизации, перемотки звуков и воспроизведения нескольких одинаковых звуков одновременно. Сейчас это уже позади, и для работы со звуками рекомендуется использовать гораздо более гибкое Web Audio API:

let context = new AudioContext();

fetch('sounds/music.mp3').then(response => {
  response.arrayBuffer().then(arrayBuffer => {
    context.decodeAudioData(arrayBuffer, buffer => {
      let source = context.createBufferSource();
      source.buffer = buffer;
      source.connect(context.destination);
      source.start(0);
    });
  });
});

Познакомиться поближе с Web Audio API поможет статья на html5rocks.

Вместо выводов

Если вы знакомы с JavaScript, то, используя небольшой сниппет для реализации game loop, вы можете в кратчайшие сроки создать простенькую игру. Для игрового цикла рекомендуется использовать именно фиксированный временной шаг, так как это не только удобно, но и функционально.

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

Подробнее о создании игр я рассказывал на встрече «Съесть собаку» — доступна запись доклада.

Удачи в ваших экспериментах!

Похожие статьи:
Всем привет, на связи автор статьи, Катя Мартынова, глава отдела исследований в Preply.com, глобальном маркетплейсе по изучению иностранных...
Капіталізація Nvidia сягнула рекордного рівня — $3,6 трильйона. Зростання пов’язують з перемогою Дональда Трампа на виборах в США. Про...
В рубрике DOU Проектор все желающие могут презентовать свой продукт (как стартап, так и ламповый pet-проект). Если вам есть о чем...
В рубрике DOU Проектор все желающие могут презентовать свой продукт (как стартап, так и ламповый pet-проект). Если вам есть о чем...
Информация о готовящемся смартфоне Microsoft Lumia 650 уже не раз попадала в Интернет. Предполагалось даже, что эта недорогая модель...
Яндекс.Метрика