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

socket

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


В 2014 году я впервые побывал на CodeDay в Нью-Йорке. И хотя CodeDay не совсем хакатон, это было моё первое знакомство с подобными мероприятиями. Там мы с моим другом Кеннетом Ли написали многопользовательскую игру в танчики. Так как несколько моих друзей спрашивали меня о том, как я её написал, я решил описать процесс её создания.

В этом посте я вкратце опишу ход своих рассуждений и покажу, как воссоздать архитектуру, а также дам некоторые советы, если вы захотите сделать игру сами. Этот пост рассчитан на тех, кто владеет основами JavaScript и Node.js. Если вы с ними не знакомы, то есть много замечательных онлайн-ресурсов, где можно их изучить.

Прим. перев. На нашем сайте есть много познавательных материалов как по JavaScript, так и по Node.js — обязательно найдёте что-нибудь подходящее.

Бэкенд игры написан на Node.js с использованием веб-сокетов, которые позволяют серверу и клиенту общаться в режиме реального времени. Со стороны клиента игра отображается в HTML5-элементе Canvas. Для начала нам, конечно же, понадобится Node.js. В этой статье описана работа с версией 6.3.1, но вы можете использовать любую версию выше 0.12.

Прим. перев. Если вы не знакомы с веб-сокетами, рекомендуем прочитать наш вводный материал.

Создание проекта

Для начала установите зависимости. Создайте папку проекта, перейдите в неё и запустите следующий код:

npm init
npm install --save express socket.io

Для быстрой настройки сервера целесообразно использовать фреймворк Express, а для обработки веб-сокетов на сервере — пакет socket.io. В файл server.js поместите следующий код:

// Зависимости
var express = require('express');
var http = require('http');
var path = require('path');
var socketIO = require('socket.io');
var app = express();
var server = http.Server(app);
var io = socketIO(server);

app.set('port', 5000);
app.use('/static', express.static(__dirname + '/static'));

// Маршруты
app.get('/', function(request, response) {
    response.sendFile(path.join(__dirname, 'index.html'));
});

// Запуск сервера
server.listen(5000, function() {
    console.log('Запускаю сервер на порте 5000');
});

Это довольно типичный код для сервера на связке Node.js + Express. Он устанавливает зависимости и основные маршруты сервера. Для этого демонстрационного приложения используется только один файл index.html и папка static. Создайте их в корневой папке проекта. Файл index.html довольно простой:

<html>
  <head>
    <title>Наша многопользовательская игра</title>
    <style>
      canvas {
        width: 800px;
        height: 600px;
        border: 5px solid black;
      }
    </style>
    <script src="/socket.io/socket.io.js?x18050"></script>
  </head>
  <body>
    <canvas id="canvas"></canvas>
  </body>
  <script src="/static/game.js?x18050"></script>
</html>

Ваш пользовательский интерфейс может содержать куда больше элементов, поэтому для более крупных проектов CSS-стили лучше помещать в отдельный файл. Для простоты я оставлю CSS в коде HTML. Обратите внимание, что я включил в код скрипт socket.io.js. Он автоматически заработает в рамках пакета socket.io при запуске сервера.

Теперь нужно настроить веб-сокеты на сервере. В конец файла server.js добавьте:

// Обработчик веб-сокетов
io.on('connection', function(socket) {
});

Пока что в игре нет никаких функций, поэтому в обработчик веб-сокетов ничего добавлять не нужно. Для тестирования допишите следующие строки в конец файла server.js:

setInterval(function() {
    io.sockets.emit('message', 'hi!');
}, 1000);

Эта функция будет отправлять сообщение с именем message и содержимым hi всем подключенным веб-сокетам. Позже не забудьте удалить эту часть кода, так как она предназначена только для тестирования.

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

var socket = io();
socket.on('message', function(data) {
    console.log(data);
});

Запустите сервер командой node server.js и в любом браузере перейдите по ссылке http://localhost:5000. Если вы откроете окно разработчика (нажать правую кнопку мыши → Проверить (Inspect)), то увидите, как каждую секунду приходит новое сообщение:

Как правило, socket.emit(name, data) отправляет сообщение с заданным именем и данными серверу, если запрос идет от клиента, и наоборот, если запрос идет от сервера. Для получения сообщений по конкретному имени используется следующая команда:

socket.on('name', function(data) {
    // аргумент data может содержать любые отправляемые данные
});

С помощью socket.emit() вы можете отправить любое сообщение. Можно также передавать объекты JSON, что для нас очень удобно. Это позволяет мгновенно передавать информацию в игре от сервера к клиенту и обратно, что является основой многопользовательской игры.

Теперь пусть клиент отправляет некоторые состояния клавиатуры. Поместите следующий код в конец файла static/game.js:

var movement = {
  up: false,
  down: false,
  left: false,
  right: false
}
document.addEventListener('keydown', function(event) {
  switch (event.keyCode) {
    case 65: // A
      movement.left = true;
      break;
    case 87: // W
      movement.up = true;
      break;
    case 68: // D
      movement.right = true;
      break;
    case 83: // S
      movement.down = true;
      break;
  }
});
document.addEventListener('keyup', function(event) {
  switch (event.keyCode) {
    case 65: // A
      movement.left = false;
      break;
    case 87: // W
      movement.up = false;
      break;
    case 68: // D
      movement.right = false;
      break;
    case 83: // S
      movement.down = false;
      break;
  }
});

Это стандартный код, который позволяет отслеживать нажатие клавиш W, A, S, D. После этого добавьте сообщение, которое оповестит сервер о том, что в игре появился новый участник, и создайте цикл, который будет сообщать серверу о нажатии клавиш.

socket.emit('new player');
setInterval(function() {
  socket.emit('movement', movement);
}, 1000 / 60);

Эта часть кода позволит отправлять на сервер информацию о состоянии клавиатуры клиента 60 раз в секунду. Теперь необходимо прописать эту ситуацию со стороны сервера. В конец файла server.js добавьте следующие строки:

var players = {};
io.on('connection', function(socket) {
  socket.on('new player', function() {
    players[socket.id] = {
      x: 300,
      y: 300
    };
  });
  socket.on('movement', function(data) {
    var player = players[socket.id] || {};
    if (data.left) {
      player.x -= 5;
    }
    if (data.up) {
      player.y -= 5;
    }
    if (data.right) {
      player.x += 5;
    }
    if (data.down) {
      player.y += 5;
    }
  });
});
setInterval(function() {
  io.sockets.emit('state', players);
}, 1000 / 60);

Давайте разберёмся с этим кодом. Вы будете хранить информацию о всех подключенных пользователях в виде объектов JSON. Так как у каждого подключённого к серверу сокета есть уникальный id, клавиша будет представлять собой id сокета подключённого игрока. Значение же будет другим объектом JSON, содержащим координаты x и y.

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

io.sockets.emit() — это запрос, который будет отправлять сообщение и данные ВСЕМ подключённым сокетам. Сервер будет отправлять это состояние всем подключённым клиентам 60 раз в секунду.

На данном этапе клиент ещё ничего не делает с этой информацией, поэтому добавьте со стороны клиента обработчик, который будет отображать данные от сервера в Canvas.

var canvas = document.getElementById('canvas');
canvas.width = 800;
canvas.height = 600;
var context = canvas.getContext('2d');
socket.on('state', function(players) {
  context.clearRect(0, 0, 800, 600);
  context.fillStyle = 'green';
  for (var id in players) {
    var player = players[id];
    context.beginPath();
    context.arc(player.x, player.y, 10, 0, 2 * Math.PI);
    context.fill();
  }
});

Этот код обращается к id Canvas (#canvas) и рисует там. Каждый раз, когда от сервера будет поступать сообщение о состоянии, данные в Canvas будут обнуляться, и на нём в виде зеленых кружков будут заново отображаться все игроки.

Теперь каждый новый игрок сможет видеть состояние всех подключенных игроков на Canvas. Запустите сервер командой node server.js и откройте в браузере два окна. При переходе по ссылке http://localhost:5000 вы должны будете увидеть нечто похожее:

Вот и всё! Если у вас возникли проблемы, посмотрите архив с исходным кодом.

Некоторые тонкости

Когда будете разрабатывать более функциональную игру, целесообразно разделить код на несколько файлов.

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

Однако в этом демо-проекте есть несколько недостатков. Обновление игры связано со слушателем сокета. Если бы я хотел повлиять на ход игры, то мог бы написать в консоли браузера следующее:

while (true) {
  socket.emit('movement', { left: true });
}

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

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

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

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

В идеале циклы обновлений как у клиента, так и на сервере не должны зависеть от сокетов. Попытайтесь сделать так, чтобы обновления игры находились за пределами блока socket.on(). В противном случае вы можете получить много странных нелогичных действий из-за того, что обновление игры будет связано с обновлением сокета.

Кроме того, старайтесь избегать такого кода:

setInterval(function() {
  // код ...
  player.x += 5;
  // код ...
}, 1000 / 60);

В этом отрезке кода обновление координаты х для игрока связано с частотой смены кадров в игре. SetInterval() не всегда гарантирует соблюдение интервала, вместо этого напишите нечто подобное:

var lastUpdateTime = (new Date()).getTime();
setInterval(function() {
  // код ...
  var currentTime = (new Date()).getTime();
  var timeDifference = currentTime - lastUpdateTime;
  player.x += 5 * timeDifference;
  lastUpdateTime = currentTime;
}, 1000 / 60);

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

Также можно сделать так, чтобы из игры удалялись отключенные игроки. Когда сокет отключается, автоматически отправляется сообщение о разъединении. Это можно прописать так:

io.on('connection', function(socket) {
  // обработчик событий ...
  socket.on('disconnect', function() {
    // удаляем отключившегося игрока
  });
});

Также попытайтесь создать собственный физический движок. Это сложно, но весело. Если захотите попробовать, то рекомендую прочитать книгу «The Nature of Code», в которой есть много полезных идей.

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

Перевод статьи «How To Build A Multiplayer Browser Game (Part 1)»