Een game met HTML5, Dojo, Node.js en MongoDB (deel 2: Node.js)

In dit tweede deel van de tutorial ga ik uitleggen hoe de service kant eruit zal zien in Node.js. Wat we gaan doen bestaat eigenlijk uit drie zaken, we gaan een MongoDB connectie hebben, een WebSocket service die content kan pushen naar de clients en een gewone webserver voor de resources (HTML pagina met JavaScript).

Heads up!

Indien je de vorige tutorial gemist hebt kan je deze hier even nalezen.

Module imports

Het eerste wat je gewoonlijk gaat doen indien je een Node.js script schrijft is de nodige modules importeren. Open het Node.js script dat je vorige keer aangemaakt hebt en plaats de volgende code:

var WebSocketServer = require('websocket').server;
var http = require('http');
var url = require("url");
var fs = require("fs");
var getMime = require("simple-mime")("text/html");
var mongoose = require('mongoose');

Zoals je kan zien hebben we een hoop modules, de meeste zijn echter al gekend vanuit mijn vorige tutorials. Zo hebben we fs waardoor we een API hebben voor het file system, url voor het parsen van URLs, http voor webservers en simple-mime om het mime-type van de resources te bepalen.
Er zijn ook twee nieuwe modules, namelijk websocket en mongoose, waarvan ik de uitleg heb gegeven in het eerste deel.

Daarnaast gaan we ook nog een variabele aanmaken waarin we de connection pool van de clients gaan bijhouden, dit doen we door middel van:

var clients = new Array();

Deze connection pool is nodig omdat we dan deze array kunnen aflopen om data naar alle clients te pushen.

MongoDB connectie

De volgende stap is dat we een connectie nodig hebben naar MongoDB. In deze database gaan we de status (locatie + naar welke kant ze opkijken) opslaan van iedere speler. De code hiervoor is:

mongoose.connect('mongodb://localhost/test');
var db = mongoose.connection;

db.on('error', console.error.bind(console, 'connection error:'));
db.once('open', function() {
    console.log('Connection to MongoDB open');
    Player.remove(function(err) {
        if (err) {
            console.log("Clearing player database failed");
        } else {
            console.log("Cleared all player data");
        }
    });
});

var playerSchema = mongoose.Schema({
    id: Number,
    location: {
        x: Number,
        y: Number
    },
    direction: Number
});

var Player = mongoose.model('Player', playerSchema);

Op de eerste lijn openen we een connectie naar de database test op localhost. We geven hierbij geen poort op, wat wilt zeggen dat we de default poort 28017 gaan gebruiken. Om nu events en andere zaken op basis van de database connectie te verrichten, hebben we een handler nodig die we kunnen verkrijgen via

mongoose.connection

.

Indien dat de connectie met de database gemaakt is, ga ik alle spelers van de vorige keer verwijderen zodat er geen achterblijvende data is. Controleren of de connectie met de database gemaakt is doen we met de event handler:

db.once('open', function() { ... }

Hierin gaan we alle spelers verwijderen door middel van:

Player.remove(function(err) { ... }

.
Hiermee kunnen we objecten verwijderen en aangezien we geen extra condities meegeven, geldt het dus voor alle spelers binnen de database. Waar we juist

Player

vandaan halen wordt zometeen duidelijk.

Om objecten in MongoDB op te kunenn slaan hebben we ook een soort van schema nodig waar de data zich aan moet houden. Het schema geven we op dmv volgende code:

var playerSchema = mongoose.Schema({
    id: Number,
    location: {
        x: Number,
        y: Number
    },
    direction: Number
});

Zoals je kan zien hebben we een id waarmee we elke speler kunnen identificeren, een locatie bestaande uit een X- en een Y coördinaat en de kant waar de speler naar kijkt (direction).
Het enige wat we nu nog hoeven te doen is om een soort van model-object te maken op basis van het schema, dit doen we door middel van:

var Player = mongoose.model('Player', playerSchema);

Via dit object kan je operaties gaan uitvoeren op alle spelers binnen de database, zo hebben we daarnet al gezien dat je spelers kan verwijderen met de functie

remove()

.

Web server

Omdat het spel via de browser kan gespeeld worden, hebben we uiteraard ook een webserver nodig waarmee we de resources (HTML bestanden enz) kunnen aanbieden. Deze web server is dezelfde als we in de tutorial gezien hebben om een eenvoudige webserver te maken.
Indien je daar uitleg over nodig hebt raad ik je aan om die tutorial te doornemen omdat dat daar allemaal uitgelegd staat. De code hiervoor is:

http.createServer(function(request, response) {
    var path = url.parse(request.url, true).pathname;
    path = "htdocs" + (path.charAt(path.length - 1) == "/" ? "/index.html" : path);

    request.on("end", function() {
        fs.exists(path, function(isExisting) {
            var data = "";
            if (isExisting) {
                fs.readFile(path, function(err, data) {
                    if (err) {
                        response.writeHead(500);
                        response.end();
                        console.log("Request " + path + " failed");
                    } else {
                        response.writeHead(200, {
                            "Content-Type": getMime(path)
                        });
                        response.end(data, 'utf-8');
                    }
                });
            } else {
                response.writeHead(404);
                response.end();
            }
        });
    });
}).listen(8080, function() {
    console.log("Server started");
});

WebSocket

Daarmee zijn we dan ook aan het moeilijkste deel van de tutorial aanbeland, namelijk de websocket server. Om de websocket server aan te maken heb je een webserver nodig die gebruikt zal worden om data over te sturen. De code hiervoor is:

var wsInternalServer = http.createServer(function(request, response) { });

wsInternalServer.listen(8081, function() { 
    console.log("HTTP Websocket server created");
});

wsServer = new WebSocketServer({
    httpServer: wsInternalServer
});

Zoals je kan zien gebruiken we hier een andere webserver dan de webserver voor de resources die we in vorig hoofdstuk gedefinieerd hebben, maar gebruiken we een aparte webserver die ik

wsInternalServer

noem.

Daarna kan je op basis van deze webserver een WebSocketServer object aanmaken.

We gaan nog één ding doen en dat is een functie maken die ervoor zorgt dat we de data van één speler kunnen pushen naar alle clients. Hiervoor gebruiken we de volgende code:

function changePlayerStatus(/** Player **/ player) {
    var data = JSON.stringify(player);
    for(var i in clients) {
        clients[i].sendUTF(data);
    }
}

Wat we hier doen is dat we alle clients aflopen zoals ik eerder al zei en data gaan versturen via de

sendUTF()

functie. Maar omdat we enkel een String kunnen meesturen, meoten we het spelers-object in

player

nog eerst omzetten naar een String, dit doen we door middel van de functie

JSON.stringify()

.

Nieuwe request

Het echte werk zit echter in het maken van de requests, hiervoor hebben we een event handler met de volgende code:

wsServer.on('request', function(request) { ... });

Het eerste wat we gaan doen als er een nieuwe request komt, is de speler aanmaken en opslaan, hiervoor gebruiken we de volgende code:

var connection = request.accept(null, request.origin);
var index = new Date().getTime();
clients[index] = connection;

var p = new Player({
    id: index,
    location: {
        x: 0,
        y: 0
    },
    direction: 2
});
p.save(function(err, obj) {
    if (err) {
        console.log("Could not add user " + index);
    } else {
        console.log("New user " + index + " detected");
    }
});

Het eerste wat we doen is de connectie accepteren en opslaan in de connection pool variabel die we eerder aangemaakt hadden. Hiervoor gebruiken we een unieke index die bestaat uit de timestamp die je kan ophalen met

new Date().getTime()

.

De volgende stap is dat we een nieuw Player object aanmaken op locatie 0, 0 en als richting het cijfer 2 bevat. Ik ga in deze tutorial 4 cijfers gebruiken voor de richting waarbij 0 naar boven (noorden) is, 1 naar rechts (oosten) is, 2 naar onder (zuiden) is en 3 naar link (westen) is. Deze speler gaat dus naar onder gericht zijn.

Ten slotte slaan we deze speler ook op met de

save()

functie.

De volgende stap is dat we deze nieuwe speler de locatie gaan geven van alle andere spelers zodat deze getoond kunnen worden in de client. Dit doen we met de volgende code:

Player.find(function(err, players) {
    if (err) {
        console.log("Problem retrieving all players");
    } else {
        for (var i = 0;i < players.length;i++) {
            connection.sendUTF(JSON.stringify({
                id: players[i].id,
                location: players[i].location,
                direction: players[i].direction
            }));
        }
    }
});

Zoals je kan zien roepen we de functie

find()

op op het model

Player

. Omdat we geen extra condities meegeven gaan we daarmee zoeken naar alle spelers in de database.

Uiteindelijk gaan we dan door middel van een for-lus deze array

players

aflopen en de gegevens naar de client sturen door middel van de

connection.sendUTF()

functie. Net zoals in de functie

changePlayerStatus()

moeten we natuurlijk eerste van de data een String maken, wat we doen met

JSON.stringify()

.

De laatste stap die moet gebeuren indien een nieuwe speler toegevoegd wordt is dat ook alle andere clients verwittigd moeten worden van deze nieuwe speler. Hiervoor gebruiken we de eerder aangemaakte functie

changePlayerStatus()

en schrijven we de volgnede code:

changePlayerStatus({
    id: index,
    location: {
        x: 0,
        y: 0
    },
    direction: 2
});

Ontvangen van berichten

Uiteraard hebben we niets aan een spel indien de client geen interactie kan verrichten met de server (zoals rondlopen). Om dit te realiseren gebruiken we de volgende event handler:

connection.on('message', function(message) { ... });

Wat er eigenlijk gaat gebeuren is dat de client zijn nieuwe locatie + richting meegeeft. Deze gaan we dan opslaan in de database en uiteraard ook doorsturen naar alle clients zodat zij ook deze speler zien bewegen. Dit doen we met de volgende code:

if (message.type == 'utf8') {
    var status = JSON.parse(message.utf8Data);

    Player.update(
        { id: index }, 
        {
            location: status.location,
            direction: status.direction
        },
        { multi: false },
        function(err) {
            if (err) {
                console.log("Could not update player " + index);
            } else {
                changePlayerStatus({
                    id: index,
                    location: status.location,
                    direction: status.direction
                });
            }
        }
    );
}

De eerste stap die we maken is het omzetten van de data (die in een String aankomt) naar een object door middel van

JSON.parse()

Daarna gaan we de speler updaten in de database door middel van de

Player.update()

functie. De eerste parameter die we meegeven is de conditie, namelijk

{ id: index }

. Hiermee geven we aan dat we het object willen updaten met die bepaalde id.
In de volgende parameter geven we dan aan wat we willen updaten, namelijk de locatie en de richting. In de derde parameter kan je extra opties meegeven, wat ik ga doen is ervoor zorgen dat maar één object geüpdate moet worden (er is ook maar één object met die bepaalde ID). Dit doe ik met

{ multi: false }

De laatste stap is de callback waarmee ik alle andere spelers ga verwittigen dat iemand zich bewoog door middel van de

changePlayerStatus()

functie.

Speler verdwijnt

We hebben nu code voorzien om een nieuwe speler toe te voegen en als een speler beweegt, het enige wat we nu nog moeten doen is ervoor zorgen dat als een speler de connectie verbreekt, dat de speler verdwijnt van de clients.
Controleren of de speler z’n connectie verbroken heeft doen we met het volgende event:

connection.on('close', function(conn) { ... });

Wat we dan moeten doen is de connectie verwijderen van de connection pool, de speler verwijderen uit de database en alle clients daarover inlichten. De code hiervoor is:

clients.splice(index, 1);

    Player.remove({ id: index }, function(err) {
        if (err) {
            console.log("Could not remove player " + index);
        } else {
            changePlayerStatus({
                id: index,
                location: { x: 0, y: 0 },
                direction: -1
            });
            console.log("Player " + index + " removed");
        }
    });
});

Om de connectie te verwijderen gebruik ik de

splice()

functie waarmee we een bepaald element uit de array kunnen verwijderen.

De tweede stap is dat we de speler uit de database verwijderen met de

remove()

functie die we aanspreken op het model.
De eerste stap hierbij is dat we een conditie meegeven, namelijk

{ id: index }

. Hiermee zeggen we eigenlijk dat we de speler willen verwijderen met die bepaalde ID.

Indien dat gelukt is moeten we uiteraard alle andere clients nog verwittigen met de

changePlayerStatus()

. Om zo uniform mogelijk alle data te sturen naar de client heb ik hiervoor gewoon gekozen om een negatieve richting mee te geven.

Testen

Hiermee hebben we dan ook de volledige code voorzien om de server te laten werken. Zonder een client kunnen we uiteraard weinig testen, maar we kunnen wel al eens de code uitvoeren om te kijken of alles werkt (database connectie, …).

node-script

Als slot wou ik nog even de volledige code meegeven zodat je niet in de knoei zit om uit te zoeken waar je nu juist een bepaald stuk code moet plaatsen.

var WebSocketServer = require('websocket').server;
var http = require('http');
var url = require("url");
var fs = require("fs");
var getMime = require("simple-mime")("text/html");
var mongoose = require('mongoose');

var clients = new Array();

mongoose.connect('mongodb://localhost/test');
var db = mongoose.connection;

db.on('error', console.error.bind(console, 'connection error:'));
db.once('open', function callback () {
    console.log('Connection to MongoDB open');
    Player.remove(function(err) {
        if (err) {
            console.log("Clearing player database failed");
        } else {
            console.log("Cleared all player data");
        }
    });
});

var playerSchema = mongoose.Schema({
    id: Number,
    location: {
        x: Number,
        y: Number
    },
    direction: Number
});

var Player = mongoose.model('Player', playerSchema);

/**
 * HTTP Resources server
 */
http.createServer(function(request, response) {
    var path = url.parse(request.url, true).pathname;
    path = "htdocs" + (path.charAt(path.length - 1) == "/" ? "/index.html" : path);

    request.on("end", function() {
        fs.exists(path, function(isExisting) {
            var data = "";
            if (isExisting) {
                fs.readFile(path, function(err, data) {
                    if (err) {
                        response.writeHead(500);
                        response.end();
                        console.log("Request " + path + " failed");
                    } else {
                        response.writeHead(200, {
                            "Content-Type": getMime(path)
                        });
                        response.end(data, 'utf-8');
                    }
                });
            } else {
                response.writeHead(404);
                response.end();
            }
        });
    });
}).listen(8080, function() {
    console.log("Server started");
});

/**
 * HTTP Websocket server
 */

function changePlayerStatus(/** Player **/ player) {
    var data = JSON.stringify(player);
    for(var i in clients) {
        clients[i].sendUTF(data);
    }
}

var wsInternalServer = http.createServer(function(request, response) { });

wsInternalServer.listen(8081, function() { 
    console.log("HTTP Websocket server created");
});

wsServer = new WebSocketServer({
    httpServer: wsInternalServer
});

wsServer.on('request', function(request) {
    var connection = request.accept(null, request.origin);
    var index = new Date().getTime();
    clients[index] = connection;

    var p = new Player({
        id: index,
        location: {
            x: 0,
            y: 0
        },
        direction: 0
    });
    p.save(function(err, obj) {
        if (err) {
            console.log("Could not add user " + index);
        } else {
            console.log("New user " + index + " detected");
        }
    });

    Player.find(function(err, players) {
        if (err) {
            console.log("Problem retrieving all players");
        } else {
            for (var i = 0;i < players.length;i++) {
                connection.sendUTF(JSON.stringify({
                    id: players[i].id,
                    location: players[i].location,
                    direction: players[i].direction
                }));
            }
        }
    });

    changePlayerStatus({
        id: index,
        location: {
            x: 0,
            y: 0
        },
        direction: 2
    });

    connection.on('message', function(message) {
        if (message.type == 'utf8') {
            var status = JSON.parse(message.utf8Data);

            Player.update(
                { id: index }, 
                {
                    location: status.location,
                    direction: status.direction
                },
                { multi: false },
                function(err) {
                    if (err) {
                        console.log("Could not update player " + index);
                    } else {
                        changePlayerStatus({
                            id: index,
                            location: status.location,
                            direction: status.direction
                        });
                    }
                }
            );
        }
    });

    connection.on('close', function(conn) {
        clients.splice(index, 1);

        Player.remove({ id: index }, function(err) {
            if (err) {
                console.log("Could not remove player " + index);
            } else {
                changePlayerStatus({
                    id: index,
                    location: { x: 0, y: 0 },
                    direction: -1
                });
                console.log("Player " + index + " removed");
            }
        });
    });
});

To be continued…

Het volgende deel kan je hier nalezen.

Tagged , , , , .

g00glen00b

Consultant at Cronos and Tech lead at Aquafin. Usually you can find me trying out new libraries and technologies. Loves both Java and JavaScript.