26 декабря 2013 г.

Tronode.js - часть 3 - написание игровой логики браузерной игры на node.js

В этом посте я опишу процесс разработки серверной части браузерной многопользовательской игры, клона Трон, - Tronode.js. В статье постараюсь показать преимущества использования одного и того же кода на сервере и на клиенте. Статья предполагает, что читатель знаком с node.js или хотя бы прочитал часть 2 этого цикла. О правилах игры и её примерном внешнем виде можно узнать из первой части. О написании бота для игры рассказано на Хабре.


Класс 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;
        }
    };
}


Заключение

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


Комментариев нет:

Отправить комментарий