×Закрыть

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

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

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

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

26 комментариев

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

Честно, тут уже встречались статьи на эту тему. Но с меньшим количеством картинок и большей концентрацией знаний на параграф текста. Автор безусловно молодец, рассказал все интересно и понятно, но вот коменты еще раз подтверждают что интерес к статьям чаще всего порожден не информационной составляющей. Будьте осторожны, в своих симпатиях, не столкните доу на путь хабра.

Спасибо, статья очень интересная и после прочтения захотелось использовать предложенную технику оптимизации рендеринга.

Как я понял, суть использования цикла в том что бы обновлять состояние несколько раз в зависимости от частоты вызова requestAnimationFrame (RAF)

А дальше в теле функции присутствует вызов с render(dt) в который передается dt

И мой вопрос в том, зачем в рендер передается это значение ?

Что бы лучше понять как это работает создал демку plnkr.co/...vUbKu4d3bpBLKnn?p=preview возможно в моей реализации есть ошибка и вы поможете мне разобраться

Нагляднее всего для чего нужен dt внутри функции render отображено в коде первого примера Различный FPS для update() и render()

dt используется для отрисовки промежуточных значений между двумя кадрами game loop, в случае если «render loop» вызывается чаще чем «game loop».

Не увидел в демке функцию render...

Спасибо, исходники примеров помогли разобраться, что dt используется с функцией интерполяции в указанном вами примере.
В моей демке роль функции «render» и «update» играла эта строка:
// h.style.transform = `rotate(${value++}deg)`;

Когда ждать Скайрим в браузере?

Дякую. Стаття дуже корисна )

хорошая статья
неужели нет фреймворков с готовой реализацией лупов, сцен и прочего?

Есть, конечно, например phaser.io
Но статья все равно хорошая

Фазер отличный фреймворк, сам его использую.

Первая интересная статья на доу

Просто много картинок.

Дякую за статтю

Все хорошо, но requestAnimationFrame принимает колбек с одним параметром — текущим временем. Потому
let frame = () => { now = performance.now(); ... }
можно заменить на
let frame = (now) => { ... }

Да, спасибо за дополнение. В оригинальном выступлении мы плавно переходили от использования setInterval и данный подход использовался для наглядности.

рекомендуется использовать гораздо более гибкое Web Audio API
Не знав, приємно здивований, з цим HTML5 має іще більше шансів відкинути в «сміттєзвалище минувших» FLASH

За что такая не любовь к Flash? Он имел всё из коробки, имел классный IDE, не тормозил на компах с 200 MB RAM. Не имел проблем стандартизации между браузерами.

За что такая не любовь к Flash?
Навпаки, хароша технологія, на ній багато чого харошого (мож і не дуже) зроблено. В нього основна проблема (принаймі для мене) то це захист, ну не розумію чому компанія Adobe не вирішує цю проблему. А от HTML5 досить швидко розвивають практично в усіх планах + дуже любив робити гіфки на флеші)

Для использования Flash нужно кроме всего прочего еще изучать флеш, не нативный.

изучать флеш, не нативный
Тобто?

Это плагин, его нельзя использовать владея только базовыми знаниями, тоесть HTML, CSS и JS

Спасибо

Хорошая статья.
Хотелось бы еще сравнение игровых JS-фреймворков: Phaser, Pixi, Cocos и т.д.

Блин как все круто когда всего лишь два квадрата двигается. С чуть более сложной отрисовкой — это все бесполезно.

Согласен. Информация о приемах оптимизации рендеринга тянет на отдельную статью.
Немного дополнительной информации на тему производительности есть в видеозаписи.
Данная статья скорее об общих принципах отрисовки и организации кода.

Подписаться на комментарии