В этом посте я опишу процесс разработки серверной части браузерной многопользовательской игры, клона Трон, - Tronode.js. В статье постараюсь показать преимущества использования одного и того же кода на сервере и на клиенте. Статья предполагает, что читатель знаком с node.js или хотя бы прочитал часть 2 этого цикла. О правилах игры и её примерном внешнем виде можно узнать из первой части. О написании бота для игры рассказано на Хабре.
Метод updateHtml() будем вызывать в методе move(), если мы работаем на клиенте. Это можно проверить, так как в этом случае переменная document будет определена.
Столкновения мы будем определять на сервере, поэтому на клиенте нет необходимости оставлять код. К счастью, код останется тем же, нужно только изменить переменные, которые у нас хранят мотоциклы. Для хранения состояния о том, столкнулся мотоцикл или нет, добавим в конструктор Bike переменную this.collided = false;. При столкновении игра должно проверять, есть ли еще не столкнувшиеся, иначе закончить партию. Для возможности подключать обработчики столкновений, добавим в класс Bike пару методов
Класс Bike вынесем в отдельный файл и поместим его в public/javascripts/bike.js
Таким образом мы сможем его подключать на клиентской стороне. А чтобы подключить его на сервере, в конце bike.js нужно добавить блок
Для node.js переменная exports является объектом и используется для модульной системы. То есть, чтобы из другого файла на сервере получить доступ к классу Bike нам нужно писать
Для передачи данных от сервера к клиенту и последующего их обновления там, добавим два метода getData() и setData():
Список слотов будем хранить в обычном массиве, который предварительно зададим пустым в конструкторе, а для его заполнения и обнуления добавим метод в GameServer:
Для инициализации нового игрока с заполнением слота данными и уведомлением всех других подключенных пользователей добавим метод initializePlayer():
На клиенте мы предполагаем возможность указать имя игрока и нажать кнопку "Присоединиться к игре". Те, кто не присоединился или тот, кому не хватило игровых столов, у нас будут зрителями. Подключать обработчики для входящих сообщений будем в методе start():
Выше описан метод updateClients(), который собирает информацию из слотов о всех используемых мотоциклах и пересылает их всем клиентам, чтобы они могли обновить данных всех мотоциклов на клиентской части.
При проверке условий окончания игры нам так же нужно определить победителя. Им считается тот, кто последний произвел столкновение. У нас есть возможность устанавливать обработчик, который будет вызываться при каждом столкновении мотоцикла. Можем поместить в него проверку, которая вызовет endGame() с победителем:
Если с вопросом завершения игры всё предельно ясно, то вот с вопросом начала игры можно поэксперементировать. Как видно из кода основного цикла, у нас есть булевая переменная this.gameStarted. Для простого варианта можно её сделать true и никогда не изменять значение. Это должно привести к постоянному рестарту игры. А можно добавить пользователям возможность решать, когда начинать игру. Я сделал вариант с задержкой рестарта игры после окончания, используя setTimeout, но это непринципиально.
В файле client.js разместим код из первой части для управления мотоциклом и обработку сообщений
Как видно по коду мы отлавливаем поступающие события и делаем соответсвующие обновления. На клиенте у нас теперь нет основного цикла, а обновление положения мотоциклов мы делаем только по сообщению от сервера. Метод startGame() в основном прикрепляет обработчики к разным кнопкам на странице, предназначение которых можно понять по их идентификаторам.
Остальные функции предназначены для управления
Класс GameServer для обработки сообщений
Всю логику по обработке входящих и исходящих сообщений вынесем в отдельный класс GameServer. Для начала обозначим, что в нём должно быть
function GameServer(sockets) { this.sockets = sockets; this.mainLoopInterval = 100; this.gameTime = 0; } GameServer.prototype.start = function() { this.sockets.on('connection', function (socket) { console.log('connected'); socket.on('disconnect', function(){ console.log('disconnected'); }); } var _this = this; setInterval(function(){ _this.mainLoop(); }, this.mainLoopInterval); }; GameServer.prototype.mainLoop = function() { this.gameTime += this.mainLoopInterval; };
Класс Bike для хранения состояний мотоциклов
Любые клиентские состояния об игре можно подделать, поэтому мы будем хранить всю информацию на сервере, а также на сервере будем проводить важные расчеты, которые влияют на эти состояния. Нам нужно хранить положения всех мотоциклов, а так же информацию о том, столкнулись они уже или нет. В процессе работы над клиентской частью мы разработали класс Bike. Так как мы используем один язык программирования для серверной и клиентской части, мы можем попробовать использовать один и тот же класс на обеих сторонах. При этом следует учесть, что на сервере нам не нужна та часть, которая производит рисование объекта. Вынесем работу по обновлению DOM-элементов в отдельный методBike.prototype.updateHtml = function() { var w = this.currentHtmlWidth; var h = this.currentHtmlHeight; if (w > 0){ this.currentHtml.style.width = w + "px"; this.currentHtml.style.left = (this.x - w + 5) + "px"; } else { this.currentHtml.style.width = Math.abs(w) + 5 + "px"; this.currentHtml.style.left = this.x + "px"; } if (h > 0){ this.currentHtml.style.height = h + "px"; this.currentHtml.style.top = (this.y) + "px"; } else { this.currentHtml.style.height = (Math.abs(h) + this.defaultHeight) + "px"; this.currentHtml.style.top = (this.y + h) + "px"; } this.headHtml.style.left = (this.x - 1) + "px"; this.headHtml.style.top = (this.y - 1) + "px"; };Здесь мы оставляем this.currentHtmlWidth и this.currentHtmlHeight, несмотря на то, что они нужны только на клиентской стороне, так как на сервере мы можем их посчитать в одном блоке кода вместе с высчитыванием текущего положения. Просто это позволяет сократить код.
Метод updateHtml() будем вызывать в методе move(), если мы работаем на клиенте. Это можно проверить, так как в этом случае переменная document будет определена.
Bike.prototype.move = function(stepSize) { if (this.collided){ return; } if (!this.currentHtml && (typeof document === 'object')) { this.createHtml(); } if (this.turnPoints.length === 0){ this.turnPoints.push([this.x, this.y]); } if (this.makeTurn){ if (typeof document === 'object'){ this.createHtml(); } this.currentHtmlWidth = this.defaultWidth; this.currentHtmlHeight = this.defaultHeight; this.turnPoints.push([this.x, this.y]); } switch (this.direction) { case "u": this.y -= stepSize; this.currentHtmlHeight += stepSize; break; case "r": this.x += stepSize; this.currentHtmlWidth += stepSize; break; case "d": this.y += stepSize; this.currentHtmlHeight -= stepSize; break; case "l": this.x -= stepSize; this.currentHtmlWidth -= stepSize; break; } if (this.currentHtml){ this.updateHtml(); } this.makeTurn = false; };Здесь добавляем проверку переменной this.makeTurn, она нам нужна, так как каждый поворот должен обнулять значения высоты и ширины для html-элемента шлейфа, потому что на клиенте мы начинаем новый блок шлейфа.
Столкновения мы будем определять на сервере, поэтому на клиенте нет необходимости оставлять код. К счастью, код останется тем же, нужно только изменить переменные, которые у нас хранят мотоциклы. Для хранения состояния о том, столкнулся мотоцикл или нет, добавим в конструктор Bike переменную this.collided = false;. При столкновении игра должно проверять, есть ли еще не столкнувшиеся, иначе закончить партию. Для возможности подключать обработчики столкновений, добавим в класс Bike пару методов
Bike.prototype.setOnCollideCallback = function(callback){ this.onCollideCallback = callback; }; Bike.prototype.collide = function() { this.direction = null; this.collided = true; if (this.onCollideCallback){ this.onCollideCallback(this); } };
Класс Bike вынесем в отдельный файл и поместим его в public/javascripts/bike.js
Таким образом мы сможем его подключать на клиентской стороне. А чтобы подключить его на сервере, в конце bike.js нужно добавить блок
if (typeof exports === 'object'){ exports.Bike = Bike; }
Для node.js переменная exports является объектом и используется для модульной системы. То есть, чтобы из другого файла на сервере получить доступ к классу Bike нам нужно писать
var bikeModule = require('./../public/javascripts/bike.js'); var bike = new bikeModule.Bike(number);
Для передачи данных от сервера к клиенту и последующего их обновления там, добавим два метода getData() и setData():
Bike.prototype.setData = function(data) { this.number = data.number; this.x = data.x; this.y = data.y; this.direction = data.direction; this.turnPoints = data.turnPoints; this.currentHtmlWidth = data.currentHtmlWidth; this.currentHtmlHeight = data.currentHtmlHeight; if (!this.currentHtml && (typeof document === 'object')) { this.createHtml(); } if (this.currentHtml){ this.updateHtml(); } this.makeTurn = data.makeTurn; this.name = data.name; }; Bike.prototype.getData = function() { var data = {}; data.number = this.number; data.x = this.x; data.y = this.y; data.direction = this.direction; data.turnPoints = this.turnPoints; data.currentHtmlWidth = this.currentHtmlWidth; data.currentHtmlHeight = this.currentHtmlHeight; data.makeTurn = this.makeTurn; data.name = this.name; return data; };
Слоты для игроков
Для ограничения количества одновременно играющих введем слоты. Слот - это объект, в котором мы будем хранить следующую информацию:- id - уникальный номер слота, он же будет номером для мотоцикла (тот номер, который красит);
- pos - стартовая позиция мотоцикла этого слота;
- direction - стартовое направление мотоцикла этого слота;
- socketId - идентификатор сокета, который занял этот слот;
- bike - мотоцикл (объект) в этом слоте.
Список слотов будем хранить в обычном массиве, который предварительно зададим пустым в конструкторе, а для его заполнения и обнуления добавим метод в GameServer:
GameServer.prototype.resetSlots = function(){ this.slots = []; for (var is in this.initialSlots){ var islot = this.initialSlots[is]; var slotData = {}; slotData['id'] = islot.id; slotData['pos'] = islot.pos; slotData['direction'] = islot.direction; slotData['socketId'] = null; slotData['bike'] = null; this.slots.push(slotData); } }; // и в конструктор допишем function Bike(number) { /* ... */ this.collided = false; this.onCollideCallback = null; }
Для инициализации нового игрока с заполнением слота данными и уведомлением всех других подключенных пользователей добавим метод initializePlayer():
GameServer.prototype.initializePlayer = function(slot, socket, name) { var bikeModule = require('./../public/javascripts/bike.js'); slot.socketId = socket.id; var bike = new bikeModule.Bike(slot.id, slot.pos); bike.x = slot.pos[0]; bike.y = slot.pos[1]; bike.direction = slot.direction; bike.name = _this.removeTags(name); //метод, который очищает имя от тегов и прочего bike.setOnCollideCallback(function(){ _this.onBikeCollided(bike); }); // отправим владельцу слота данные о его мотоцикле, которым он будет управлять socket.emit('state', { 'state': 'addBike', 'bike': bike.getData() }); slot.bike = bike; // отправим всем остальным клиентам данные о новом мотоцикле socket.broadcast.emit('state', { 'state': 'newPlayer', 'bike': bike.getData() }); };
На клиенте мы предполагаем возможность указать имя игрока и нажать кнопку "Присоединиться к игре". Те, кто не присоединился или тот, кому не хватило игровых столов, у нас будут зрителями. Подключать обработчики для входящих сообщений будем в методе start():
GameServer.prototype.start = function() { var _this = this; //сохраняем контекст для использования в обработчиках this.sockets.on('connection', function (socket) { socket.on('control', function(data){ var bike = _this.getBikeBySocketId(socket.id); //проверяем, если ли уже мотоцикл этого клиента if (!bike && data.button === 'join') { var slot = _this.getNextFreeSlot(); //перебирает все слоты в поисках незанятого if (slot) { _this.initializePlayer(slot, socket, data.name); } } else if (bike) { if (data.button === 'right') { bike.turnRight(); _this.updateClients(); } else if (data.button === 'left') { bike.turnLeft(); _this.updateClients(); } } }); socket.emit('state', { 'state': 'connected', 'existedBikes': _this.getBikes().map(function(bike){return bike.getData();}) }); socket.on('disconnect', function() { // освобождаем слот var slot = _this.getSlotBySocketId(socket.id); if (slot) { slot.socketId = null; slot.bike = null; } }); }); this.resetSlots(); setInterval(function(){ _this.mainLoop(); }, this.mainLoopInterval); }; GameServer.prototype.updateClients = function(){ var bikesData = []; var bikes = this.getBikes(); for (var b in bikes){ var bike = bikes[b]; bikesData.push(bike.getData()); } this.sockets.emit('state', { 'state': 'update', 'bikes': bikesData }); };
Выше описан метод updateClients(), который собирает информацию из слотов о всех используемых мотоциклах и пересылает их всем клиентам, чтобы они могли обновить данных всех мотоциклов на клиентской части.
Основной цикл и окончание игры
В основном цикле мы будем передвигать все мотоциклы в их направлении и проверять столкновения. Для этого напишем содержимое метода с основным циклом:GameServer.prototype.mainLoop = function() { if (!this.gameStarted){ return; } var bikes = this.getBikes(); for (var b in bikes) { var bike = bikes[b]; bike.move(10); } this.detectCollisions(); this.gameTime += _this.mainLoopInterval; this.updateClients(); };
При проверке условий окончания игры нам так же нужно определить победителя. Им считается тот, кто последний произвел столкновение. У нас есть возможность устанавливать обработчик, который будет вызываться при каждом столкновении мотоцикла. Можем поместить в него проверку, которая вызовет endGame() с победителем:
GameServer.prototype.onBikeCollided = function(bike){ var last = true; for (var s in this.slots) { var slot = this.slots[s]; if (slot.bike && !slot.bike.collided) { last = false; } } if (last){ this.endGame(bike); } }; GameServer.prototype.endGame = function(winnerBike){ //this.gameStarted = false; this.sockets.emit('state', { 'state': 'endGame', 'winnerBike': winnerBike.getData(), 'time': this.gameTime }); this.gameTime = 0; this.resetSlots(); };
Если с вопросом завершения игры всё предельно ясно, то вот с вопросом начала игры можно поэксперементировать. Как видно из кода основного цикла, у нас есть булевая переменная this.gameStarted. Для простого варианта можно её сделать true и никогда не изменять значение. Это должно привести к постоянному рестарту игры. А можно добавить пользователям возможность решать, когда начинать игру. Я сделал вариант с задержкой рестарта игры после окончания, используя setTimeout, но это непринципиально.
Пользовательская часть
Основные моменты на стороне сервера рассмотрены. Кратко расскажу о необходимых изменениях в клиентской части. В первую очередь подключим все необходимые js-файлы:<script src="/javascripts/socket.io.min.js"></script> <script src="/javascripts/bike.js"></script> <script src="/javascripts/client.js"></script>
В файле client.js разместим код из первой части для управления мотоциклом и обработку сообщений
var gameWidth = 800; var gameHeight = 600; var myBike; var bikes = []; var gameContainer; var socket = io.connect(document.location); socket.on('state', function(data) { if (data.state === 'connected'){ startGame(); for (var b in data.existedBikes){ var bikeData = data.existedBikes[b]; var bike = new Bike(bikeData.number); bike.allocate(gameContainer); bike.setData(bikeData); bikes.push(bike); } } else if (data.state === 'addBike'){ myBike = new Bike(data.bike.number); myBike.allocate(gameContainer); myBike.setData(data.bike); bikes.push(myBike); var bikeExample = document.getElementById('bike-example'); bikeExample.className = 'bike-' + myBike.number; document.getElementById('bike-example-name').innerHTML = myBike.name; bikeExample.style.display = 'block'; document.getElementById('start-btn').style.display = 'block'; } else if (data.state === 'update'){ for (var lb in bikes){ var localBike = bikes[lb]; for (var b in data.bikes){ var bike = data.bikes[b]; if (bike.number === localBike.number){ localBike.setData(bike); localBike.move(10); } } } } else if (data.state === 'endGame'){ document.getElementById('winner-name').innerHTML = data.winnerBike.name; document.getElementById('winner-time').innerHTML = Math.round(data.time / 1000); document.getElementById('endgame-container').style.display = 'block'; bikes = []; } else if (data.state === 'newPlayer'){ var bike = new Bike(data.bike.number); bike.allocate(gameContainer); bike.setData(data.bike); bikes.push(bike); } }); function startGame() { gameContainer = document.getElementById('game-container'); var body = document.getElementsByTagName('body')[0]; bindEvents(body); document.getElementById('join-btn').onclick = join; document.getElementById('reset').onclick = reset; document.getElementById('start-btn').onclick = function(){ if (myBike){ socket.emit('control', {'button': 'start'}); document.getElementById('start-btn').style.display = 'none'; } }; }
Как видно по коду мы отлавливаем поступающие события и делаем соответсвующие обновления. На клиенте у нас теперь нет основного цикла, а обновление положения мотоциклов мы делаем только по сообщению от сервера. Метод startGame() в основном прикрепляет обработчики к разным кнопкам на странице, предназначение которых можно понять по их идентификаторам.
Остальные функции предназначены для управления
function join(){ var joinName = document.getElementById('join-name').value; if (joinName) { socket.emit('control', {'button': 'join', 'name': joinName}); document.getElementById('join-container').style.display = 'none'; } } function reset() { gameContainer.innerHTML = ''; for (var b in bikes){ var bike = bikes[b]; bike.allocate(gameContainer); bike.createHtml(); bike.updateHtml(); } document.getElementById('endgame-container').style.display = 'none'; join(); } function bindEvents(container) { container.onkeydown = function(e) { if (!myBike){ return; } e = e || window.event; switch (e.keyCode) { case keyRight: case keyD: myBike.turnRight(); socket.emit('control', {'button': 'right'}); break; case keyLeft: case keyA: myBike.turnLeft(); socket.emit('control', {'button': 'left'}); break; } }; }
Комментариев нет:
Отправить комментарий