Charger un niveau avec le patron Visiteur

Dans cet article, je vous propose d’utiliser le patron Visiteur (Visitor Pattern) pour charger facilement un niveau. Ce patron permet (entre autres) de facilement parcourir une structure de données pour en extraire une information. Dans notre cas, c’est un fichier XML créé par l’éditeur “Tiled Map Editor” qui est analysé pour charger un niveau en mémoire.

Je commence par créer un niveau en XML avec Tiled, qui permet d’illustrer les différents principes:

Il faut bien choisir dans le panneau à gauche Format de calque de tile le format “XML”. Cela produit un fichier XML qui ressemble à ceci:

<?xml version="1.0" encoding="UTF-8"?>
<map version="1.0" orientation="orthogonal" renderorder="left-up" width="16" height="16" tilewidth="16" tileheight="16" nextobjectid="1">
 <tileset firstgid="1" name="advancewars-tileset1" tilewidth="16" tileheight="16" tilecount="256">
  <image source="advancewars-tileset1.png" width="256" height="256"/>
 </tileset>
 <tileset firstgid="257" name="advancewars-tileset2" tilewidth="16" tileheight="16" tilecount="256">
  <image source="advancewars-tileset2.png" width="256" height="256"/>
 </tileset>
 <layer name="Ground" width="16" height="16">
  <data>
   <tile gid="41"/>
   <tile gid="41"/>
   <tile gid="41"/>
   <tile gid="41"/>
   <tile gid="41"/>
   <tile gid="41"/>
   <tile gid="41"/>
   <tile gid="41"/>
   ...
   <tile gid="41"/>
  </data>
 </layer>
 <layer name="Objects" width="16" height="16">
  <data>
   <tile gid="0"/>
   <tile gid="0"/>
   <tile gid="0"/>
   <tile gid="0"/>
   <tile gid="0"/>
   ...
   <tile gid="0"/>
   <tile gid="0"/>
   <tile gid="0"/>
  </data>
 </layer>
</map>

On a tout d’abord une balise principale <map> avec des propriétés globale, puis deux balises <tileset> qui définissent les jeux de tuiles utilisés (un pour le fond, l’autre pour les objets), et enfin deux balises <layer> qui définissent les tuiles pour deux couches (l’une pour le fond, l’autre pour les objets).

Pour décoder ce fichier, je propose d’utiliser le parser XML inclus dans la librairie Java standard. Celui-ci repose sur la patron Visiteur, et permet de facilement travailler sur les balises sans avoir à se soucier des problèmes de décodage de caractères.

Patron Visiteur

Le patron Visiteur peut être présenté de la manière suivante :

L’interface IElement représente les éléments de la structure. Dans cet exemple, il y a deux types d’objets possible : ElementA et ElementB. L’interface requiert une méthode du type accept() qui prend comme argument un visiteur qui implante l’interface IVisitor. Il peut y avoir plusieurs manières de visiter la structure, et donc autant d’autres méthodes similaires à accept(). Des paramètres peuvent être également utilisés pour influencer le parcours. L’interface IVisitor est implantée par l’utilisateur qui souhaite parcourir les données structurées. En général, les méthodes de l’interface correspondent aux différents types d’éléments qui la compose : dans cet exemple, ce sont les classes ElementA et ElementB. Il est tout à fait possible d’imaginer d’autres cas qui peuvent intéresser un utilisateur, comme être au début ou à la fin d’un élément. Dans tous les cas, la méthode accept() appelle les méthodes de l’interface IVisitor en fonction des cas rencontrés lors de son parcours. Au sein de ces méthodes, l’utilisateur est libre de consulter et modifier les données.

Lire le niveau

Pour lire et stocker les informations du niveau, je définis une classe Level qui contient ces informations sous la forme d’attributs:

public class Level
{
    private ArrayList<String> tilesetImages = new ArrayList();
    private int tileWidth;
    private int tileHeight;

    private int width;
    private int height;
    private int[][][] level;
    
    private int tilesetWidth;
    private int tilesetHeight;
    private int x;
    private int y;
    ...
  • L’attribut tilesetImages contient le nom des images pour les jeux de tuiles;
  • Les attributs tileWidth et tileHeight contiennent la largeur et la hauteur d’une tuile (16 x 16 pixels dans notre exemple);
  • Les attributs width et height contiennent la largeur et la hauteur du niveau (16 x 16 cellules dans notre exemple);
  • L’attribut level contient pour chaque coordonnées de cellule trois informations: les coordonnées (x,y) (en tuiles) de la tuile à dessiner, et le jeu de tuile à utiliser.
  • Les attributs suivants servent uniquement à décoder les informations dans le fichier.

Pour décoder le fichier, je définis une classe LevelLoader qui implante org.xml.sax.helpers.DefaultHandler. La classe LevelLoader est l’équivalent de la classe Visitor dans le diagramme ci-dessus, et DefaultHandler l’équivalent de IVisitor. Je ne m’intéresse qu’à la méthode startElement(), équivalent des méthodes visitElementA() et visitElementB() dans le diagramme ci-dessus. Cette méthode est appelée à chaque fois que le parser rencontre une nouvelle balise ouvrante:

public class LevelLoader extends DefaultHandler {

    public void startElement (String uri, String localName,
                          String qName, Attributes attributes)
       throws SAXException {
        if (qName.equals("tileset")) {
            if (tilesetImages.isEmpty()) {
                tileWidth = Integer.parseInt(attributes.getValue("tilewidth"));
                tileHeight = Integer.parseInt(attributes.getValue("tileheight"));
            }
        }
        else if (qName.equals("image")) {
            if (tilesetImages.isEmpty()) {
                tilesetWidth = Integer.parseInt(attributes.getValue("width")) / tileWidth;
                tilesetHeight = Integer.parseInt(attributes.getValue("height")) / tileHeight;
            }
            tilesetImages.add(attributes.getValue("source"));                
        }
        else if (qName.equals("layer")) {
            if (level == null) {
                width = Integer.parseInt(attributes.getValue("width"));
                height = Integer.parseInt(attributes.getValue("height"));
                level = new int[height][width][3];                    
            }
            x = 0;
            y = 0;
        }
        else if (qName.equals("tile")) {
            int id = Integer.parseInt(attributes.getValue("gid"));
            if (id != 0) {
                if (id >= 257) {
                    level[x][y][2] = 1;
                    id -= 256;
                    level[x][y][0] = id % tilesetWidth - 1;
                    level[x][y][1] = id / tilesetWidth - 1;
                }
                else {
                    level[x][y][0] = id % tilesetWidth - 1;
                    level[x][y][1] = id / tilesetWidth;
                }
            }
            x ++;
            if (x >= width) {
                x = 0;
                y ++;
                if (y > height) {
                    throw new SAXException("Erreur dans le fichier");
                }
            }
        }
    }
}

La méthode est une longue discussion en fonction de la balise rencontrée, dont le nom est placé dans l’argument qName. Pour les cas “tileset” et “image” les informations relatives à un jeu de tuiles sont décodées. Pour le cas “layer”, le niveau est initialisé, et pour “tile”, les informations relatives à une tuile sont mémorisées. Les attributs x et y servent à mémoriser les coordonnées de la prochaine tuile à décoder.

Enfin, je définis une méthode load() dans la classe Levelqui utilise la classe LevelLoader pour charger le niveau. Rien d’extraordinaire ici, c’est un quasi copié collé du tutoriel sur le site de Java:

public boolean load(String fileName) {
    try {
        SAXParserFactory spf = SAXParserFactory.newInstance();
        SAXParser saxParser = spf.newSAXParser();
        XMLReader xmlReader = saxParser.getXMLReader();
        xmlReader.setContentHandler(new LevelLoader());
        URL fileURL = this.getClass().getClassLoader().getResource(fileName);
        xmlReader.parse(fileURL.toString());
        return true;
    }
    catch(Exception ex) {
        return false;
    }
}

La classe classe Level dispose également d’accesseurs/mutateurs (getters/setters) non listés ici.

Afficher le niveau

Pour afficher le niveau, je reprend le code de l’article sur la façade AWT pour pouvoir afficher des tuiles. Le résultat est très proche de ce qui est fait dans cet article, sinon le chargement et l’utilisation des données chargées:

Level level = new Level();
if (!level.load("advancewars-map1.tmx")) {
    JOptionPane.showMessageDialog(null, "Erreur lors du chargement de advancewars-map1.tmx", "Erreur", JOptionPane.ERROR_MESSAGE);
    return;
}

int scale = 2;

Layer backgroundLayer = gui.createLayer();
backgroundLayer.setTileSize(level.getTileWidth(),level.getTileHeight());
backgroundLayer.setTexture(level.getTilesetImage(0));
backgroundLayer.setSpriteCount(level.getWidth()*level.getHeight());
for(int y=0;y<level.getHeight();y++) {
    for(int x=0;x<level.getWidth();x++) {
        int index = x + y * level.getWidth();
        backgroundLayer.setSpriteLocation(index, new Rectangle(scale*x*level.getTileWidth(), scale*y*level.getTileHeight(), scale*level.getTileWidth(), scale*level.getTileHeight()));
        if (level.getTileset(x, y) == 0) {
            Rectangle tile = new Rectangle(level.getTile(x, y), new Dimension(1,1));
            backgroundLayer.setSpriteTexture(index, tile);
        }
    }
}

Layer groundLayer = gui.createLayer();
groundLayer.setTileSize(level.getTileWidth(),level.getTileHeight());
groundLayer.setTexture(level.getTilesetImage(1));
groundLayer.setSpriteCount(level.getWidth()*level.getHeight());
for(int y=0;y<level.getHeight();y++) {
    for(int x=0;x<level.getWidth();x++) {
        int index = x + y * level.getWidth();
        groundLayer.setSpriteLocation(index, new Rectangle(scale*x*level.getTileWidth(), scale*(y-1)*level.getTileHeight(), scale*level.getTileWidth(), scale*2*level.getTileHeight()));
        if (level.getTileset(x, y) == 1) {
            Rectangle tile = new Rectangle(level.getTile(x, y), new Dimension(1,2));
            groundLayer.setSpriteTexture(index, tile);
        }
    }
}

gui.createWindow("Patron visiteur pour lire un niveau",
    scale*level.getTileWidth()*level.getWidth(),
    scale*level.getTileHeight()*level.getHeight());

Le code de cet article peut être téléchargé ici.
Pour compiler, saisissez: javac fr/phtools/visitor01/Main.java
Pour lancer, saisissez: java fr.phtools.visitor01.Main

 

Ce contenu a été publié dans Tutoriel, avec comme mot(s)-clé(s) , . Vous pouvez le mettre en favoris avec ce permalien.

Laisser un commentaire