Dans cette nouvelle entrée, nous allons récupérer la liste des Mondes ainsi que leurs noms localisés (internationalization, localization - i18n et l10n, le fait d'afficher des données -et également de les saisir ou de les présenter- en fonction de la langue de l'utilisateur).
Charabia légal
Guild Wars et Guild Wars 2 sont des marques déposées de ArenaNet et NCsoft. De plus, toutes les images restent la propriété de ArenaNet. De même, la Web API utilisée reste la propriété de ArenaNet qui peut décider de la retirer ou d'en interdire l'utilisation comme bon lui semble. Et bien sûr, je ne suis affilié ni avec ArenaNet, ni avec NCsoft.
Contexte
Même si le concept m'a paru souvent étrange car venant de Guild Wars 1 où la notion de monde est quasiment inexistante, dans la plupart des MMORPG, les joueurs voient leur personnages liés à des mondes ou encore serveurs (aussi appelés shards -éclats- dans d'autres jeux qui ont repris la dénomination de l'antique Ultima Online). Dans la plupart des jeux de ce type (World of Warcraft, Star Wars: The Old Republic, ou ceux qui les ont précédés), un joueurs peut se connecter à plusieurs serveurs et chaque serveur contient une liste différente de personnages créés par ce joueur. Souvent ces jeux offrent des services payants pour permettre le transfert d'un personnage vers un autre serveur. Guild Wars 2 fonctionne un peu différemment : le compte d'un joueur est affilié à un seule et unique monde et ce monde contient tous les personnages du compte. Pour transférer son compte sur un autre monde, il faut utiliser un service payant pour déplacer l'intégralité des personnages vers le monde destination (ou effacer tous les personnages auquel cas le transfert est gratuit).
Une seconde notion s'y ajoute, celle de région. Bien qu'il n'y ait qu'un seul jeu américano-européen, il y a une séparation entre les serveurs américains et européens. Certains services tels que l’hôtel de vente, le chat ou la messagerie sont trans-région mais, actuellement*, un joueur américain ne peut pas venir aider ou combattre un joueur européen (et inversement). Outre un probable besoin de maîtrise des coûts, d'autres considération sont sans doute entrée en ligne de compte dans ce choix de design : avoir les données des joueurs européens hébergées sur des datacenters européens, plutôt qu'aux États Unis (et donc un ping ou une latence plus basse offrant alors une meilleure expérience de jeu) ou encore éviter des matchs McM qui seraient déséquilibrés à cause de mondes ayants des horaires de plage de jeu trop décalés (les joueurs jouent principalement en soirée ou le week-end, hors il y a un trop grand décalage horaire entre l'Europe et les Amériques, donc les serveurs d'un même match mixte n'auraient pas la même plage de couverture horaire).
*Il fut un temps ou ce genre de limitation existait également dans Guild Wars 1 mais elle fut levée dans la seconde année d’existence du jeu.
De plus, compte tenu de la multiplicité des langues européennes, certains mondes sont principalement dédiés à des communautés linguistiques ; ainsi, dans la région EU, la plupart des monde sont "européens", c'est à dire multilingues mais à dominance anglaise pour la communication entre joueurs (il n'y a pas que les joueurs européens d'ailleurs : les joueurs d'Afrique du Sud, de Russie ou encore du Moyen Orient jouent également sur ces serveurs) ; tandis qu'il existe plusieurs mondes dédiés aux locuteurs FR (langue française) et DE (langue allemande) et également un monde ES (langue espagnole). Dans la région US, au contraire, tous les mondes sont en langue anglaise (les joueurs d'Amérique du Sud semblent jouer principalement sur le serveur EU espagnol).
Le jeu comporte actuellement 3 modes principaux :
- JcE - Joueur contre Environnement (PvE - Player vs. Environnement) : le monde aventure du jeu. Avant la grosse mise à jour de début 2014, la notion de mondes avait un impact majeur en JcE, car les joueurs d'un même serveur se retrouvaient ensemble dans les villes et dans les zones d'exploration (le monde hors des villes). Pour simplifier, si on était sur un monde FR, on se retrouvait entre joueurs de ce même monde FR ; il était possible mais pas forcément évident au premier abord de rencontrer et de jouer avec des joueurs d'autres serveurs européens. Cette limitation a été levée depuis avec une fusion des mondes dans ce que le concepteur du jeu a appelé le megaserver qui fait que les joueurs européens (resp américains) jouent désormais tous ensemble. Cependant certains facteurs dont la langue du joueur peuvent encore entrer en ligne de compte dans l'algorithme qui décidera dans quelle instance de quelle carte le joueur arrive.
- JcJ - Joueur contre Joueur (PvP - Player vs. Player) : un mode compétitif en arènes soit des cartes de conquête (capture de point + victoire au score) soit des carte de match à mort (deathmatch). Un troisième mode, Bastion (Stronghold) sera rajouté plus tard dans l'année. Le mode JcJ n'a jamais été impacté par la notion de mondes, mise à part bien sur le fait que les joueurs américains et européens ne peuvent pas combattre ensemble.
- McM - Monde contre Monde (WvW - World vs. World) : le mode de match dans lesquel 3 mondes s'affrontent sur 4 cartes durant une semaine (un nouveau match démarre le vendredi soir et court jusqu'au vendredi suivant). Ici, la notion de mondes persiste, le but étant de créer un aspect communautaire entre les joueurs pour que ceux-ci participent à la défense et à l'effort de guerre de leur monde. Ainsi, il est courant que les principaux serveurs disposent de forums et de sites dédiés ou encore de serveurs TeamSpeak pour faciliter la transmission d'ordres de bataille ou d'information sur les mouvements ennemis.
Bien que désormais en retrait en JcE, la notion de monde dans Guild Wars 2 cependant est toujours présente de nos jours, c'est après tout probablement la première chose que le jeu doit renseigner lorsqu'il lance le jeu pour la première fois. De plus, au fur et à mesure que la semaine de match McM avance, suivant son score, un monde gagne des bonus (résistance, dégâts, découverte d'or, soins, ...) qui s'appliquent quand même en JcE et donc qui impactent les joueurs même si les néophytes n'y prêtent pas trop garde (ex : il est plus aisé de faire des donjons ou des événements difficiles le vendredi lorsqu'on a tous les bonus gagnés au cours de la semaine que le samedi après le début du match hebdomadaire lorsqu'on en a aucun).
En détails
L'API Guild Wars 2 nous présente donc un endpoint worlds qui permet de récupérer la liste des mondes du jeu. Ainsi, si on accède à l'URL https://api.guildwars2.com/v2/worlds, un tableau JSON contenant entiers sera retourné :
Code : | Sélectionner tout |
[1001,1002,1003,1004,1005,1006,1007,1008,1009,1010,1011,1012,1013,1014,1015,1016,1017,1018,1019,1020,1021,1022,1023,1024,2001,2002,2003,2004,2005,2006,2007,2008,2009,2010,2011,2012,2013,2014,2101,2102,2103,2104,2105,2201,2202,2203,2204,2205,2206,2207,2301]
Ici, chacun de ces nombres représente l’identifiant d'un des mondes actuels du jeu. Il est ensuite possible de consulter le nom du monde en ajoutant à l'URL le paramètre id=<id du monde> ; par exemple, l'URL https://api.guildwars2.com/v2/worlds?id=1001 retournera l'objet JSON suivant :
Code : | Sélectionner tout |
{"id":1001,"name":"Anvil Rock"}
Il est possible de spécifier un paramètre optionnel pour avoir le nom localisé du serveur en ajoutant un paramètre lang=<code du langage>. Pour composer plusieurs paramètres entre eux, il suffit d'utiliser le caractère & ; par exemple https://api.guildwars2.com/v2/worlds?id=1001&lang=fr. Cette requête retournera l'objet JSON suivant :
Code : | Sélectionner tout |
{"id":1001,"name":"Rocher de l'enclume"}
Plusieurs langues sont disponibles ; celles qui sont supportées par le jeu lui-même dans sa version US-EU :
- en - la langue anglaise qui est la langue par défaut quand on ne spécifie pas le paramètre ;
- fr - la langue française ;
- de - la langue allemande ;
- es - la langue espagnole.
Si vous utilisez des codes de langues qui ne sont pas supportées, le contenu sera retourné en anglais. Si vous utilisez un code inconnu, une erreur sera générée.
Il est également possible de requérir plusieurs mondes en même temps via le paramètre ids=<id du monde 1>,<id du monde 1>,<id du monde 3>, ... (ici, le paramètre est ids au lieu de id). Par exemple, l'URL https://api.guildwars2.com/v2/worlds...1,1002&lang=fr retournera un tableau JSON contenant des objets JSON définissant les mondes sont les identifiants sont 1001 et 1002 :
Code : | Sélectionner tout |
[{"id":1001,"name":"Rocher de l'enclume"},{"id":1002,"name":"Passage de Borlis"}]
Cooodage
Nous allons commencer par rajouter une méthode dans notre classe utilitaire test.query.QueryUtils :
Code Java : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | public static String idsToString(final int... ids) { String result = "all"; // NOI18N. if (ids.length > 0) { final StringBuilder builder = new StringBuilder(); for (final int id : ids) { builder.append(id); builder.append(','); // NOI18N. } // On retire la virgule finale. builder.replace(builder.length() - 1, builder.length(), ""); // NOI18N. result = builder.toString(); } return result; } |
Cette méthode idsToString() va nous permettre de générer le texte qui servira à composer les URLs qui permettent de faire des requêtes sur plusieurs identificateurs.
Tout comme nous avons fait pour les images de Quaggan, nous allons ensuite nous construire une classe dédiée aux interactions avec la Web API de Guild Wars 2. C'est cette classe qui va interagir avec l'API de la JSR 353 :
Code Java : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | public final class WorldsQuery { private static final String basecode = "https://api.guildwars2.com/v2/worlds"; // NOI18N. private WorldsQuery() { } public static List<Integer> list() throws IOException { final JsonArray array = queryArray(basecode); final List<Integer> result = array.getValuesAs(JsonNumber.class) .stream() .map(value -> value.intValue()) .collect(Collectors.toList()); return Collections.unmodifiableList(result); } public static Pair<Integer, String> worldName(final String languageCode, final int id) throws IOException { final JsonObject value = queryObject(String.format("%s?id=%d&lang=%s", basecode, id, languageCode)); // NOI18N. final String name = value.getString("name"); // NOI18N. final Pair<Integer, String> result = new Pair<>(id, name); return result; } public static Map<Integer, String> worldNames(final String languageCode, final int... ids) throws IOException { final String idsCode = idsToString(ids); final JsonArray array = queryArray(String.format("%s?ids=%s&lang=%s", basecode, idsCode, languageCode)); // NOI18N. final Map<Integer, String> result = new HashMap(); array.getValuesAs(JsonObject.class) .stream() .forEach((value) -> { final JsonNumber id = value.getJsonNumber("id"); // NOI18N. final JsonString name = value.getJsonString("name"); // NOI18N. result.put(id.intValue(), name.getString()); }); return result; } } |
Regardons ces méthodes d'un peu plus près :
- la méthode list() retournera la liste des identifiants des serveurs. En l'état elle n'a que peu d’intérêt ; tout au plus elle permettra de vérifier que si on récupère un identificateur de serveur quelque part, ce numéro correspondra bien à l'id d'un serveur officiel ;
- la méthode worldName() retourne une paire l'identifiant et le nom localisé d'un seul serveur. Cela pourra être utile, par exemple, si un jour on peu afficher des données d'un compte utilisateur (puisque 1 compte = affilié à 1 seul monde) ;
- tandis que la méthode worldNames() (avec un s à la fin) retourne une table de hachage contenant les noms localisés des mondes qui dont les identifiants ont été passés en paramètres (ou de tous les mondes si aucun identifiant a été spécifié). C'est cette méthode que nous allons utiliser ici en récupérant la liste complète des noms. Le fait de pouvoir fournir des identifiants en paramètre est utile si on veut par exemple récupérer une liste partiele de mondes (par exemple le nom des 3 serveurs d'un même match McM) ou de faire un affichage découpé en plusieurs pages. Cependant ici la liste des mondes n'étant pas trop longue, autant la récupérer dans son intégralité.
Je vais donc construire à nouveau une interface graphique permettant d'exploiter cette requête. Celle-ci contiendra des éléments permettant de sélectionner la langue dans laquelle nous voulons que les noms s'affichent ainsi que la possibilité de filtrer la liste obtenue pour n'afficher que les mondes d'une régions donnée (ou tous si aucune région n'est sélectionnée). Comme vous pouvez le voir, il est possible de définir des groupes de bascule (ToggleGroup) dans le fichier FXML et d'utiliser leur identifiants (défini par l’attribut fx:id) comme nom de variable.
Code XML : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 | <?xml version="1.0" encoding="UTF-8"?> <?import javafx.scene.image.*?> <?import java.lang.*?> <?import java.util.*?> <?import javafx.scene.*?> <?import javafx.scene.control.*?> <?import javafx.scene.layout.*?> <?import javafx.scene.text.*?> <VBox id="rootPane" fx:id="rootPane" styleClass="root-pane" prefHeight="600" prefWidth="400" xmlns="http://javafx.com/javafx/8.0.40" xmlns:fx="http://javafx.com/fxml/1" fx:controller="test.controller.WorldListingController"> <children> <TextFlow id="regionFlow" fx:id="regionFlow" VBox.vgrow="NEVER"> <children> <Text id="regionLabel" fx:id="regionLabel" styleClass="main-text" text="%choose.your.region" /> </children> </TextFlow> <HBox id="regionBox" fx:id="regionBox" styleClass="region-box"> <children> <ToggleButton fx:id="usToggle" text="%region.us" mnemonicParsing="false" selected="true" styleClass="region-button"> <toggleGroup> <ToggleGroup fx:id="regionSelectionGroup" /> </toggleGroup> </ToggleButton> <Separator orientation="VERTICAL" prefHeight="-1" /> <ToggleButton fx:id="euToggle" text="%region.eu" mnemonicParsing="false" styleClass="region-button" toggleGroup="$regionSelectionGroup" selected="true" /> </children> </HBox> <TextFlow id="languageFlow" fx:id="languageFlow" VBox.vgrow="NEVER"> <children> <Text id="languageLabel" fx:id="languageLabel" styleClass="main-text" text="%choose.your.language" /> </children> </TextFlow> <HBox id="languageBox" fx:id="languageBox" styleClass="language-box"> <children> <ToggleButton fx:id="enToggle" text="%language.english" mnemonicParsing="false" styleClass="language-button"> <toggleGroup> <ToggleGroup fx:id="languageSelectionGroup" /> </toggleGroup> </ToggleButton> <Separator orientation="VERTICAL" prefHeight="-1" /> <ToggleButton fx:id="frToggle" text="%language.french" mnemonicParsing="false" styleClass="language-button" toggleGroup="$languageSelectionGroup" selected="true" /> <Separator orientation="VERTICAL" prefHeight="-1" /> <ToggleButton fx:id="deToggle" text="%language.german" mnemonicParsing="false" styleClass="language-button" toggleGroup="$languageSelectionGroup" /> <Separator orientation="VERTICAL" prefHeight="-1" /> <ToggleButton fx:id="esToggle" text="%language.spanish" mnemonicParsing="false" styleClass="language-button" toggleGroup="$languageSelectionGroup" /> </children> </HBox> <Label id="worldListLabel" fx:id="worldListLabel" text="%world.list" wrapText="true" /> <StackPane VBox.vgrow="ALWAYS"> <children> <ListView id="worldListView" fx:id="worldListView"/> <ProgressIndicator id="progressIndicator" fx:id="progressIndicator" maxHeight="64.0" maxWidth="64.0" /> </children> </StackPane> </children> </VBox> |
Le tout est injecté dans le contrôleur lors du chargement.
Nous allons donc créer un service dans ce contrôleur pour récupérer la table de hachage contenant les noms des mondes de manière asynchrone, toujours pour ne pas bloquer l'interface graphique de l'application (même si ici, on fait une seule requête assez courte dont le message de réponse n'est pas trop gros, autant prendre de bonnes habitudes) :
Code Java : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 | private void loadWorldList(final String regionCode, final String languageCode) { regionBox.setDisable(true); languageBox.setDisable(true); worldListView.setVisible(false); progressIndicator.setProgress(ProgressIndicator.INDETERMINATE_PROGRESS); progressIndicator.setVisible(true); final Service<Map<Integer, String>> query = new Service<Map<Integer, String>>() { @Override protected Task<Map<Integer, String>> createTask() { return new Task<Map<Integer, String>>() { @Override protected Map<Integer, String> call() throws Exception { return WorldsQuery.worldNames(languageCode); } }; } }; query.setOnSucceeded(workerStateEvent -> { worlds = query.getValue(); final ObservableList<Integer> worldIds = FXCollections.observableArrayList(worlds.keySet()); final ObservableList<Integer> sortedWorldIds = new SortedList<>(worldIds, Integer::compareTo); filteredWorldIds = new FilteredList<>(sortedWorldIds); filterWorldList(regionCode); worldListView.setItems(filteredWorldIds); worldListView.setCellFactory(listView -> new WorldListCell(worlds)); progressIndicator.setVisible(false); progressIndicator.setProgress(0); worldListView.setVisible(true); regionBox.setDisable(false); languageBox.setDisable(false); }); query.setOnCancelled(workerStateEvent -> { regionBox.setDisable(false); languageBox.setDisable(false); }); query.setOnFailed(workerStateEvent -> { regionBox.setDisable(false); languageBox.setDisable(false); System.out.println(query.getException()); }); query.start(); } |
Ici, il ne sert à rien de chercher à récupérer la liste des identifiants des mondes séparément. En effet, ils sont tous contenus dans la table de hachage retournée par la requête qui permet de récupérer les noms puisqu'il s'agit des clés de cette table. Donc autant faire d'une pierre deux coups et nous récupérons les clés de la table dans une liste, que nous empaquetons dans une liste observable que nous trions ensuite avant de la filtrer. Cette liste filtrée observable est ensuite placée dans une liste graphique.
Notre liste graphique ne contient que des valeurs entières ! Pour afficher les noms de nos serveurs, nous allons placer sur cette liste graphique une fabrique à cellules qui va prendre en paramètre notre table de hachage contenant les noms des mondes. Et c'est gagné : lorsque la cellule cherche à afficher un identificateur de monde, elle ira chercher son nom dans la table et l'affichera à la place de la valeur entière.
Code Java : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 | public final class WorldListCell extends ListCell<Integer> { [...] @Override protected void updateItem(Integer item, boolean empty) { super.updateItem(item, empty); if (!empty) { setText(worldNamesMap.get(item)); } } } |
Concernant le filtrage des mondes, au premier abord, l'API ne semble pas permettre de distinguer les mondes US des mondes EU. Mais, en y regardant de plus près, on peut s’apercevoir que l'identificateur de chacun des mondes américain commence par un 1, tandis que deux des mondes européens comment par un 2. Il est donc assez aisé de définir des prédicats qui serviront de filtres pour n'afficher qu'une liste partielle de mondes :
Code Java : | Sélectionner tout |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | private final Predicate<Integer> acceptAllPredicate = worldId -> true; private final Predicate<Integer> usOnlyPredicate = worldId -> worldId < 2000; private final Predicate<Integer> euOnlyPredicate = worldId -> worldId >= 2000; private void filterWorldList(final String regionCode) { Predicate<Integer> predicate = acceptAllPredicate; if (regionCode != null) { switch (regionCode) { case "us": { // NOI18N. predicate = usOnlyPredicate; } break; case "eu": { // NOI18N. predicate = euOnlyPredicate; } break; default: } } filteredWorldIds.setPredicate(predicate); } |
De même si on regarde de plus près, les mondes multilingue européens ont tous un identifiant qui commence par 20, celui des serveurs FR commence par 21, celui des serveurs DE commence par 22 et le serveur ES dispose d'un identifiant qui commence par 23. Il est donc assez facile de rajouter des filtres pour n'afficher par exemple que les mondes FR.
Voilà ce à quoi ressemble le programme :
Au lancement, la liste des mondes est chargée par avec la langue par défaut (le français) et le filtrage a lieu sur les serveurs EU. Lorsque vous sélectionnez une nouvelle langue, une nouvelle liste complète de mondes est à nouveau récupérée puisque c'est le seul moyen d'avoir les noms dans cette langue (sans mettre en œuvre une stratégie de cache/sauvegarde locale). Cependant, lorsque vous filtrez le résultat, aucune requête n'est effectuée sur le site web distant, tout le filtrage s'effectue en local sur la liste complète.
Code source
Vu que le code commence à devenir plus volumineux et que notre interface graphique est un peu plus complexe, je ne vais pas le lister ici, vous retrouverez l’exemple au complet dans l'archive ci-jointe : [ATTACH]174963d1/a/a/a" />
La version la plus récente du projet, au format NetBeans, est accessible sur GitHub.