Написать пост

Создаем многопользовательскую браузерную игру. Часть вторая. Разбираем игровой фреймворк

Аватар Егор Мадьяров

Обложка поста Создаем многопользовательскую браузерную игру. Часть вторая. Разбираем игровой фреймворк

Рассказывает Алвин Лин, разработчик программного обеспечения из Нью-Йорка

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

Задержка и запаздывание

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

Задержка происходит из-за физической отдаленности вашего компьютера от сервера, к которому вы подключены. В прошлой статье я рассказывал про определение полномочного сервера. Лаг — это промежуток времени между отправкой данных на сервер и получением подтверждения от сервера.

Создаем многопользовательскую браузерную игру. Часть вторая. Разбираем игровой фреймворк 1

Чтобы справиться с лагом, существует концепция клиентского прогнозирования — экстраполяция. Это понятие предполагает прорисовку действия на стороне клиента до того, как его распознает сервер. Таким образом достигается эмуляция плавного хода игры. Фактически клиент предугадывает состояние сервера после подтверждения действия.

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

Создаем многопользовательскую браузерную игру. Часть вторая. Разбираем игровой фреймворк 2

Фреймворки

Создаем многопользовательскую браузерную игру. Часть вторая. Разбираем игровой фреймворк 3

Теперь перейдем к рассмотрению фреймворков, первым из которых будет incheon.gg. Это довольно классный фреймворк, который позволяет делать многое в играх с дискретной и непрерывной физикой, при этом он берет на себя большую часть логики синхронизации клиента и сервера. Очень рекомендую использовать этот фреймворк для начала, если вы не хотите реализовывать сетевое взаимодействие самостоятельно. У него очень грамотная и понятная документация.

Как я написал собственный фреймворк

На одной из конференций в технологическом институте Рочестера, в которой я принимал участие, наша команда представила многопользовательскую игру. Моей задачей было написание фреймворка для игры.

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

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

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

			git clone https://github.com/omgimanerd/game-framework
		
Создаем многопользовательскую браузерную игру. Часть вторая. Разбираем игровой фреймворк 4

Начнем с package.json — основы основ любого проекта на Node.js. Основные зависимости у меня — express, hashmap, morgan, pug и socket.io. Для этого проекта мне будут нужны только socket.io и hashmap. Morgan — это просто полезный инструмент для записи информации, express — широко используемый веб-фреймворк, pug — шаблонизатор. При желании вы можете заменить их на те инструменты, с которыми вам удобнее работать. Пакет hashmap используют для того, чтобы связать id сокета каждого клиента с игровым экземпляром, в то время как socket.io необходим для обеспечения общения в режиме реального времени.

Перейдем к bower.json, который содержит две зависимости на стороне клиента: jquery и howler.js. Ни одна из них нам особо не понадобится, но jquery может упростить вам работу, а howler.js — классная библиотека, которая добавляет в игру звук. Позже я расскажу, как использую howler.js.

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

			Entity2D.prototype.update = function(deltaTime) {
  var currentTime = (new Date()).getTime();
  if (deltaTime) {
    this.deltaTime = deltaTime;
  } else if (this.lastUpdateTime === 0) {
    this.deltaTime = 0;
  } else {
    this.deltaTime = (currentTime - this.lastUpdateTime) / 1000;
  }
  for (var i = 0; i < DIMENSIONS; ++i) {
    this.position[i] += this.velocity[i] * this.deltaTime;
    this.velocity[i] += this.acceleration[i] * this.deltaTime;
    this.acceleration[i] = 0;
  }
  this.lastUpdateTime = currentTime;
};
		

Обратите внимание, как реализовано независимое обновление частоты смены кадров, о которой говорилось в первой части. Обновления позиции и скорости происходят благодаря deltaTime, что приводит к плавной игре. Player.js — просто расширение Entity2D.js, которое управляет входными данными пользователя помимо базовой физики.

Game.js немного интереснее.

			function Game() {
  this.clients = new HashMap();
  this.players = new HashMap();
}
		

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

			Game.prototype.update = function() {
  var players = this.getPlayers();
  for (var i = 0; i < players.length; ++i) {
    players[i].update();
  }
};
Game.prototype.sendState = function() {
  var ids = this.clients.keys();
  for (var i = 0; i < ids.length; ++i) {
    this.clients.get(ids[i]).emit('update', {
      self: this.players.get(ids[i]),
      players: this.players.values().filter(
          (player) => player.id != ids[i])
    });
  }
};
		

Game.prototype.update() обновляет каждый элемент в игре, а Game.prototype.sendState() передает состояние игры каждому клиенту, подключенному к серверу. Единственное, что нужно отметить (и что отличает мою реализацию от других) — я отфильтровываю подключенных в данный момент игроков и отправляю каждому информацию отдельно. Однако вы можете реализовать это по-своему. Я решил сделать именно так, поскольку так мне легче работать на стороне клиента.

Поговорим о папке public. Она содержит статические файлы JavaScript, которые выполняются со стороны клиента.

Создаем многопользовательскую браузерную игру. Часть вторая. Разбираем игровой фреймворк 5

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

Drawing.js — это объект контекста Canvas, который прорисовывает все последовательности в спрайтах. Файл Game.js использует window.requestAnimationFrame(), чтобы запустить у клиента цикл для отправки входных данных пользователя на игровой сервер, а также для исполнения полученных состояний игры.

			Game.prototype.animate = function() {
  this.animationFrameId = window.requestAnimationFrame(
      Util.bind(this, this.update));
};
Game.prototype.update = function() {
  if (this.selfPlayer) {
    // Слушаем события игрока
    this.socket.emit('player-action', {
      keyboardState: {
        left: Input.LEFT,
        right: Input.RIGHT,
        up: Input.UP,
        down: Input.DOWN
      }
    });
    // Рисуем состояние в canvas
    this.draw();
  }
  this.animate();
};
		

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

Файл Input.js я очень часто использую в своих играх (с некоторыми изменениями). Он связывает обработчики определенных событий, чтобы отслеживать входные данные от клавиатуры/мыши, и сохраняет их для быстрого доступа. Когда я выделяю эту функцию и сохраняю ее в одном классе, мне не приходится беспокоиться о том, чтобы получать входные данные пользователя в любом другом месте.

Хотя Sound.js не используется в демо-модели игры, я немного расскажу о нем здесь. Этот файл содержит некоторые функции потрясающей библиотеки howler.js, которые я использую для воспроизведения звуков в моих играх. После инициализации он создает Howl-объекты для каждого звука, который будет использоваться в игре. Звуки затем могут воспроизводиться с использованием Sound.prototype.play(). Лично я сделал так, что все звуковые файлы должны храниться в папке sound внутри общедоступного каталога, но это можно легко изменить и переделать так, как вы считаете нужным. Я настоятельно рекомендую просмотреть документацию howler.js.

Двигаемся дальше. Папка shared содержит только один файл — Util.js.

Создаем многопользовательскую браузерную игру. Часть вторая. Разбираем игровой фреймворк 6

Util.js — еще один из тех файлов, которые я часто использую в своих проектах. Он содержит множество вспомогательных функций, которые используются как на стороне клиента, так и на стороне сервера. Папка shared служит для статики, поэтому я могу подключить скрипт из файла HTML и использовать его с помощью require() на стороне сервера, когда мне это потребуется. Вы можете перемещать этот файл, если хотите, просто такая организация удобна для меня.

Кроме этого, я использую папку shared для хранения игровых констант, к которым необходимо иметь доступ как серверу, так и клиенту. Например, у меня есть класс, который выглядит следующим образом:

			function Constants() {}

Constants.PLAYER_HITBOX = 10;

Constants.WORLD_SIZE = 2500;

if (typeof(module) === 'object') {
  module.exports = Constants;
} else {
  window.Util = Util;
}
		

Этот код позволяет легко обрабатывать класс на стороне клиента или сервера. Папка views не очень интересна и содержит шаблон для демонстрации модели.

			doctype html
html
  head
    title Game!
  body
    canvas(id='canvas' width='800px' height='600px')
  script(src='/socket.io/socket.io.js')
  script(src='/public/bower/jquery/dist/jquery.min.js')
  script(src='/shared/Util.js')
  script(src='/public/js/game/Drawing.js')
  script(src='/public/js/game/Game.js')
  script(src='/public/js/game/Input.js')
  script(src='/public/js/game/Sound.js')
  script(src='/public/js/client.js')
		

Я использую шаблонизатор pug, но вы легко можете подключить любой другой. И, наконец, в корневой папке у нас есть файл server.js. Это довольно стандартный сервер Node.js, использующий веб-фреймворк express.

			const FPS = 60;
io.on('connection', (socket) => {
  socket.on('player-join', () => {
    game.addNewPlayer(socket);
  });
  socket.on('player-action', (data) => {
    game.updatePlayerOnInput(socket.id, data);
  });
  socket.on('disconnect', () => {
    game.removePlayer(socket.id);
  })
});
setInterval(() => {
  game.update();
  game.sendState();
}, 1000 / FPS);
		

В первой части я рассказывал про обработчик событий сокета, и это переработанный, упорядоченный код, где я использую методы, которые были определены в классе Game ранее. Цикл setInterval() просто запускает обновление игры и передачу состояния снова и снова с частотой примерно 60 кадров в секунду.

Что дальше

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

Спасибо, что прочитали!

Следите за новыми постами
Следите за новыми постами по любимым темам
12К открытий12К показов