Forums Développement Multimédia

Les formations Mediabox
Les formations Mediabox

Annexe : Pathfinding - modifier la classe Player

Par gnicos (Nicolas Gauville), le 22 août 2012
Navigation rapide : Sommaire

Rappel : Ceci est une annexe du cours sur les jeux isométriques en HTML5/JS.

Ce cours se base sur les chapitres “pas à pas”, et utilise les fichiers fournis à la fin du huitième chapitre :

Jeu entier (incluant les dossiers “www” et “dev”) : jeu.zip

Moteur isométrique (dossier “dev/src” uniquement) : moteur_iso.zip

Ce cours se base également sur les chapitres précédents sur le Pathfinding !

Animer le joueur

Dans cette dernière annexe sur le Pathfinding, nous allons animer le joueur ! Le but étant de parvenir au résultat suivant :

Voir un aperçu

Pour cela, nous allons ajouter une méthode “smoothMove” à la classe player, qui réalisera un mouvement lisse jusqu'aux coordonnées indiquées (coordonnées absolues, cette fois).

Mise en place

Pour ne pas perdre de temps, nous allons commencer par modifier la classe “Map”. Nous allons remplacer notre utilisation de la fonction “move” :

this.player.move ( toTile.line - this.player.posX, toTile.col - this.player.posY );

Par la fonction “smoothMove” :

this.player.smoothMove ( toTile.line, toTile.col );

La méthode “movePlayer” de la classe “Map” est donc définie ainsi :

/**
 * void movePlayer
 * int $x, int $y : coordonnées de la tuile d'arrivée
 */
p.movePlayer = function ( $x, $y )
{
        if ( this.player === null )
        {
                /**
                 * Pas de joueur, donc pas de pathfinding.
                 */
                return;
        }
 
        var graph = this.getNodeMap();
        var start = graph[player.posX][player.posY];
        var end = graph[$x][$y];
 
        var path = Pathfinder.findPath ( graph, start, end );
 
        while ( path.length !== 0 )
        {
                var toTile = path.shift ();
 
                this.player.smoothMove ( toTile.line, toTile.col );
        }
};

Nous n'avons maintenant plus qu'à modifier la classe “Player” !

La classe Player

Principe de fonctionnement

Le principe de fonctionnement sera assez simple :

  1. - Lors de l'utilisation de la méthode “smoothMove”, le mouvement à effectuer sera ajouté à la liste d'attente.
  2. - Parallèlement, un programme s'executant à chaque image effectuera le mouvement à effectuer le plus vieux parmi la liste d'attente, progressivement. Ce programme effectuera petit à petit chaque mouvement de la liste, jusqu'à ce que celle-ci soit vide.

Nous allons donc devoir ajouter un certain nombre de nouvelles propriétés et nouvelles méthodes à notre classe Player.

Les propriétés supplémentaires

Nous allons commencer par ajouter les nouvelles propriétés dont nous avons besoin !

La liste d'attente

Première propriété : la liste d'attente, qui gardera les mouvements à effectuer comme cela a été expliqué dans le paragraphe précédent. Il s'agit d'un simple tableau de positions à effectuer :

/**
 * Array waintingList
 * Liste d'attente pour les déplacements
 */
p.waitingList = [];

Décalage en pixels

Le déplacement va s'effectuer progressivement. La position réelle du joueur ne sera donc pas celle affichée, pendant le déplacement. Il y aura donc un décalage, en X et en Y, qui sera réduit progressivement, jusqu'à ce qu'il soit nul (le déplacement aura alors été effectué). Nous allons donc ajouter deux propriétés pour ce décalage :

/**
 * int decalX, int decalY : décalage (en pixels) entre la position réelle et la position actuelle.
 */
p.decalX = 0;
p.decalY = 0;

La méthode "smoothMove"

La méthode “smoothMove” va être très simple : nous n'avons qu'à ajouter le déplacement demandé à la liste d'attente, par exemple avec la méthode “push” :

/**
 * void smoothMove
 * @purpose : Déplace le joueur
 * $x, $y : déplacement à effectuer en x et en y.
 */
p.smoothMove = function ( $x, $y )
{
        this.waitingList.push ( [ $x, $y ] );
};

Effectuer les déplacements

Maintenant, nous devons effectuer le déplacement, et c'est là que sa se complique ! Pour commencer, la fonction qui effectue le déplacement devra s'executer à chaque image. Nous allons donc utiliser une méthode similaire à l'utilisation de l'évènement “onEnterFrame” en ActionScript.

Pour cela, nous allons utiliser une méthode proposée par CreateJS. Nous commençons par ajouter l'écouteur, par exemple dans le constructeur de notre classe “Player” :

Ticker.addListener ( this, true );

La méthode “Ticker.addListener” comprend deux paramètres :

  1. - L'objet à ajouter : ici, this (soit, l'instance de la classe “Player”).
  2. - La prise en charge du mode “pause”, autorisant ainsi l'arrêt de la diffusion de l'évènement dans ce cas. Nous choisissons donc “true”.

Le constructeur est alors comme ceci :

/**
 * Class Player extends Tile
 */
var Player = function ( $type, $content, $walkable, $offsetX, $offsetY )
{
        this.construct ( $type, $content, $walkable, $offsetX, $offsetY );
 
        Ticker.addListener ( this, true );
};

Maintenant, nous aurons une méthode qui s'exécutera à chaque image :

/**
 * Execution à chaque image.
 */
p.tick = function ( $e )
{
     //Ce code est exécuté à chaque image !
}

Et c'est donc cette dernière méthode que nous allons modifier.

Première chose à faire : si il y a un décalage en X ou en Y, on doit réduire ce décalage jusqu'à ce qu'il soit nul. On commence donc par vérifier la présence d'un décalage :

if ( this.decalX !== 0 || this.decalY !== 0 )
{
     //S'il y a un décalage, on doit le réduire.
}

Nous allons ensuite tester, pour chaque décalage, s'il est positif ou négatif. En fonction de ça, nous allons pouvoir le réduire, et modifier la position du joueur (c'est à dire la position de l'objet “this.content”).

Le déplacement étant isométrique, nous prendrons soin de réduire le décalage en X deux fois plus vite que le décalage en Y ! Pour la vitesse, nous pouvons choisir ce que nous voulons, à condition, bien évidemment, qu'il s'agisse d'un diviseur de la taille d'une tuile (sinon, notre décalage n'arrivera jamais à 0 exactement).

Pour ma part, j'ai choisis une vitesse de 8 en X, et donc 4 en Y. Nous pouvons donc avoir un code similaire au code suivant :

if ( this.decalX !== 0 || this.decalY !== 0 )
{
        if ( this.decalX > 0 )
        {
                this.decalX -= 8;
                this.content.x += 8;
        }
        else if ( this.decalX < 0 )
        {
                this.decalX += 8;
                this.content.x -= 8;
        }
 
        if ( this.decalY > 0 )
        {
                this.decalY -= 4;
                this.content.y += 4;
        }
        else if ( this.decalY < 0 )
        {
                this.decalY += 4;
                this.content.y -= 4;
        }
}

Ainsi, s'il y a un décalage en X ou en Y, on le réduit progressivement, en déplaçant le joueur de quelques pixels.

Ensuite, s'il n'y a pas de décalage, c'est que le mouvement en cours à été effectué, ou qu'il n'y a pas de mouvement en cours. Dans ce cas, nous pouvons regarder s'il y a d'autres mouvements à effectuer dans la liste d'attente :

else if ( this.waitingList.length !== 0 )
{
        //La liste d'attente n'est pas vide, on doit donc effectuer le prochain mouvement !
}

On commence donc par récupérer le prochain mouvement à effectuer, avec la méthode “shift” de la classe Array :

var position = this.waitingList.shift();

On peut alors modifier les coordonnées du joueur :

this.posX = position[0];
this.posY = position[1];

Nous mouvons alors mettre à jour les profondeurs :

this.map.updateDepth ();
Ici, nous ne pouvons par utiliser la méthode “this.map.update()”, car celle-ci mettrait également à jour les positions, et le mouvement serais donc effectué “d'un coup” !

Et enfin, nous allons modifier les valeurs “decalX” et “decalY”, pour réaliser le déplacement nécessaire. Nous commençons par chercher la tuile sur laquelle nous devons aller, pour connaître sa position :

var toTile = this.map.getTileAt ( this.posX, this.posY, 0 );

Nous devons alors décaler notre joueur. Pour obtenir la distance à parcourir, en X et en Y, nous allons donc prendre les coordonnées finales (toTile.content.x et toTile.content.y) auxquelles nous allons soustraire les coordonnées actuelles (this.content.x et this.content.y) :

this.decalX = toTile.content.x - this.content.x;
this.decalY = toTile.content.y - this.content.y;

Et … notre joueur effectue alors le déplacement de façon lisse.

Déplacement animé = erreurs de profondeurs !

En ayant réalisé toutes ces modifications, nous avons alors un personnage qui se déplace progressivement de façon animée. Cependant, il ne vous faudra pas longtemps pour voir le problème !

Nous avons le résultat suivant : exemple 2

Effectuez un mouvement vers le bas : nous n'avons pas de problèmes. Par contre, lorsque nous effectuons un déplacement vers le haut … nous voyons tout de suite un gros problème de profondeur !

L'explication est simple : la mise à jour des profondeurs est effectuée au début d'un déplacement. Pendant le déplacement, la profondeur du joueur est donc égale à la profondeur finale, c'est à dire à celle qu'il aura une fois le déplacement terminé.

Le problème, c'est donc que nous allons devoir mettre à jour les profondeurs au début OU à la fin d'un déplacement, selon le type de déplacements (haut/bas). Nous allons donc parer à ce problème.

Pour commencer, nous allons ajouter une nouvelle propriété “needUpdate”, qui permettra d'effectuer une mise à jour des profondeurs différée à la fin d'un déplacement :

/**
 * bool needUpdate.
 * Indique la nécessité de mettre à jour les profondeurs.
 */
p.needUpdate = false;

Ensuite, nous devrons effectuer la mise à jour si cette propriété est affectée à “true”. Nous ferons donc cette mise à jour entre deux déplacements, c'est à dire après la condition qui vérifie s'il y a un décalage, et avant celle qui vérifie si la liste d'attente est vide :

else if ( this.needUpdate )
{
        this.map.updateDepth();
        this.needUpdate = false;
}

Enfin, nous allons devoir modifier notre dernière condition, pour soit effectuer la mise à jour des profondeurs, soit la différer en affectant “needUpdate” à la valeur “true”.

Avant de mettre à jour la position du joueur (posX, posY), nous vérifions donc si nous avons besoin de différer la mise à jour (déplacement vers le bas) :

if ( this.posX - position[0] > 0 || this.posY - position[1] > 0 )
{
        this.needUpdate = true;
}

Plus loin, une fois les coordonnées du joueur mises à jour, si nous n'avons pas de mise à jour différée, nous l'effectuons immédiatement :

if ( !this.needUpdate )
{
        this.map.updateDepth ();
}

Notre fonction “p.tick” est donc définie comme ceci :

/**
 * Execution à chaque image.
 */
p.tick = function ( $e )
{
        if ( this.decalX !== 0 || this.decalY !== 0 )
        {
                if ( this.decalX > 0 )
                {
                        this.decalX -= 8;
                        this.content.x += 8;
                }
                else if ( this.decalX < 0 )
                {
                        this.decalX += 8;
                        this.content.x -= 8;
                }
 
                if ( this.decalY > 0 )
                {
                        this.decalY -= 4;
                        this.content.y += 4;
                }
                else if ( this.decalY < 0 )
                {
                        this.decalY += 4;
                        this.content.y -= 4;
                }
        }
        else if ( this.needUpdate )
        {
                this.map.updateDepth();
                this.needUpdate = false;
        }
        else if ( this.waitingList.length !== 0 )
        {
                var position = this.waitingList.shift();
 
                if ( this.posX - position[0] > 0 || this.posY - position[1] > 0 )
                {
                        this.needUpdate = true;
                }
 
                this.posX = position[0];
                this.posY = position[1];
 
                if ( !this.needUpdate )
                {
                        this.map.updateDepth ();
                }
 
                var toTile = this.map.getTileAt ( this.posX, this.posY, 0 );
 
                this.decalX = toTile.content.x - this.content.x;
                this.decalY = toTile.content.y - this.content.y;
        }
};

Et nous avons maintenant réglé ce “bug des profondeurs” durant le déplacement !

Le cas de clics pendant le mouvement

Nous avons donc maintenant le résultat suivant : exemple 3. Le problème de profondeurs est donc reglé, mais il nous reste, malheureusement, encore un bug ! Il s'agit du cas d'un clic pendant le déplacement.

Effectuez un déplacement (si possible un peu long), et, pendant ce déplacement, cliquez ailleurs pour en demander un second. Vous verrez alors le joueur se comporte de façon étrange pour le second déplacement.

L'explication est simple : lorsque l'on clic, l'algorithme de Pathfinding génère le trajet à partir de la position courante. Le problème, c'est que le trajet sera effectué à la fin du déplacement en cours, et donc la position de départ ne sera plus celle donnée à l'algorithme de Pathfinding.

Pour régler ce problème, nous allons devoir modifier la méthode “movePlayer” de la classe “Map”. Nous allons créer deux variables “playerX” et “playerY” qui correspondront aux bonnes coordonnées. Deux cas sont donc possibles : soit la liste d'attente est vide, dans ce cas nous pouvons utiliser “player.posX” et “player.posY”, soit elle ne l'est pas, et nous utilisons les dernières valeurs de la liste d'attente :

var playerX, playerY;
if ( this.player.waitingList.length === 0 )
{
        /**
         * La liste d'attente est vide, on utilise les coordonnées réélles.
         */
        playerX = this.player.posX;
        playerY = this.player.posY;
}
else
{
        /**
         * La liste d'attente n'est pas vide, on utilise les coordonnées finales.
         */
        playerX = this.player.waitingList[this.player.waitingList.length-1][0];
        playerY = this.player.waitingList[this.player.waitingList.length-1][1];
}

Nous choisissons ensuite le point de départ en fonction des variables “playerX” et “playerY” :

var start = graph[playerX][playerY];

Et, cette fois, tous les problèmes sont résolus !

Récapitulatif !

Nous avons beaucoup modifié nos classes pour utiliser notre algorithme de Pathfinding, et je vais donc donner le code final des classes “Map” et “Player”.

A la fin de ce tutoriel, vous devez donc avoir le résultat suivant : exemple 4

Code de la classe “Map” :

/**
 * Class Map extends Container
 */
var Map = function ()
{};
 
var p = Map.prototype = new Container();
 
/**
 * object tiles
 * Tableau de tuiles (une seule dimension).
 */
p.tiles = [];
 
/**
 * int TileWidth
 * Largeur des tuiles (px).
 */
p.tilesWidth = 128;
 
/**
 * int TileHeight
 * Hauteur des tuiles (px).
 */
p.tilesHeight = 64;
 
/**
 * int offsetX
 * Décalage en X.
 */
p.offsetX = 0;
 
/**
 * int offsetY
 * Décalage en Y.
 */
p.offsetY = 0;
 
/**
 * Player player
 * Joueur contrôlable dans le cas de l'utilisation du pathfinding.
 */
p.player = null;
 
/**
 * void addTile
 * @purpose : Ajoute une tuile à la carte.
 * tile $tile : Tuile à ajouter à la carte.
 * int $x : position en X de l'endroit ou ajouter la tuile (en nombre de cases).
 * int $y : position en Y de l'endroit ou ajouter la tuile (en nombre de cases).
 * int $z : position en Z de l'endroit ou ajouter la tuile (en nombre de cases).
 */
p.addTile = function ( $tile, $x, $y, $z )
{
        $tile.posX = $x;
        $tile.posY = $y;
        $tile.posZ = $z;
        $tile.map = this;
 
        this.tiles.push ( $tile );
        this.addChild ( $tile.content );
 
        this.update ();
 
        $tile.content.onClick = function ()
        {
                this.tile.map.movePlayer ( this.tile.posX, this.tile.posY );
        };
};
 
/**
 * void removeTile
 * @purpose : Enlève une tuile de la carte
 * tile $tile : Tuile à supprimer de la carte.
 */
p.removeTile = function ( $tile )
{        
        var index = this.tiles.indexOf ( $tile );
 
        if ( index === -1 )
        {
                throw new Error ( 'Map.removeTile : la tuile à supprimer ne fait pas partie de la carte !' );
        }
 
        this.removeChild ( $tile.content );
        this.tiles.splice ( index, 1 );
 
        $tile.dispose();
};
 
/**
 * tile getTileAt
 * @purpose : Renvoie la tuile à la position indiquée.
 * $x, $y, $z : position de la tuile demandée.
 */
p.getTileAt = function ( $x, $y, $z )
{ 
        var r_tile = null;
        this.tiles.forEach ( function($tile)
        {
                if ( $tile.posX === $x && $tile.posY === $y && $tile.posZ === $z )
                {
                        r_tile = $tile;
                }
        });
 
        return r_tile;
};
 
/**
 * void updatePos
 * @purpose : Met à jour l'affichage.
 */
p.update = function ()
{
        this.updatePos();
        this.updateDepth();
};
 
/**
 * void updatePos
 * @purpose : Met à jour l'affichage (positions).
 */
p.updatePos = function ()
{
        /**
         * Mise à jour des tuiles.
         */
        this.tiles.forEach ( function($tile)
        {
                $tile.content.x = ( $tile.posY - $tile.posX ) * ($tile.map.tilesWidth/2) + ($tile.offsetX + $tile.map.offsetX);
                $tile.content.y = ( $tile.posY + $tile.posX ) * ($tile.map.tilesHeight/2) + ($tile.offsetY + $tile.map.offsetY);              
 
                $tile.content.tile = $tile;
        });
};
 
/**
 * void updateDepth
 * @purpose : Met à jour l'affichage (profondeurs).
 */
p.updateDepth = function ()
{
        /**
         * Tri des tuiles pour gérer les profondeurs.
         */
        this.sortChildren (function($a,$b)
        {
                $n_a = 5 * ($a.tile.posX + $a.tile.posY) + $a.tile.posZ;
                $n_b = 5 * ($b.tile.posX + $b.tile.posY) + $b.tile.posZ;
 
                if ( $n_a > $n_b )
                {
                        return 1;
                }
                else if ( $n_a < $n_b )
                {
                        return -1;
                }
                else
                {
                        return 0;
                }
        });
};
 
/**
 * Array getGraph
 * @purpose : Crée une carte de nodes pour le pathfinding.
 */
p.getGraph = function ()
{
        var graph = [];
 
        for ( var i = 0, max = this.tiles.length; i < max; i++ )
        {
                var tile = this.tiles[i];
 
                if ( graph[tile.posX] === undefined )
                {
                        graph[tile.posX] = [];
                }
 
                graph[tile.posX][tile.posY] = new Node ( tile.posX, tile.posY, tile.walkable );
        }
 
        return graph;
};
 
/**
 * void movePlayer
 * int $x, int $y : coordonnées de la tuile d'arrivée
 */
p.movePlayer = function ( $x, $y )
{
        if ( this.player === null )
        {
                /**
                 * Pas de joueur, donc pas de pathfinding.
                 */
                return;
        }
 
        var playerX, playerY;
        if ( this.player.waitingList.length === 0 )
        {
                /**
                 * La liste d'attente est vide, on utilise les coordonnées réélles.
                 */
                playerX = this.player.posX;
                playerY = this.player.posY;
        }
        else
        {
                /**
                 * La liste d'attente n'est pas vide, on utilise les coordonnées finales.
                 */
                playerX = this.player.waitingList[this.player.waitingList.length-1][0];
                playerY = this.player.waitingList[this.player.waitingList.length-1][1];
        }
 
        var graph = this.getGraph();
        var start = graph[playerX][playerY];
        var end = graph[$x][$y];
 
        var path = Pathfinder.findPath ( graph, start, end );
 
        while ( path.length !== 0 )
        {
                var toTile = path.shift ();
 
                this.player.smoothMove ( toTile.line, toTile.col );
        }
};
 
/**
 * void dispose
 * @purpose : Détruit la carte.
 */
p.dispose = function ()
{
        while ( this.tiles.length > 0 )
        {
                this.removeTile ( this.tiles[0] );
        }
 
        this.tiles = null;
        this.tilesWidth = null;
        this.tilesHeight = null;
        this.offsetX = null;
        this.offsetY = null;
};

Code de la classe “Player” :

/**
 * Class Player extends Tile
 */
var Player = function ( $type, $content, $walkable, $offsetX, $offsetY )
{
        this.construct ( $type, $content, $walkable, $offsetX, $offsetY );
 
        Ticker.addListener ( this, true );
};
 
var p = Player.prototype = new Tile ();
 
/**
 * Array waintingList
 * Liste d'attente pour les déplacements
 */
p.waitingList = [];
 
/**
 * int decalX, int decalY : décalage (en pixels) entre la position réelle et la position actuelle.
 */
p.decalX = 0;
p.decalY = 0;
 
/**
 * bool needUpdate.
 * Indique la nécessité de mettre à jour les profondeurs.
 */
p.needUpdate = false;
 
/**
 * void smoothMove
 * @purpose : Déplace le joueur
 * $x, $y : déplacement à effectuer en x et en y.
 */
p.smoothMove = function ( $x, $y )
{
        this.waitingList.push ( [ $x, $y ] );
};
 
/**
 * Execution à chaque image.
 */
p.tick = function ( $e )
{
        if ( this.decalX !== 0 || this.decalY !== 0 )
        {
                if ( this.decalX > 0 )
                {
                        this.decalX -= 8;
                        this.content.x += 8;
                }
                else if ( this.decalX < 0 )
                {
                        this.decalX += 8;
                        this.content.x -= 8;
                }
 
                if ( this.decalY > 0 )
                {
                        this.decalY -= 4;
                        this.content.y += 4;
                }
                else if ( this.decalY < 0 )
                {
                        this.decalY += 4;
                        this.content.y -= 4;
                }
        }
        else if ( this.needUpdate )
        {
                this.map.updateDepth();
                this.needUpdate = false;
        }
        else if ( this.waitingList.length !== 0 )
        {
                var position = this.waitingList.shift();
 
                if ( this.posX - position[0] > 0 || this.posY - position[1] > 0 )
                {
                        this.needUpdate = true;
                }
 
                this.posX = position[0];
                this.posY = position[1];
 
                if ( !this.needUpdate )
                {
                        this.map.updateDepth ();
                }
 
                var toTile = this.map.getTileAt ( this.posX, this.posY, 0 );
 
                this.decalX = toTile.content.x - this.content.x;
                this.decalY = toTile.content.y - this.content.y;
        }
};
 
/**
 * void move
 * @purpose : Déplace le joueur
 * $x, $y : déplacement à effectuer en x et en y.
 */
p.move = function ( $x, $y )
{
        /**
         * On teste si le déplacement est possible.
         */
        if ( this.map.getTileAt( this.posX + $x, this.posY + $y, 0 ).walkable )
        {
                /**
                 * Le déplacement est possible, donc on l'éffectue.
                 */
                this.posX += $x;
                this.posY += $y;
 
                /**
                 * Enfin, on met à jour la carte.
                 */
                this.map.update ();
        }
};

Conclusion

Nous avons ainsi pu voir comment transcrire un algorithme de pathfinding en JavaScript, et comment l'exploiter dans notre moteur isométrique.

Ce modifications nous aurons ainsi permis de créer un jeu jouable à la souris, et utilisable notamment sur tablette tactile !

Navigation rapide : Sommaire