24 декабря 2013 г.

Tronode.js - часть 1 - создание графической части браузерной игры

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

Это вводная часть, в ней нет ничего о самом node.js, зато есть о много о клиентской части. Объяснение постараюсь дать детальное, особенно в плане написания функций.



Выбор способа отображения

Для отображения игровой графики в браузере мне известно несколько способов:

  • рисование на canvas;
  • использование WebGL;
  • использование html-тегов и раскрашивание их с помощью css


Каждый подход имеет свои преимущества и недостатки. Я знал о сложностях работы с canvas, когда работал над Maze; о сложностях с WebGL узнал, когда работал над Amoeba; а вот способ с html-тегами мне попробовать еще не удалось, отчего он мне казался наиболее простым и подходящим. Изначально я планировал игру с очень простой реализацией, особенно в плане графики, так как много элементов отображать и не придется.

Кроме того, я привык работать с тегами, используя jQuery, но при портировании игры Web Developer на мобильные платформы с использованием phonegap, производительность конечного приложения оказывалась слишком низкая. Я винил jQuery, поэтому решил в этот раз освежить свои знания и попробовать сделать все на чистом js.

Создаем игровое поле и мотоцикл

Для начала создадим контейнер с помощью добавления обычного div. Он же и будет игровым полем.
 
var body = document.getElementsByTagName('body')[0];
var gameContainer = document.createElement("div");
gameContainer.className = "game";
body.appendChild(gameContainer);

В css добавим пару стилей для установки ширины, высоты, фона, но это не стоит внимания.
Теперь, когда у нас есть игровое поле, добавим мотоцикл. Создадим для него класс, который позволит нам создавать несколько экземпляров мотоциклов для их отображения.
function Bike(number, pos) {
    this.number = number;
    this.x = pos[0] || 0;
    this.y = pos[1] || 0;
    this.direction = "r";
    this.turnPoints = [];
    this.currentHtml = null;
    this.defaultWidth = 5;
    this.defaultHeight = 5;
    this.currentHtmlWidth = this.defaultWidth;
    this.currentHtmlHeight = this.defaultHeight;
    this.container = null;
    this.turnPoints.push(pos);
    this.headHtml = null;
}


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

  • x,y - позиция мотоцикла;
  • direction - направление движения мотоцикла, а чтобы было понятнее, я решил использовать значения u, r, d, l, которые сосуществуют up, right, down, left;
  • turnPoints - массив, который содержит координаты точек поворотов; они нужны мне для определения столкновений, т. к. по ним можно построить полные траектории;
  • defaultWidth, defaultHeight - это ширина и высота шлейфа по умолчанию;
  • currentHtmlWidth, currentHtmlHeight - это ширина и высота шлейфа в данный момент; т. к. мы изменяем высоту блока шлейфа, когда движемся по вертикали, и изменяем ширину, когда движемся по горизонтали;
  • container - это еще один div-блок, в который мы будем прикреплять все шлейфы и голову мотоцикла
  • headHtml - div-блок для обозначения головы (самого мотоцикла)
Теперь добавим несколько методов нашему классу.
Метод allocate для первоначального размещения мотоцикла на игровом поле. Самому элементу мы назначаем дополнительный css-класс с суффиксом номера мотоцикла, чтобы с помощью css можно было выставить цвета всем шлейфам мотоцикла.
Bike.prototype.allocate = function(parent) {
    this.container = document.createElement("div");
    this.container.className = "bike bike-" + this.number;
    parent.appendChild(this.container);

    this.headHtml = document.createElement("div");
    this.headHtml.className = "bike-head";
    this.container.appendChild(this.headHtml)
    this.headHtml.style.left = (this.x - 1) + "px";
    this.headHtml.style.top = (this.y - 1) + "px";
}


Метод createHtml, который создает html-элемент нашего шлейфа и назначает ему ширину, высоту и положение. Нам нужно создавать новый блок каждый раз, когда мотоцикл поворачивает.
Bike.prototype.createHtml = function() {
    this.currentHtml = document.createElement("div");
    this.currentHtml.className = "bike-trail";
    this.currentHtml.style.left = this.x + "px";
    this.currentHtml.style.top = this.y + "px";
    this.currentHtml.style.width = this.currentHtmlWidth + "px";
    this.currentHtml.style.height = this.currentHtmlHeight + "px";
    this.container.appendChild(this.currentHtml);
}


Метод move, который двигает мотоцикл в текущем направлении на некоторую величину stepSize. Фактически, мы изменяем положение блока шлейфа и его высоту или ширину в зависимости от направления:
Bike.prototype.move = function(stepSize) {
    if (!this.currentHtml) {
        this.createHtml();
    }
    switch (this.direction) {
        case "u":
            this.y -= stepSize;
            this.currentHtmlHeight += stepSize;
            this.currentHtml.style.height = this.currentHtmlHeight + "px";
            this.currentHtml.style.top = this.y + "px";
            break;
        case "r":
            this.x += stepSize;
            this.currentHtmlWidth += stepSize;
            this.currentHtml.style.width = this.currentHtmlWidth + "px";
            break;
        case "d":
            this.y += stepSize;
            this.currentHtmlHeight += stepSize;
            this.currentHtml.style.height = this.currentHtmlHeight + "px";
            break;
        case "l":
            this.x -= stepSize;
            this.currentHtmlWidth += stepSize;
            this.currentHtml.style.width = this.currentHtmlWidth + "px";
            this.currentHtml.style.left = this.x + "px";
            break;
    }
    this.headHtml.style.left = (this.x - 1) + "px";
    this.headHtml.style.top = (this.y - 1) + "px";
}


И два последних метода turnRight и turnLeft, которые создают новый блок шлейфа и изменяют направление движения мотоцикла:
Bike.prototype.turnRight = function() {
    this.currentHtml = null;
    this.currentHtmlWidth = this.defaultWidth;
    this.currentHtmlHeight = this.defaultHeight;
    this.turnPoints.push([this.x, this.y]);
    switch (this.direction) {
        case "r":
            this.direction = "d";
            break;
        case "d":
            this.direction = "l";
            break;
        case "l":
            this.direction = "u";
            break;
        case "u":
            this.direction = "r";
            break;
    }
}

Bike.prototype.turnLeft = function() {
    this.currentHtml = null;
    this.currentHtmlWidth = this.defaultWidth;
    this.currentHtmlHeight = this.defaultHeight;
    this.turnPoints.push([this.x, this.y]);
    switch (this.direction) {
        case "r":
            this.direction = "u";
            break;
        case "d":
            this.direction = "r";
            break;
        case "l":
            this.direction = "d";
            break;
        case "u":
            this.direction = "l";
            break;
    }
}

Класс для мотоцикла у нас готов. Теперь его можно добавить на игровое поле. Для управления мотоциклом добавим обработчики события нажатия клавиш. Для удобства продублируем стрелки с кнопками A и D. Движение мотоцикла будем осуществлять по интервалу.
var keyRight = 39;
var keyD = 68;
var keyLeft = 37;
var keyA = 65;

var myBike;
var moveInterval = 50;

function startGame() {
    var body = document.getElementsByTagName('body')[0];
    var gameContainer = document.createElement("div");
    gameContainer.className = "game";
    body.appendChild(gameContainer);
    bindEvents(body);

    var bike1 = new Bike(1, [100, 100]);
    bike1.allocate(gameContainer);
    bikes.push(bike1);

    var bike2 = new Bike(2, [700, 300]);
    bike2.allocate(gameContainer);
    bike2.direction = "l";
    bikes.push(bike2);

    var bike3 = new Bike(3, [50, 500]);
    bike3.allocate(gameContainer);
    bike3.direction = "u";
    bikes.push(bike3);

    myBike = bike1;
    window.setInterval(mainLoop, mainLoopInterval);
}

function mainLoop() {
    for (var b in bikes) {
        var bike = bikes[b];
        bike.move(10);
    }
}

function bindEvents(container) {
    container.onkeypress = function(e) {
        e = e || window.event;
        switch (e.keyCode) {
            case keyRight:
            case keyD:
                myBike.turnRight();
                break;
            case keyLeft:
            case keyA:
                myBike.turnLeft();
                break;

        }
    }
}
где bikes - это массив, который мы предварительно создали пустым.

Мы решили украшать всё с помощью css, поэтому нам нужно подключить такие стили:
.game {
  height: 600px;
  width: 800px;
  background: #eee;
  border: 1px solid #000;
}
.game .bike {
  position: relative;
}
.game .bike .bike-trail {
  position: absolute;
}
.game .bike-1 .bike-trail {
  background-color: #f00;
}
.game .bike-2 .bike-trail {
  background-color: #0f0;
}
.game .bike-3 .bike-trail {
  background-color: #00f;
}
.game .bike .bike-head {
  position: absolute;
  z-index: 10;
  width: 5px;
  height: 5px;
  border: 1px solid #000;
}

Эти стили дадут нам такую картинку:


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

Определение столкновений

Все объекты, с которыми нам нужно находить столкновения, в этой игре являются прямыми линиями - это границы игрового поля и шлейфы мотоциклов. Любую прямую на плоскости мы можем построить по двум точкам. Но нам нужна не сама прямая, а именно отрезки с началом и концом. Для начала соберем координаты начала и конца для каждого отрезка в один массив.
    /* line = [x1, y1, x2, y2] */
    var lines = [];
    lines.push([0, 0, gameWidth, 0]);
    lines.push([gameWidth, 0, gameWidth, gameHeight]);
    lines.push([gameWidth, gameHeight, 0, gameHeight]);
    lines.push([0, gameHeight, 0, 0]);

    /* add lines from bikes' trails */
    for (var b in bikes) {
        var bike = bikes[b];
        if (bike.turnPoints.length > 1) {
            for (var i = 1; i < bike.turnPoints.length; i++) {
                lines.push([
                    bike.turnPoints[i - 1][0],
                    bike.turnPoints[i - 1][1],
                    bike.turnPoints[i][0],
                    bike.turnPoints[i][1]
                ]);
            }
        }
        var lastPoint = bike.turnPoints[bike.turnPoints.length - 1];
        lines.push([
            lastPoint[0],
            lastPoint[1],
            bike.x,
            bike.y,
            bike.number
        ]);

    }

Теперь задача состоит в том, чтобы определить, пересёк ли мотоцикл какой-либо отрезок прямой. Мы упростим себе задачу и сделаем шаг движения мотоциклов таким, чтобы свести задачу к определению лежит ли мотоцикл на отрезке в данный момент. Этого нетрудно добиться, если у нас ширина поля 800, высота 600. В таком случае шаг движения stepSize для мотоциклов можно брать любой из общих делителей 800 и 600. Возьмем 10.
Функция определения, лежит ли точка на отрезке, содержит в себе только сравнения.
 1. Если y-координаты мотоцикла и начала отрезка равны, то это горизонтальный отрезок, и нам нужно сравнить x-координаты. Координата x мотоцикла должна быть больше наименьшей x-координаты (начала) отрезка и меньше наибольшей (конца) y-координаты отрезка.
рисунок о горизонтальном отрезке

 2. Если x-координаты мотоцикла и начала отрезка равны, то это вертикальный отрезок, и нам нужно сравнить y-координаты аналогично первому случаю.

Стоит отметить, что y-координата у нас идет вниз. Хоть мы и подобрали числа так, чтобы координаты получались только целыми числами, мы все же будем сравнивать их с некоторой поправкой на точность. Для этого введем переменную eps и положим её равной какому-либо малому числу, например, 0.01. Вместо if (y1 == y), будем писать if (y1 - y < eps). Это позволит нам сравнивать на равенство числа, которые отличаются на величину порядка 0,001. Хотя, конечно, сильно усложняет читаемость кода.

В итоге нам нужно пробежать по всем мотоциклам и определить, находится ли он в каком либо отрезке из массива lines:
var eps = 0.01;
for (var b in bikes) {
     var bike = bikes[b];
     var x = bike.x, y = bike.y;
     for (var li in lines) {
            var line = lines[li];
            var x1 = line[0], y1 = line[1], x2 = line[2], y2 = line[3];
            
            if (typeof line[4] === "number" && line[4] === bike.number) {
             /* last line of current bike */
                continue;
            }

            if ((Math.abs(y1 - y) < eps && ((x > x1 - eps && x < x2 + eps) || (x > x2 - eps && x < x1 + eps))) ||
                    (Math.abs(x1 - x) < eps && ((y > y1 - eps && y < y2 + eps) || (y > y2 - eps && y < y1 + eps)))) {
                bike.collide();
            }
        }
    }

Как видно по коду, при сборке массива lines мы храним в последнем отрезке мотоцикла номер самого мотоцикла, а в цикле по мотоциклам мы проверяем равенство номеров. Это нужно, потому что последний отрезок не должен участвовать в проверке для того же самого мотоцикла. Если мы выясняем, что мотоцикл столкнулся с чем-то, то мы вызываем метод bike.collide(), который нужно добавить:
Bike.prototype.collide = function() {
    console.log("collide");
    this.direction = null;
}

Так как мы обнуляем направление мотоцикла, то он не сможет больше двигаться.
Вызов функции проверки столкновений нужно поместить в основной цикл после вызова движения:
function mainLoop() {
    for (var b in bikes) {
        var bike = bikes[b];
        bike.move(10);
    }
    detectCollisions();
}

Заключение

Мы создали класс для мотоцикла, добавили игровой контейнер и поместили в него несколько экземпляров мотоциклов. Мы научили игру определять столкновения и останавливать движения столкнувшихся мотоциклов. Таким образом мы проверили, что использование
html-тегов для отображения игры в нашем случае не принесло проблем и не создало трудностей, а значит можно перейти к клиент-серверному взаимодействую для многопользовательской игры на node.js.

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

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