Доброго времени суток, Хабр! Сегодня я покажу каким образом сделать простенький real-time 2D шутер от третьего лица на node.js и phaser.js.

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

Итак, писать мы будем простой 2d шутер с видом от третьего лица. В дизайн примера никто особо силы не вкладывал, поэтому в конечно итоге получится, что-то в таком духе:

image

Вот список технологий, какие мы будем использовать:

— node.js;
— express;
— socket.IO;
— phaser.js

В начале серверная часть:

var express = require('express');  //подключаем экспресс
var app = require('express')();
var http = require('http').Server(app);
var io = require('socket.io')(http); //подключаем socket.IO
var port = process.env.PORT || 3000; //указываем порт на котором у нас будет работать игра

var players = {}; //переменная со всеми игроками

/* При открытии страницы, открываем новое подключение для обмена сокетами*/
io.on("connection", function(socket) {  
    console.log('an user connected ' + socket.id); //выводим в консоль node.js инфу о том, что подключился новый пользователь

    players[socket.id] = {
        "x": Math.floor(Math.random(1) * 2000),
        "y": Math.floor(Math.random(1) * 2000),
        "live": true,
    }; //генерирует параметры нового игрока


    io.sockets.emit('add_player', JSON.stringify({
        "id": socket.id,
        "player": players[socket.id]
    })); //создаем нового игрока на клиенте

    socket.emit('add_players', JSON.stringify(players));// создаем нового игрока на клиенте(если игроков более одного)

    socket.on('player_rotation', function(data) {
        io.sockets.emit('player_rotation_update', JSON.stringify({
            "id": socket.id,
            "value": data
        }));
    }); // пакет получающий данные о направлении игрока и отправляющий их на клиент для синхронного отображения для всех пользователей


    socket.on('player_move', function(data) {
        data = JSON.parse(data); //получаем данные какая кнопка нажата

        data.x = 0; //создаем новое свойство объекта x и заодно сбрасываем его до нуля, при каждом обновлении сокета
        data.y = 0; // аналогично предыдущему, только ось y

       /*передаем различные параметры в зависимости от нажатой кнопки*/
        switch (data.character) {
            case "W":
                data.y = -5;
                players[data.id].y -= 5;
                break;
            case "S":
                data.y = 5;
                players[data.id].y += 5;
                break;
            case "A":
                data.x = -5;
                players[data.id].x -= 5;
                break;
            case "D":
                data.x = 5;
                players[data.id].x += 5;
                break;
        }
        io.sockets.emit('player_position_update', JSON.stringify(data)); //отправляем данные на клиент
    });

    socket.on('shots_fired', function(id) {  //получаем id стреляющего
        io.sockets.emit("player_fire_add", id); //отправляем на клиент вызов функции выстрелов от конкретного пользователся
    });

    socket.on('player_killed', function (victimId) { //функция при попадания пули в игрока
		
		io.sockets.emit('clean_dead_player', victimId);  //чистим поле от проигравших
		players[victimId].live = false; //заканчиваем выполнение ряда функций для проигравшего игрока
        io.sockets.connected[victimId].emit('gameOver', 'Game Over'); //выводим пользователю в которого попали информацию о проиграше 
		
	});


    socket.on('disconnect', function() { //убираем с поля отсоединившихся игроков
        console.log("an user disconnected " + socket.id); 
        delete players[socket.id];
        io.sockets.emit('player_disconnect', socket.id);
    });
});

app.use("/", express.static(__dirname + "/public")); //пути к файлам клиента
app.get("/", function(req, res) {
    res.sendFile(__dirname + "/public/index.html"); // главная страница
}); 

http.listen(port, function() {
    console.log('listening on *:' + port); // запуск сервера
});

Теперь клиентская часть:

var width = window.innerWidth; //получаем ширину монитора
var height = window.innerHeight; //получаем высоту монитора

var game = new Phaser.Game(width, height, Phaser.CANVAS, 'phaser-example', { preload: preload, create: create, update: update, render: render }); //создаем игровое поле с высотой и шириной экрана пользователя;

/*инициализируем все наши переменные */
var player;
var socket, players = {};
var map;
var map_size = 2000; //размер карты
var style = { font: "80px Arial", fill: "white" }; //стили для надписи "Game Over"
var text;
var bullets;

var fireRate = 100;
var nextFire = 0;
var balls;
var ball;
var player_speed = 400; //скорость движения игрока
var live;
var moveBullets;

/*предзагружаем графические элементы*/
function preload() {
    game.load.image('unit', 'img/unit.png');
    game.load.image('bullet', 'img/bullet.png');
    game.load.image('killer', 'img/killers.png');
    game.load.image('map', 'img/grid.png');
}

function create() {
    socket = io.connect(window.location.host); //подключаем сокеты

    game.physics.startSystem(Phaser.Physics.ARCADE); 

    game.time.advancedTiming = true;
    game.time.desiredFps = 60;
    game.time.slowMotion = 0;

    bg = game.add.tileSprite(0, 0, map_size, map_size, 'map'); //спрайт карты
    game.world.setBounds(0, 0, map_size, map_size); //размеры карты
    game.stage.backgroundColor = "#242424"; //цвет фона

    socket.on("add_players", function(data) {
        data = JSON.parse(data);
        for (let playerId in data) {
            if (players[playerId] == null && data[playerId].live) {
                addPlayer(playerId, data[playerId].x, data[playerId].y, data[playerId].name);
            }
        }
        live = true;
    }); //создаем игроков

    socket.on("add_player", function(data) {
        data = JSON.parse(data);
        if (data.player.live) {
            addPlayer(data.id, data.player.x, data.player.y, data.player.name);
        }
    }); //создаем игрока

    socket.on("player_rotation_update", function(data) {
        data = JSON.parse(data);
        players[data.id].player.rotation = data.value;
    }); //вращение вокруг своей оси, ориентируясь на курсор

    socket.on("player_position_update", function(data) {
        data = JSON.parse(data);
        players[socket.id].player.body.velocity.x = 0;
        players[socket.id].player.body.velocity.y = 0;

        players[data.id].player.x += data.x;
        players[data.id].player.y += data.y;

    }); //обновляем положение игроков

    socket.on('player_fire_add', function(id) {
            players[id].weapon.fire();        
    }); //исполняем выстрелы

    game.input.onDown.add(function() {
        socket.emit("shots_fired", socket.id);
    }); //вызываем выстрелы

    socket.on('clean_dead_player', function(victimId) {
        if (victimId == socket.id) {
            live = false;
        }
        
        socket.on("gameOver", function(data){
            text = game.add.text(width / 2, height / 2, data, { font: "32px Arial", fill: "#ffffff", align: "center" });
            text.fixedToCamera = true;
            text.anchor.setTo(.5, .5);
        });
        players[victimId].player.kill();

    }); //чистим поле от игроков в которых попали и выводим им текст о проигрыш 

    socket.on('player_disconnect', function(id) {
        players[id].player.kill();
    }); //чистим поле от отключившихся игроков

    keybord = game.input.keyboard.createCursorKeys(); //инициализируем клавиатуру
}
 

function update() {
    if (live == true) {
        players[socket.id].player.rotation = game.physics.arcade.angleToPointer(players[socket.id].player); //вращаем игрока в сторону курсора
        socket.emit("player_rotation", players[socket.id].player.rotation); //отправляем на сервер данные о вращени
        setCollisions(); //функция вызывающаяся при столкновении пули с игроком
        characterController(); //управление игроком
    }
}


function bulletHitHandler(player, bullet) {
    socket.emit("player_killed", player.id);
    bullet.destroy(); // убираем пулю с поля, после попадания
} //функция при столкновении пули с игроком

function setCollisions() {
    for (let x in players) {
        for (let y in players) {
            if (x != y) {
                game.physics.arcade.collide(players[x].weapon.bullets, players[y].player, bulletHitHandler, null, this);
            }
        }
    }
} //Проверка столкновения игрока с пулей

function sendPosition(character) {
    socket.emit("player_move", JSON.stringify({
        "id": socket.id,
        "character": character
    }));
} //отправляем инфу о том, куда игрок двинулся на сервер

function characterController() {

    if (game.input.keyboard.isDown(Phaser.Keyboard.A) || keybord.left.isDown) {
        //players[socket.id].player.x -= 5;
        sendPosition("A");
    }
    if (game.input.keyboard.isDown(Phaser.Keyboard.D) || keybord.right.isDown) {
        //players[socket.id].player.x += 5;
        sendPosition("D");
    }
    if (game.input.keyboard.isDown(Phaser.Keyboard.W) || keybord.up.isDown) {
        //players[socket.id].player.y -= 5;
        sendPosition("W");
    }
    if (game.input.keyboard.isDown(Phaser.Keyboard.S) || keybord.down.isDown) {
        //players[socket.id].player.y += 5;
        sendPosition("S");
    }
} //управление


function render() {
    game.debug.cameraInfo(game.camera, 32, 32);
}

function addPlayer(playerId, x, y) {
    player = game.add.sprite(x, y, "unit");
    game.physics.arcade.enable(player);
    player.smoothed = false;
    player.anchor.setTo(0.5, 0.5);
    player.scale.set(.8);
    player.body.collideWorldBounds = true; //границы страницы
    player.id = playerId;

    let weapon = game.add.weapon(30, 'bullet'); //подключаем возможность выстрелов
    weapon.bulletKillType = Phaser.Weapon.KILL_WORLD_BOUNDS;
    weapon.bulletSpeed = 600; //скорость выстрелов
    weapon.fireRate = 100;
    weapon.trackSprite(player, 0, 0, true);

    players[playerId] = { player, weapon };
    game.camera.follow(players[socket.id].player, ); //слежение за игроком который открыл страницу
} //создаем игрока и даем ему ствол :)

Вот ссылка на гитхаб, чтобы задеплоить(допустим на heroku) и посмотреть как оно работает:

github.com/Dmitry-Rudenko/phaser_mmo_shooter

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

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

Надеюсь, эта информация оказалось полезной.

Всем спасибо!
Поделиться с друзьями
-->

Комментарии (21)


  1. coddy
    02.06.2017 12:30
    +5

    Заливать node_modules в git — так себе идея.


    1. Dmitriy_Rudenko
      02.06.2017 12:31

      Это да, это правильно подмечено. Снесу пожалуй)


      1. token
        02.06.2017 13:02
        +2

        .gitignore


        1. norlin
          02.06.2017 13:27

          СС Dmitriy_Rudenko:


          .gitignore в общем и в частности для node.js (вдруг кому пригодится).



  1. norlin
    02.06.2017 12:56
    +6

    Тема интересная, но на статью это не тянет. Вы просто выложили код с некоторыми комментариями, не рассказав "что", "почему", "зачем" и т.д. Например, не вчитываясь в код, я так и не понял, зачем вам Phaser (и что это такое вообще). Конечно, я пойду и погуглю, но в чём тогда смысл статьи?


    p.s. А, ок, понял про Phaser. Но зачем оно нужно – всё равно не очень понял. Я вот как-то разбирал клон agar.io (и делал на его основе свою игру) – без использования библиотек, чисто на канвасе. И там кода не сильно больше было...


    1. norlin
      02.06.2017 13:00

      И там кода не сильно больше было...

      p.p.s. хотя нет, всё-таки больше :(


      1. Dmitriy_Rudenko
        02.06.2017 13:11
        -4

        Наверное, стоит написать, что статья для тех, кто хоть как-то знаком с Phaser.js

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


        1. norlin
          02.06.2017 13:18

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


          А те, кто знаком, почти наверняка не увидят тут ничего нового, насколько я понимаю (на мой взгляд, код крайне простой и просто использование нескольких методов библиотеки мало что даст в плане обучения)...


          1. Dmitriy_Rudenko
            02.06.2017 13:35
            -5

            Я думаю, что на вкус и цвет товарища нет. Кто-то любит читать длинные туториалы с подробным расписание каждого действия, кому-то наоборот удобнее сразу почитать код с комментариями и попробовать проверить, работает ли.

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

            Думаю, на этом все.


            1. TheShock
              04.06.2017 19:17

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

              Вот потому в статье на хабре вы пишете длинный тутор, а в первом абзаце даете ссылку на гит-репозиторий с кодом.


  1. norlin
    02.06.2017 13:23
    +1

    Упс, только обратил внимание, что у вас проверка коллизий в клиентской части идёт. Для ММО это вообще ни в какие ворота не лезет, простите :)


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


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


    1. Dmitriy_Rudenko
      02.06.2017 13:47

      Благодарю!
      Надо будет поправить :)


    1. Anarions
      02.06.2017 16:44
      -1

      Если клиент не будет «принимать решений» даже при небольшом пинге будет неиграбельно (особенно в случае коллизий). Такие вещи обязательно надо рассчитывать на клиенте. Другое дело что сервер должен их рассчитывать тоже, и всё это дело должно валидироваться и синхронизироваться. Но чтобы добиться хоть какой-то играбельности — в первую очередь считать надо на клиенте.


      1. norlin
        02.06.2017 16:45

        Как я и написал:


        (с поправкой на возможную интерполяцию, предсказания и прочие компенсации лагов)

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


        Более того, в таких играх как эта и прочие agar.io-подобные, на клиенте вообще можно ничего не считать. И всё будет нормально играбельно, при адекватных пингах.
        Компенсация лагов нужна в более серьёзных играх типа 3d-шутеров и т.д.


        1. Anarions
          02.06.2017 16:51

          А вы пробовали? Просто я — да, и если всё происходит на сервере — выглядит это, мягко говоря, коряво. Многое, конечно, зависит от того что считать адекватным пингом. Я так географически расположен что до почти любых серверов «внешнего мира» пинг в 100мс — поэтому с такими настройками и тестировал.


          1. norlin
            02.06.2017 16:56

            Пробовал, при 100мс agarоподобные игры вполне себе играбельны.


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


            А в первом комменте этого треда я писал непосредственно про принятие геймплейных решений на стороне клиента.


            1. Anarions
              02.06.2017 17:02
              -1

              Ну, если это чисто для себя — то приемлемый костыль если хочется чтобы «что-то работало».


  1. kot5150
    04.06.2017 12:01

    Не знаю почему все ополчились на автора.
    Мне статья очень помогла. Я портировал код сервера на Go и разобрался как это работает.
    Спасибо большое!


  1. deemaagog
    04.06.2017 13:36

    Я правильно понимаю, при ипользовании cluster не взлетит? Вместо хранения в памяти нужно использовать что типа redis?


  1. ratatyq
    04.06.2017 13:36

    Получается данный пример не рассчитан для многих клиентов, ведь тут отсутствует интерполяция пакетов например движения.