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

In het vorige deel heb ik de volledige service-code voorzien in Node.js bestaande uit een webserver (voor de client resources) en een websocket server (voor de verbinding van de game zelf) waarbij een MongoDB connectie aan te pas kwam.
In dit deel ga ik de volledige client code voorzien bestaande uit een HTML pagina en een Dojo script. Wat we hier eigenlijk gaan doen is gebruik maken van de dojox/gfx library waarmee we bepaalde zaken op het scherm kunnen tekenen.
Het handige aan deze library is dat het éénzelfde interface voorziet onafhankelijk van de technologieën die mogelijk zijn om deze te renderen in je browser (SVG, Silverlight, …).

Heads up!

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

HTML pagina

We gaan als eerste stap de HTML pagina voorzien van de nodige elementen. Deze HTML pagina is terug te vinden in de htdocs map van je Node.js project. De code hiervoor is vrij eenvoudig, namelijk:

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
        <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
        <title>game-server</title>

        <script type="text/javascript">
            dojoConfig = {
                parseOnLoad : true,
                async: true,
                gfxRenderer: "svg,canvas,silverlight,vml"
            }
    </script>
    <script src="http://ajax.googleapis.com/ajax/libs/dojo/1.8.3/dojo/dojo.js"></script>
    <script type="text/javascript" src="assets/js/additions.js"></script>
</head>
<body>
    <div id="surfaceElement"></div>
</body>
</html>

Deze HTML pagina is vrij eenvoudig, het enige wat we hier doen is de Dojo configuratie voorzien in

dojoConfig

, Dojo inladen, onze eigen JavaScript inladen en dat we een div hebben met de id

surfaceElement

waar alle magie straks gaat plaatsvinden.

Het belangrijkste is dat in

dojoConfig

het attribuut

gfxRenderer

te vinden is met daarin de verschillende rendermethodes die gebruikt kunnen worden in prioritaire volgorde. Dit wilt zeggen dat een browser de HTML5 SVG technologie gaat gebruiken indien deze beshcikbaar is, zoniet wordt de canvas-methode gebruikt en anders silverlight of VML. Deze twee laatste technologieën zullen vooral gebruikt worden in Internet Explorer.

Modules importeren

De volgende stap is dat we het bestand additions.js gaan aanpassen met de nodige code. Het eerste wat we meoten doen is uiteraard de nodige Dojo modules importeren via

require()

. De volledige code daarvoor is:

require(["dojox/gfx", "dojox/gfx/fx", "dojox/socket", "dojo/json", "dojo/on", "dojo/keys", "dojo/domReady!"], function(gfx, fx, Socket, JSON, on, keys) { ... });

De modules die we hier gebruiken zijn dojox/gfx en dojox/gfx/fx voor de grafische zaken (het tekenen van de elementen binnen de browser), dojox/socket om een connectie te maken met de WebSocket server uit deel 2, dojo/json om JSON te kunnen converteren, dojo/on als event handler, dojo/keys om bepaalde constantes te kunnen gebruiken gerelateerd aan keyboard events en dojo/domReady om de HTML nodes te kunnen gebruiken.

Daarnaast ga ik ook al enkele variabelen voorzien die ik doorheen de JavaScript code zal gebruiken, namelijk:

var playerSize = { width: 18, height: 28 };
var gameSize = { width: 33*playerSize.width, height: 21*playerSize.height };
var surface = gfx.createSurface("surfaceElement", gameSize.width, gameSize.height);
var myPlayer = {
    location: {
        x: 0,
        y: 0
    },
    direction: 2
};
var players = new Array();
var playerShapes = new Array();

In

playerSize

en

gameSize

ga ik de grootte van een speler-sprite en de map zelf bewaren. In

myPlayer

en

players

ga ik de status van de spelers bewaren, in

myPlayer

gaat de status van de eigen speler bewaard worden terwijl in

players

de status van alle spelers bewaard wordt.

Dan hebben we nog twee grafische elementen, namelijk

surface

, namelijk het veld dat gebruikt wordt om alle grafische elementen op te tekenen en

playerShapes

waarin alle sprites van alle spelers gaan bewaard worden (om deze te kunnen bewegen).
Merk op dat we in surface de functie

gfx.createSurface()

aanroepen om een veld te creëren dat even groot is als gedefinieerd wordt in

gameSize

en dat dit in die ene

<div>

geplaatst wordt die we voorzien hebben op de HTML pagina.

Daarnaast gaan we ook het veld voorzien van een gras-texture, namelijk op de volgende manier:

for (var x = 0;x < gameSize.width;x+=128) {
    for (var y = 0;y < gameSize.height;y+=128) {
        surface.createImage({
            x: x,
            y: y,
            width: 128,
            height: 128,
            src: "assets/img/grass128.png"
        });
    }
}

De gras-texture is 128×128 terwijl het speelveld veel groter is. Om deze reden gaan we de grastile meerdere keren binnen het speelveld plaatsen door telkens een nieuwe afbeelding 128px verder te plaatsen. Dit doen we door middel van de twee for-lussen die telkens een teller met 128 laten verspringen (voor de X- en Y-as).
Om een afbeelding te tekenen op het speelveld gebruiken we de

createImage()

functie die een X- en een Y-waarde verwacht, de breedte en de hoogte en uiteraard ook nog de source van de afbeelding verwacht.

Event handler

De volgende stap is dat we een event-handler gaan voorzien voor de pijltjestoetsen die ervoor gaan zorgen dat de speler verplaatst wordt. Hiervoor gebruiken we de volgende code:

on(document.body, "keyup", function(e) {
    switch (e.keyCode) {
        case keys.UP_ARROW:
            e.preventDefault();
            if (myPlayer.location.y > 0) {
                myPlayer.location.y--;
                myPlayer.direction = 0;
                socket.send(JSON.stringify(myPlayer));
            }
            break;
        case keys.DOWN_ARROW:
            if (myPlayer.location.y * playerSize.height < gameSize.height - playerSize.height) {
                myPlayer.location.y++;
                myPlayer.direction = 2;
                socket.send(JSON.stringify(myPlayer));
            }
            e.preventDefault();
            break;
        case keys.LEFT_ARROW:
            if (myPlayer.location.x > 0) {
                myPlayer.location.x--;
                myPlayer.direction = 3;
                socket.send(JSON.stringify(myPlayer));
            }
            e.preventDefault();
            break;
        case keys.RIGHT_ARROW:
            if (myPlayer.location.x * playerSize.width < gameSize.width - playerSize.width) {
                myPlayer.location.x++;
                myPlayer.direction = 1;
                socket.send(JSON.stringify(myPlayer));
            }
            e.preventDefault();
            break;
        default:
            break;
    }
});

We maken gebruik van het

keyup

event waarvoor we een event handler schrijven door middel van de dojo/on module. Deze geeft een event-object terug waarmee we de ingedrukte knop kunnen bepalen met het

keyCode

attribuut.

Voor de pijltjestoetsen kunnen we heel eenvoudig de dojo/keys module gebruiken waarmee we een constante kunnen gebruiken om de code voor de pijltjestoetsen te bepalen, namelijk

keys.UP_ARROW

,

keys.DOWN_ARROW

,

keys.LEFT_ARROW

en

keys.RIGHT_ARROW

.

In deze cases gaan we steeds eerst bepalen of de speler nog binnen het speelveld staat als deze zou bewegen. Voor boven en links is dit vrij eenvoudig te bepalen, zolang de X- en Y-waarde van de speler nog steeds groter is dan 0, kunnen we nog verder naar boven of naar links gaan.
Om naar beneden of naar onder te gaan moeten we echter controleren of de X- en Y-positie binnen de breedte of hoogte van het speelveld vallen (min de breedte of de hoogte van de speler zelf). Indien dat zo is, kan de speler nog verder bewegen.

Als de speler nog kan bewegen, gaan we de juiste waarde binnen

myPlayer.location

verhogen of verlagen en gaan we de richting van de speler aanpassen door middel van

myPlayer.direction

.
In de vorige tutorial heb ik besproken dat we 0 gaan gebruiken voor iemand die naar boven kijkt, 1 voor iemand die naar rechts kijkt, 2 voor iemand die naar onder kijkt en 3 voor iemand die naar links kijkt.
Een negatieve waarde wilt zeggen dat de speler de verbinding verbroken heeft en dus niet meer getekend moet worden.

Daarna kunnen we de data verzenden door middel van de

socket.send()

functie. Net zoals in het Node.js script moeten we uiteraard wel er eerst voor zorgen dat het

myPlayer

object omgezet wordt tot een String door middel van

JSON.stringify()

.

Een speler verwijderen

Het eerste wat we moeten doen is een WebSocket verbinding opzetten, dit doen we door middel van:

var socket = new Socket("ws://localhost:8081");

Daarna kunnen we het message-event gaan gebruiken indien we berichten ontvangen, namelijk met de volgende code:

socket.on("message", function(event) { ... });

Het eerste wat we gaan doen is de code voorzien voor als een speler de connectie verbreekt. Hiervoor moeten we eerst het bericht binnenhalen en parsen naar een object, aangezien we dit als een JSON String binnen krijgen.

var player = JSON.parse(event.data);

Daarna kunnen we, indien de richting van de speler negatief is de speler verwijderen uit de

players

en

playerShapes

array en de speler verwijderen van het scherm. Dit doen we door middel van de volgende code:

if (player.direction < 0) {
    playerShapes[player.id].removeShape(false);
    playerShapes[player.id].destroy();

    players.splice(player.id, 1);
    playerShapes.splice(player.id, 1);
}

De functie

removeShape()

en

destroy()

zorgen ervoor dat de speler niet langer meer getekend zal worden. De parameter

false

duidt aan dat we dit meteen mogen doen. In games kan je er meestal voor kiezen om iets offscreen te renderen om zo de performantie te verbeteren. Omdat we hier echter maar 1 element hebben kunnen we dit direct on-screen gaan doen.

Een speler updaten

Indien de speler niet verwijderd moet worden moeten we in de

else

branch van de vorige

if

werken. Hierin kunnen twee situaties geplaatst worden, of we hebben te maken met een nieuwe speler, of we moeten een speler die al getekend wordt updaten.
Hiervoor schrijven we de volgende code:

if (playerShapes[player.id] != null) {
    ...
} else {
    ...
}

Als we namelijk te maken hebben met een nieuwe speler, zal deze nog niet in

players

of

playerShapes

te vinden zijn.

Om een speler te updaten moeten we de shape updaten (opnieuw tekenen) en eventueel een animatie voorzien zodat de speler niet lijkt te verspringen, maar vloeiend van de ene naar de andere positie beweegt. De code hiervoor is:

playerShapes[player.id].setShape({
    x: players[player.id].location.x * playerSize.width,
    y: players[player.id].location.y * playerSize.height,
    width: playerSize.width,
    height: playerSize.height,
    src: "assets/img/sprite_" + player.direction + ".png"
});
fx.animateTransform({
    shape: playerShapes[player.id],
    duration: 500,
    transform: [
        {
            name: "translate",
            start: [
                0,
                0
            ],
            end: [
                (player.location.x - players[player.id].location.x) * playerSize.width,
                (player.location.y - players[player.id].location.y) * playerSize.height
            ]
        }
    ]
}).play();

Het eerste wat we doen is de

setShape()

functie oproepen. Hiermee gaan we eigenlijk de sprite updaten zodat de afbeelding klopt voor de richting waar de speler naar kijkt. Indien de speler naar rechts kijkt zullen we namelijk een andere afbeelding tonen dan als de speler naar links kijkt.
Dit doen we door de

src

te wijzigen naar de juiste sprite. De sprites hebben een naam gekregen van sprite_0.png tot sprite_3.png waarbij het cijfer overeenkomt met het cijfer dat we afgesproken hebben voor de richting.
De X- en Y- positie van de speler worden bepaald door de X- en Y-coördinaat van de locatie van de speler te vermenigvuldigen met de breedte en de hoogte van de speler (zodat we de locatie in pixels kennen).

De volgende stap is dat we de speler laten bewegen. Dit doen we door middel van de dojo/gfx/fx module die een functie

animateTransform()

voorziet.
Aan deze functie moeten we een object meegeven met het attribuut

shape

waarin we de shape/afbeelding van de speler meegeven, het attribuut

duration

waarmee we de lengte van de animatie bepalen, het attribuut

start

dat een relatieve start-positie geeft en het attribuut

end

dat een relatieve eind-positie bevat.

Omdat we relatief gezien vanaf het beginpunt vertrekken, bevat het

start

attribuut de waarde 0 voor X en Y. De eindpositie is dan weer het aantal “vakjes” dat we bewegen, dit bepalen we door de oude positie van de nieuwe positie af te trekken en daarna te vermenigvuldigen met de breedte of de hoogte.

Om de animatie te starten gebruiken we dan uiteindelijk de

play()

functie.

Een nieuwe speler toevoegen

Indien we te maken hebben met een nieuwe speler (waarvoor dus nog geen shape aangemaakt is), moeten we shape aanmaken door middel van:

playerShapes[player.id] = surface.createImage({
    x: player.location.x * playerSize.width,
    y: player.location.y * playerSize.height,
    width: playerSize.width,
    height: playerSize.height,
    src: "assets/img/sprite_" + player.direction + ".png"
});
console.log("Player " + player.id + " available");

Wat we hier doen is een afbeelding aanmaken met de X- en Y-positie van de speler (vermenigvuldigd met de breedte/hoogte) en met de juiste URL (afhankelijk van de richting waar de speler naar kijkt).

Eindigen doen we door ook nog de

players

array te updaten, maar omdat dit zowel voor een update als een nieuwe speler moet gebeuren, moeten we dit dus onder de

if () { ... } else { ... }

structuur plaatsen door middel van:

players[player.id] = player;

Testen

Hiermee hebben we dan ook de volledige JavaScript/Dojo code geschreven. Hieronder kan je nog eens het volledige overzicht zien van de code die we geschreven hebben, waarbij alle code op de juiste plaats staat (mocht je daarmee problemen hebben).

require(["dojox/gfx", "dojox/gfx/fx", "dojox/socket", "dojo/json", "dojo/on", "dojo/keys", "dojo/domReady!"], function(gfx, fx, Socket, JSON, on, keys) {
    var playerSize = { width: 18, height: 28 };
    var gameSize = { width: 33*playerSize.width, height: 21*playerSize.height };
    var surface = gfx.createSurface("surfaceElement", gameSize.width, gameSize.height);
    var myPlayer = {
        location: {
            x: 0,
            y: 0
        },
        direction: 2
    };
    var players = new Array();
    var playerShapes = new Array();

    for (var x = 0;x < gameSize.width;x+=128) {
        for (var y = 0;y < gameSize.height;y+=128) {
            surface.createImage({
                x: x,
                y: y,
                width: 128,
                height: 128,
                src: "assets/img/grass128.png"
            });
        }
    }

    on(document.body, "keyup", function(e) {
        switch (e.keyCode) {
            case keys.UP_ARROW:
                e.preventDefault();
                if (myPlayer.location.y > 0) {
                    myPlayer.location.y--;
                    myPlayer.direction = 0;
                    socket.send(JSON.stringify(myPlayer));
                }
                break;
            case keys.DOWN_ARROW:
                if (myPlayer.location.y * playerSize.height < gameSize.height - playerSize.height) {
                    myPlayer.location.y++;
                    myPlayer.direction = 2;
                    socket.send(JSON.stringify(myPlayer));
                }
                e.preventDefault();
                break;
            case keys.LEFT_ARROW:
                if (myPlayer.location.x > 0) {
                    myPlayer.location.x--;
                    myPlayer.direction = 3;
                    socket.send(JSON.stringify(myPlayer));
                }
                e.preventDefault();
                break;
            case keys.RIGHT_ARROW:
                if (myPlayer.location.x * playerSize.width < gameSize.width - playerSize.width) {
                    myPlayer.location.x++;
                    myPlayer.direction = 1;
                    socket.send(JSON.stringify(myPlayer));
                }
                e.preventDefault();
                break;
            default:
                break;
        }
    });

    var socket = new Socket("ws://localhost:8081");
    socket.on("message", function(event) {
        var player = JSON.parse(event.data);
        if (player.direction < 0) {
            playerShapes[player.id].removeShape(false);
            playerShapes[player.id].destroy();

            players.splice(player.id, 1);
            playerShapes.splice(player.id, 1);
        } else {
            if (playerShapes[player.id] != null) {
                playerShapes[player.id].setShape({
                    x: players[player.id].location.x * playerSize.width,
                    y: players[player.id].location.y * playerSize.height,
                    width: playerSize.width,
                    height: playerSize.height,
                    src: "assets/img/sprite_" + player.direction + ".png"
                });
                fx.animateTransform({
                    shape: playerShapes[player.id],
                    duration: 500,
                    transform: [
                        {
                            name: "translate",
                            start: [
                                0,
                                0
                            ],
                            end: [
                                (player.location.x - players[player.id].location.x) * playerSize.width,
                                (player.location.y - players[player.id].location.y) * playerSize.height
                            ]
                        }
                    ]
                }).play();
            } else {
                playerShapes[player.id] = surface.createImage({
                    x: player.location.x * playerSize.width,
                    y: player.location.y * playerSize.height,
                    width: playerSize.width,
                    height: playerSize.height,
                    src: "assets/img/sprite_" + player.direction + ".png"
                });
                console.log("Player " + player.id + " available");
            }
            players[player.id] = player;
        }
    });
});

Om het spel zelf uit te testen moet je er dus voor zorgen dat je Node.js script draait en dan kan je in je browser naar http://localhost:8080 gaan om alles uit te testen. Het resultaat zie je in onderstaande afbeelding.

result-1

In de console kan je dan het volgende merken:

console-result-1

Als je dan met de pijltjestoetsen je speler gaat bewegen, zou je deze ook moeten zien bewegen. Dus de grafische elementen werken en de animatie werkt ook.

result-2

Om te kijken of de multiplayer werkt, open je een nieuw tabblad en ga je opnieuw naar http://localhost:8080. Het resultaat is dat je nu dus beide spelers zal zien.

result-multiplayer

Als je dan één van de tabbladen sluit, zal deze speler opnieuw verdwijnen, in de console merk je ondertussen het volgende:

console-result-2

Daarmee zijn we dan ook aan het einde van deze (lange) drie-delige tutorial gekomen. Je hebt nu een eigen game geschreven en daarbij gebruik gemaakt van enkele van de meest recente/populaire technologieën zoals HTML5, Node.js en MongoDB.

Download project

Download –game-server.tar.gz (707 kB)

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.