0
Обложка: Создать JS-игру с мультиплеером за 24 часа и выжить

Создать JS-игру с мультиплеером за 24 часа и выжить

За 24 часа можно успеть очень много. Сегодня я решил рассказать, как всего за сутки мы с моими коллегами (шестью фронтендерами и одним бэкендером) создали настоящую мультиплеерную игру на JavaScript. Поехали!

Вадим Силантьев
Вадим Силантьев
Frontend-разработчик IRLIX

Собрались похакатонить…

Совсем недавно в компании IRLIX, где я работаю frontend-разработчиком, прошло знакомое для многих мероприятие — хакатон. На мой взгляд, это очень крутой формат, который независимо от масштаба позволяет изучить новые грани разработки. В том числе для frontend направления. Главное — объективно оценивать, на что вы способны и найти баланс между полётом фантазии и реальностью при выполнении задач.

Целью данного хакатона было создать ровно за 24 часа игру с нуля, используя только JavaScript, Node.js, Soket.io. и два ящика энергетиков.

Я очень легко соглашаюсь на разную движуху. И мне совершенно не обязательно знать, что из этого выйдет. Уже в процессе меня одергивает разум: «Чувак, зачем ты в это вписался?». Но раз пути назад нет — погнали. Этот раз тоже не стал исключением. Не успел я оглянуться — и вот уже полностью погружен в процесс разработки игры.

Почему JavaScript?

Сегодня игрушку можно сделать практически на любом языке программирования, но мы решили остановиться на JS. Выбор стека был обусловлен в основном нашими знаниями по определенным технологиям. Мы решили использовать библиотеку Soket.io, так как она поддерживает постоянную связь клиента с сервером и имеет возможность создавать кастомные эвенты между ними, что необходимо при построении мультиплеерной игры. На мой взгляд, это было идеальным решением в рамках времени, которым мы располагали.

Процесс разработки или сутки, которые я не забуду никогда

Мне казалось нереально сложным иметь только общее представление о том, что должно получиться в итоге, и ни малейшего представления о том, как это сделать. Спустя сутки я понял, что в данном случае главная фича для крутого результата — это именно свобода действий. Ведь тебя практически ничего не ограничивает (ну, кроме времени, само собой) — фантазируй, придумывай, воплощай.

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

const gameLoop = (game, io) => {
  io.sockets.emit('state', game);
};

setInterval(() => {
  if (game.players && io) {
    gameLoop(game, io);
  }
}, engineTimeConnect);

Серверный движок

socket.on('state', (game) => {
  GAME = game;
  PLAYER = game.players[socket.id];
  dt = 0;
});

const drawLoop = () => {
  if (GAME) {
    now = performance.now();
    dt = dt + (now - last);
    const dtPercent = dt / engineTimeConnect;
    last = now;
    context.clearRect(0, 0, WINDOW_WIDTH, WINDOW_HIGHT);
    // Рисуем игроков
    for (const id in GAME.players) {
      const player = GAME.players[id];
      if (player.state !== 'DEATH') {
        drawPlayer(context, player, dtPercent);
      }
    }
  }
  window.requestAnimationFrame(drawLoop);
};
window.requestAnimationFrame(drawLoop);

Клиентский движок

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

const movement = {
  up: false,
  down: false,
  left: false,
  right: false,
}

document.addEventListener("keydown", (event) => {
  switch (event.keyCode) {
    case 65: // A
      movement.left = true;
      socket.emit("movement", movement);
      break;
    case 87: // W
      movement.up = true;
      socket.emit("movement", movement);
      break;
    case 68: // D
      movement.right = true;
      socket.emit("movement", movement);
      break;
    case 83: // S
      movement.down = true;
      socket.emit("movement", movement);
      break;
  }
});

document.addEventListener("keyup", (event) => {
  switch (event.keyCode) {
    case 65: // A
      movement.left = false;
      socket.emit("movement", movement);
      break;
    case 87: // W
      movement.up = false;
      socket.emit("movement", movement);
      break;
    case 68: // D
      movement.right = false;
      socket.emit("movement", movement);
      break;
    case 83: // S
      movement.down = false;
      socket.emit("movement", movement);
      break;
  }
});

Клиентский код

const movement = {
    up: false,
    down: false,
    left: false,
    right: false,
  };

  socket.on('movement', (move) => {
    movement.up = move.up;
    movement.down = move.down;
    movement.left = move.left;
    movement.right = move.right;
  });

  setInterval(() => {
    if (movement.down||movement.left||movement.right||movement.up) {
      const player = players[socket.id] || {};
      if (movement.left && player.positionX > 0) {
        player.moveTo('left');
      }
      if (movement.up && player.positionY > 0) {
        player.moveTo('up');
      }
      if (movement.right && player.positionX < map.size.x) {
        player.moveTo('right');
      }
      if (movement.down && player.positionY < map.size.y) {
        player.moveTo('down');
      }
    } else {
      const player = players[socket.id] || {};
      player.moveTo && player.moveTo('stop');
    }
  }, engineTimeConnect);

Серверный код

Это весь цикл перемещения игрока. Кроме того, для удобства в дальнейших кейсах мы решили задавать игроку состояние. Ниже пример метода перемещения игрока.

moveTo(direction) {
    if (this.state === playerState.DEATH) return null;

    switch (direction) {
      case 'left':
        this.state = playerState.MOVE;
        this.TempX = this.positionX - physic.v;
        break;
      case 'up':
        this.state = playerState.MOVE;
        this.TempY = this.positionY - physic.v;
        break;
      case 'right':
        this.state = playerState.MOVE;
        this.TempX = this.positionX + physic.v;
        break;
      case 'down':
        this.state = playerState.MOVE;
        this.TempY = this.positionY + physic.v;
        break;

      default:
        this.state = playerState.IDLE;
        break;
    }

Когда мы поняли, что движущиеся точки — это конечно круто, но недостаточно, решили пуститься «во все тяжкие» и делать мультиплеерный шутер. Первое, что мы сделали — проработали концепцию, отбросив все лишнее. После — упростили графику ( от того, что нарисовало богатое воображение в наших головах до реализуемого варианта). Затем перешли к тому, что создали персонажей и их способности к стрельбе.

Было много спорных моментов, обсуждений, далеко не всегда получалось прийти к компромиссу. И, конечно, были эпизоды, в которых спорить было не о чем, потому что мы просто не знали, как это воплотить.

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

Еще пример: мы не знали, что делать с ФПС ( наша бравая лошадка-сервер вывозила не больше 4 человек, а после грустно ложилась помирать). Происходило это по нескольким причинам: чем больше клиентов, тем больше данных нужно отправлять, а данные отправлялись 60 раз в секунду, на 8 игроках игровой цикл замедлялся до 15 циклов в секунду. Однако для оптимизации процессов было еще слишком рано, готовой игровой модели еще не было, и мы продолжили разработку.

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

 

// линейная интерполяция, piece = 0.0 -> 1.0
const lerp = (start, finish, piece) => {
  return start + (finish - start) * piece;
};

const drawProjectile = (ctx, bullet, dt) => {
  const x = bullet.positionX;
  const y = bullet.positionY;

  const smoothX = lerp(bullet.prevPositionX, x, dt);
  const smoothY = lerp(bullet.prevPositionY, y, dt);

  ctx.fillStyle = bullet.color;
  ctx.beginPath();
  ctx.arc(smoothX, smoothY, bullet._BulletRadius, 0, Math.PI * 2);
  ctx.fill();
  ctx.closePath();
}

Отрисовка снарядов на клиенте

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

Следующим этапом стала обработка столкновений. Первая наша попытка была просто ужасной: была куча блоков if/else, но все работало, однако глядя на такой код, хотелось плакать. Поэтому мы решили сделать все области столкновений в игре круговыми и обратиться к геометрии.

const getCollision = (obj1, obj2) => {
  const r = obj1.r + obj2.r;
  const dX = obj1.x - obj2.x;
  const dY = obj1.y - obj2.y;
  const xSq = dX * dX;
  const ySq = dY * dY;
  const dSq = xSq + ySq;
  const d = Math.sqrt(dSq);

  return r > d;
};

Реализация с помощью кода

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

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

Самым эмоциональным для меня стал момент записи звуков для игры. За неимением других источников нам пришлось имитировать их самим. Мы бегали вокруг стола, стучали кружками и делали что-то еще. К тому моменту прошло уже много часов хакатона, мозг немного был затуманен, что в некоторых моментах даже сыграло нам на руку. Особенно смешным и немного нелепым получился звук попадания снаряда — представьте, как рыба пытается сказать букву «П».

А что в результате?

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

***

Простые игры на Canvas в браузере могут сделать ваше приложение более интерактивным. Например, простой игрой вы можете заменить скучные и долгие экраны загрузок. А написание полноценных браузерных игр на JS может стать отличной альтернативой разработке однотипных, скучных приложений. Вердикт: геймдеву на JS быть!