Intergalacticulaire

C’est l’histoire d’un clic, qui n’avait rien de particulier, mais était destiné à de grandes choses. Il ne le savait pas encore, mais il allait bientôt vivre un grand moment et constater les conséquences de ses actes.

C’est l’histoire d’un clic, droit comme un pique, sur une porte, droite elle aussi, qui pouvait en un même instant faire bouger un caddie, un peu bancale lui, et demander une interaction qui elle allait dans tous les sens.

Cette histoire chers amis, c’est une conclusion, en terme de preuve de concept (POC), qui dure maintenant depuis plusieurs semaines. On y est enfin, la boucle est bouclée et voici l’explication finale.

Pour vous donner cette explication je vais procéder de manière chronologique en séquences. Ceci dit, je ne vais pas me priver de quelques détails. À cela j’ajoute que j’ai opté pour la solution HTML du menu et que ça nous a réservé quelques surprises.

Tout commence par un clic droit, notre isoPlayer (de type isoScene) reçoit l’événement, on détecte le bouton droit et on tente de déterminer sur quoi on a cliqué (le fameux Raycasting). On trouve un Element (ou rien et ça s’arrête là), on lui demande ses coordonnées (oulala…), on dit au Hero que c’est en fonction de cet Element qu’il va s’orienter, puis on attaque la partie déplacement en fonction des coordonnées reçues. On termine par préparer une surveillance de quand il arrivera à destination.

Excepté si le Hero est déjà sur place, nous patientons un instant et on nous rappelleras quand il y sera. La fonction de surveillance sera appelée et dedans, tout simplement, on informe les intéressés, qu’un événement de menu d’interactions a été déclenché, en lui passant les informations recueillies de notre Element cible : coordonnées du bloc à l’écran et la liste des interactions disponibles.

Nous venons de parler des intéressés, c’est à dire ceux qui se sont abonnés à l’événement du menu d’interactions. Petite modification dans notre composant Player (celui qui englobe le rendu et le point de départ de Game), nous avons créé et ajouté un composant InteractionsMenu, dont le but est d’afficher une liste de verbes et de prévenir quand on a cliqué dessus. Notre composant Player s’est abonné et va donc recevoir l’événement dont on a parlé fin du paragraphe précédent.

Du coup que se passe-t-il ensuite ? IsoPlayer est prévenu et peut à son tour préparer une variable à destination du composant InteractionsMenu, qui à son tour va pouvoir s’afficher selon les coordonnées écrans avec la liste données. Jusque là, tout est comme annoncé.

Petit commentaire sur la liste des verbes, bien sur il y a 2 verbes pour une porte, « open » et « close » (actuellement dans notre POC), mais selon l’état actuel de la porte le menu n’affiche que ce qui est disponible.

Vient ensuite le choix du menu, ici il n’y en a plus qu’un vu le filtre cité juste avant. Le composant InteractionsMenu a une sortie sous forme d’événement à son tour. Vous cliquez, il informe ses abonnés, dans le cas présent : le composant Player, qui à son tour, quand déclenché, masquera le menu et demandera à IsoPlayer de gérer l’action.

Pour ce POC, et vu que nous n’avons qu’une porte, la gestion se fait au cas par cas et selon l’état. Donc, de quel type est l’Element ciblé, et en fonction du verbe reçus on contacte la bonne méthode pour lui demander de changer d’état. En interne, c’est à lui de se gérer et mettre sa liste d’interactions à jour.

Nous voilà donc avec un boucle bouclée, un clic est à l’origine du ciblage et nous affiche un menu d’interactions disponibles, on choisi et c’est reparti, on remonte la chaîne pour agir sur l’Element.

Ceci est bien sûr un premier jet, fonctionnel certes, du côté implémentation de TARS (usage de la librairie), mais on peut se poser quelques questions et y voir déjà des choses à régler ou à faire évoluer. Enfin, ceci sera surtout vu au cas par cas tout au long de notre chemin pour nous rapprocher du projet HeroQuest.

Mais du coup, maintenant que ce grand challenge a été relevé, c’est quoi le suivant ? Bonne question, mais je pense que l’éditeur reste la suite logique. C’est à dire, l’édition de la carte, une gestion différente de la souris et de la caméra, un menu interactif pour la gestion du catalogue et interagir avec un Element sélectionné. Il me reste à découper ça en phase et à trouver par où attaquer ^^.

Ours à poil, sens du détail

Hier encore la détection se faisait à l’aide de « bounds », de zones définies par rapport au Sprite pour définir ce qui est susceptible d’être le contenu de ce qui ne l’est pas. Nous avions un arbre, un sol, des murs, jusque là c’était évident, 5 à 6 points suffisent pour les définir grosso-modo. Mais, depuis nous nous sommes mis en tête de mettre une porte, qui plus est ouverte, et là, c’est un besoin de 5 zones et ce par direction, soit 20 repérages pour un seul objet et encore, sans animation.

Vous l’aurez compris, le but n’est pas de se compliquer la vie, alors développons une solution : la détection par transparence de l’objet.

Nos images sont des PNG, donc un format préservant la transparence, une fois dans un « canvas » (l’objet HTML), nous pouvons demander à celui-ci de nous donner la valeur d’un pixel. Il nous suffit, dans notre méthode de détection existante, au lieu d’utiliser les « bounds », mais en gardant tous les calculs préalables, d’utiliser notre « canvas » afin de tester la valeur du pixel sous notre souris.

Je schématise bien sur, cela demande un environnement vierge, une remise à zéro des valeurs pour comparaison correcte, le dessin de l’image, la capture du point, son analyse, enfin bref, 4-5 lignes et c’est joué.

Pour vous ça revient à tester si la souris touche du rose (qui sera la transparence) ou l’objet, ici notre porte. Ce qui nous rappelle les belles années des jeux « point & click », comme Monkey Island, Loom, Sam & Max etc, basé sur ce genre de sprite en fond rose ou vert une fois décortiqué.

Ceci dit, ça n’a pas été aussi simple, il a fallu modifier les interfaces de configuration des animations, détecter si on veut travailler en Alpha ou en Bounds, rendre ça « sexy » et pratique.

Ce qui nous a également amené à découvrir des erreurs planquées, car ce sont des cas qui n’ont pas encore été testés. Il s’agit de notre porte, ben tiens, encore elle. Elle est définie par 2 Sprites et n’en affiche qu’un à la fois selon son état. MAIS le calcul de taille est basé sur la somme des 2 et la détection les parcoure également. Imaginez une seconde le bordel quand vous ne voyez la porte que normalement, alors que le système manipule tout à fait autre chose.

Après de longues recherches, j’ai introduit la notion de Structure, permettant à un Element défini par X Sprites (un tronc et un feuillage), de les manipuler sur conditions (3 blocs de tronc puis le feuillage) et ainsi de travailler sur une base connue et manipulable (par une classe d’extension). Ainsi notre porte répond à la question « Quelle est ta structure ? » et les méthodes en ayant besoin ne sont plus en erreur, et correspondent à vos attentes.

Maintenant que nous avons la détection sur la porte, et les « verbes », que j’ai ajouté dans la foulée. Nous allons pouvoir « demander » à l’objet trouvé, non plus sa nature comme vu précédemment, mais les interactions possibles ! À nous de les afficher comme bon nous semble et de définir comment nous allons gérer ces interactions.

Mouse to be alive

Tel un tube disco et des déhanchés sur le « dance floor », c’est l’euphorie du clic à présent ! La séparation clic gauche pour le déplacement et le droit pour l’interaction (avec déplacement si besoin et possible) est fait, et ce n’était pas une sinécure !

Dis comme ça, vu qu’on avait déjà les 2 fonctionnalités, rien de neuf sous le soleil mais cela a soulevé des questions, qui ont soulevées la mousse des rochers du début de projet et créé un vrai bordel et remises en question.

Nous en revenons au Raycasting (détecter un objet quand on clique dessus), car désormais nous avons un GridBlock à gérer et ses 7 zones. Nous ne parlerons pas du besoin de séparer le côté spécifique des 7 zones avec un GridBlock générique et dont les IsoScene devront prendre la gestion à l’usage, c’est connu et ça attendra une prochaine version une fois que la poussière du champs de bataille sera retombée.

Le cycle est le suivant :

  • Cliquez (quel bouton ?) droit sur un arbre ou un mur.
  • L’IsoPlayer prend l’événement, détecte le clic droit et demande quel est l’élément « Raycasté » (on dira ça ainsi, ou touché par le lancé du rayon sacré venant de la sainte souris…).
  • Celui-ci demande alors au service Iso de s’en occuper et plus vite que ça !
  • Le service parcours le subsetMap, et demande à chaque GridBlock de faire de même pour les Elements.
  • Du coup le GridBlock demande à ses zones de consulter chaque Element et de voir s’il y a contact.
  • Oui / Non : l’histoire nous le dira !

Première remarque, la manière dont on parcours le subsetMap, on partait de la coordonnées du joueurs et on recalculait ses dimensions pour faire une boucle, je simplifie, mais pas bien du tout ! Après 2 efforts, et surtout l’idée que la grille n’est pas carrée mais un losange, on retiendra que pour chaque colonne et ligne de cette grille on cherche le minimum et maximum afin de la parcourir efficacement et sans artifice inutile.

Notez également qu’un tableau ne peut avoir d’index négatif, ceux-ci seront considérés comme des propriétés (‘-‘ étant un caractère au final) et du coup un forEach, length, etc. ne sont d’aucune aide, privilégiez le « for … in » alors. Ceci dit, nous avons décidé de ne pas les gérer, ce qui permettra une forme d’optimisation.

Ensuite, nous avons le niveau de GridBlock qui doit orchestrer le parcours des zones dans le sens inverse de l’ordre de dessins pour tester le Raycasting. Encore une fois, il faut faire attention à ne pas se répéter inutilement et surtout mettre le bon code au bon endroit, GridBlock ne doit que gérer ses zones. Ainsi, après un petit « refactoring » (refonte), on a une fonction de calcul de hauteur d’une zone (car le Raycasting travaille à l’envers du dessin), une fonction de détection par zone (en demandant à chaque Element si ça touche :p) et la fonction d’orchestration principale pour gérer l’ordre des zones et l’ensemble de l’opération. C’est ce qui m’a pris du temps à rendre joli.

Un code laid n’est pas un bon code

À cet instant, chaque zone est bien ordonnée, testée et aucun code n’est dupliqué. C’est élégant, fonctionnel (surtout) et avec cette refonte, un horizon de généricité se dessine.

La méthode a été reportée sur la fonction de dessin qui souffrait des mêmes manques. On peut donc imaginer avec plus de facilité sa prochaine version, son évolution, comme dit en début d’article.

Après une, deux, semaines, nous revoilà au début de notre schéma pour obtenir l’interaction, point 1) le clic sur l’objet concerné.

Notre but reste un clic sur la porte, faire apparaître un menu quelconque et modifier son état ouvert/fermé. À partir de là on ouvre beaucoup de possibilités à explorer et en découvrir les limites, à repousser toujours plus loin.

En parlant de menu, une question se pose, profiter du HTML à notre disposition ou définir une interface dessinée. La première existe déjà et aura ses avantages / inconvénients, la seconde est a créer de toutes pièces et permet donc une maîtrise complète (événements, animation, éléments inexistant en HTML, etc.) mais demandera beaucoup plus de temps à concevoir.

Enfin, avant de penser à ça, nous allons devoir définir ce qu’est une interaction, lesquelles sont possibles, comment les récupérer et les définir. Un beau challenge encore. 🙂

Je suis Traversable

Je suis … Traversable !!! Comme un fantôme ! Ou du moins comme une porte :). Exactement, une porte, ce sujet qui nous tracasse depuis qu’on a des murs.

Mais qu’est-ce qu’une porte ? C’est un Element, qui se situe au niveau d’une zone de mur et à qui le pathfinder doit poser la question si oui ou non on peut passer ce « mur ». Il doit donc demander s’il est Traversable.

Côté technique il y a 2 aspects : étendre Element au travers d’une classe Door et définir une interface Traversable ainsi qu’une astuce typescript, une fonction dédiée instanceOfTraversable (car nativement on ne peut tester si une classe étend ou non une interface).

Ne pas oublier que pour ce faire nous avons également ajouté les images, les définitions dans les fichiers de ressources et modifié le générateur de map pour notre POC comme précédemment, petit rappel.

Pour les codeurs qui chercheraient de l’aide sur le sujet des interfaces voici comment s’y prendre :

export interface Traversable {
  isTraversable(): boolean;
}

export function instanceOfTraversable(object: any): object is Traversable {
  return 'isTraversable' in object;
}

Ainsi notre DoorElement basé sur Element nous permet d’utiliser cette interface et de répondre à la question. Notez que la question n’est pas posée par le pathfinder lui-même mais par la fonction de validation qu’on lui donne. Ceci reste donc côté implémentation comme désiré.

Comme vous pouvez le remarquer on a bien notre porte… enfin 2 !

Mais qu’est-ce qui a bien pu se passer ? Ben en fait c’est tout con, première règle élémentaire de la Robotique de notre moteur TARS, tout est empilé ! Et quand Element reçoit des spritesRefs multiples, il stack naturellement, jusqu’à ce qu’on surcharge sa classe et sa méthode draw() pour changer tout ça :). Et c’était prévu, mais, car il y a un mais, il y avait moyen d’améliorer l’accès à la fonction de dessin individuel.

Dans notre cas nous avons 2 sprites chargé pour un DoorElement, ouvert et fermé (ah ben bravo !). Nous ajoutons un statut à la porte, on en profite pour faire une interface surchargeant SceneElement pour les config et un « enum » pour structurer Open et Closed comme valeur possible du statut de la porte, le tout rendant l’objet porte bien structuré quand on veut en ajouter une.

Enfin, suivant le statut, un seul Sprite sera affiché et grâce à la fonction drawOne() qu’on a ajouté, le tout est rendu plus simple.

Actuellement il n’y a pas encore d’interaction, donc la vidéo ci-dessus est illustrée par 2 compilations distinctes.

Ce qui nous amène à une observation, l’eau ça mouille… et les arbres cachent la case derrière eux (en NE), du coup le raycasting actuellement utilisé au déplacement de la souris, ne permet pas aisément de sélectionner la case visée, il faut aller sur le bord de l’arbre sur 1-2 pixels.

Après réflexion, vu qu’on tend à implémenter les interactions, à revoir cette gestion de la souris. Ceci vous étonnera surement, mais elle a plusieurs boutons, la souris. On pourrait donc en utiliser un pour se déplacer, sans raycasting, juste un survol des cases, et un autre pour tenter d’interagir, si la cible le permet.

Il y aurait également la réflexion sur le curseur qui serait caché, car son rendu se fait au moment de sa case, et si un mur ou un arbre le masque, vous ne savez pas vraiment où il est si vous n’avez pas suivi. On pourrait imaginer un postDraw (un rendu d’après), permettant alors de se redessiner après tout, sous une forme allégée, comme un contour en transparence ou surbrillance. Il faudra également savoir qui a besoin de ce postDraw, pour éviter de parcourir à nouveau l’ensemble du subsetMap. Soit par préenregistrement, au moment de l’init du subset par exemple vu qu’on parcours la map, ou au moment du rendu vu qu’on passe dessus également. C’est peut-être cette dernière piste que je regarderais.

Les mexicains vont pas aimer

Des murs !!! Ça y est, le système affiche proprement les murs définit, actuellement définit en code pour les besoins du POC (test). Vous voyez ici, dans l’ordre haut gauche vers bas droite, un N et un E, puis un S, ensuite un W et E, et enfin encore un W. Ainsi l’on constate que tout se met bien même côte à côte (EW).

Ça c’était la partie facile, au final, vu que tout était prêt avec GridBlock. La difficulté à présent est de modifier le pathfinder pour qu’il tienne compte des murs.

Premier constat, initialement nous tenons compte de l’élévation entre blocs de manière claire au sein du code, ce qui va à l’encontre d’une idée générique (entre l’idée de la librairie pathfinder et son usage en fonction du projet). Ceci dit c’était essentiel pour les POC précédents. C’est donc notre point d’entrée, il faut un moyen d’accepter ou non le fait de passer d’une case à une autre.

On retire donc tout ce qui touche à l’élévation et hauteur enregistrée jusque là, on crée une fonction que l’on envoie au pathfinder pour sa résolution, ainsi il ne connait rien de votre projet et cela vous permet de définir les règles d’acceptation, on le nommera le Validator. Pour un premier essai que tout continue de fonctionner comme avant, il renvoie toujours oui, et ça fonctionne, cette mécanique est prometteuse.

Ensuite vient le fait d’avoir connaissance des murs, car jusque là, tout ce que le préparateur de la grille du pathfinder sait c’est une coordonnée (x, y, vu qu’on a retiré l’élévation). Mais dans notre Validator nous n’avons aucun accès à la Map ou au subsetMap, nous avons juste la structure de coordonnées du pathfinder (précision importante ici).

Il faudra donc ajouter le GridBlock consulté lors de la préparation de la grille à la structure de coordonnée du pathfinder, pour garder un accès et le consulter plus tard au moment de la résolution du chemin et donc de la validation (passage d’une case à une autre) et modifier ci – et – là le fait d’utiliser cette structure au lieu de simple coordonées [x, y].

Ceci étant fait, nous avons donc un pathfinder, qui a une grille avec accès à la Map par référence, et une méthode de validation de passage le rendant générique. Il ne nous reste plus qu’à écrire ces fameuses règles autorisant ou non le passage d’une case à une autre, notre soucis : les murs.

Nouvelle question comment savoir quel murs comparer sur une case et sur l’autre ? Car oui, comme dans l’exemple initial en début d’article, on a deux murs collés EW, chaque sur leur case, il faut tenir compte des deux. Et ce n’est pas tout, comment savoir que c’est le N et le S qu’il faut comparer alors que l’on reçoit juste 2 coordonnées en entrée du Validator.

Nouveau défit, nouvelles idées. Nous avons au sein d’Element une fonction permettant de définir l’orientation. C’est grâce à cela que le caddie se tourne suivant son chemin. En divisant cette fonction en deux on a le getDirection() qui nous manquait. Dès lors la théorie à appliquer pour ce premier POC est assez simple, suivant l’orientation NESW, on compare les 2 murs des 2 cases, par exemple je vais vers le N, je compare mon mur N d’origine et le S de destination, et ainsi de suite selon toute logique :).

Et ça fonctionne !

Maintenant des portes et des interactions, car un mur avec une pancarte par exemple, sera lisible (interaction) mais : de quel côté ? condition(s) de mur mitoyen ? Etc. Beaucoup de questions qui vont demander de modifier les fonctions de l’IsoPlayer et du PlayerElement car, au final, ce ne sont que des détections ^^…

La grille, ultime frontière

Première étape cruciale dans l’évolution du système vers l’objectif HeroQuest : la grille (cf Tron Legacy ^^). L’évolution permettant l’ajout de murs et la gestion de toutes les conséquences induites, comme le pathfinder, le système de rendu, le raycasting, etc.

La solution est qu’une coordonnée de la grille, qui était un tableau d’Elements, devienne un GridBlock et c’est lui qui contient les Elements dans un de ses espaces. En gros GridBlock est lui même un tableau d’Element, un multi-tableau d’Element même, youhou bienvenue dans la 4ème dimension de la grille !

Évidemment ce n’est pas aussi basique ni aussi simple. GridBlock est volontairement une classe et non un simple tableau de plus. Ceci nous permet de prendre la partie du rendu globales des zones dans un ordre logique, de donner sur demande le contenu d’une zone et de prendre de son côté les fonctions d’insert et de retrait d’Element. Donc on rassemble ce qui peut l’être au lieu d’être au sein des méthodes ci-et-là.

On parle de zones, mais qu’est-ce donc ? En gros, nous voulons améliorer la gestion de l’empilement, et avec HeroQuest nous avons besoin de murs et portes, et pourquoi pas plus tard des indications au dessus des objets ou du héro. L’objet GridBlock a donc pour vocation de gérer des espaces virtuels pour faciliter le montage finale de la coordonnées. Il y a NESW, Center (centre), Top (dessus) et Bottom (dessous), ce qui correspond à un Ground en Bottom, un personnage ou arbre en Center, les murs et portes en NESW, etc.

Ensuite, il y a l’usage, car, dans un premier temps, j’avais tout mis dans la zone centrale, et fait en sorte que les méthodes liées utilisent la zone centrale par défaut. Ainsi, étape par étape, ça continue de fonctionner et ça permet d’isoler chaque étape de modifications.

En premier j’ai divisé la méthode qui construit actuellement la carte dynamiquement pour le POC, une grille de 10×10 qui ajoute aléatoirement des arbres. Et j’ai donc poussé le héro en zone centrale avec les arbres et le sol dans la zone du dessous. Rien de compliqué là dedans. Mais du coup le système de rendu n’affichait plus le sol (vu que c’est central par défaut).

Comme dit plus haut, l’avantage d’avoir GridBlock en tant qu’objet et non que comme simple conteneur, j’ai pu lui écrire une méthode draw() pour orchestrer le rendu par zone et donc déplacer le code initialement dans Iso vers GridBlock et ce pour chaque zone dans l’ordre désiré. Le tout en gardant les notions Z importante pour l’empilement. « Les » car Top se base sur Center, NESW et Center sur Bottom.

On a donc un caddie sur sol avec des arbres. Retour case départ, mais le caddie ne bouge plus.

C’est normal, le pathFinder se base sur un objet de type Ground et il cherche dans la zone centrale alors qu’on a déplacé dans bottom. Jusque là c’est simple de lui dire quoi faire pour corriger ça, mais est-ce au service de préparation de la grille du pathFinder de se tracasser de ça ? Non ! J’ai donc remplacé excludedTypes (types d’Element exclus du calcul) par une fonction que l’appelant implémente avec sa logique de tri.

Ainsi, un coup je peu être un personnage marchant sur des zones centrales inoccupée, autant une autre fois je peux être un oiseau parcourant les zones top. C’est bête mais important ! Du coup on a une fonction plus générique, une séparation logique propre et un résultat fonctionnel. Enfin, quand on oublie pas que le caddie occupe une place centrale et donc invalide sa position et l’empêche de bouger ^^ Bug connu précédemment revenu suite au changement de façons de procéder, excludedTypes était la solution précédente, on lui envoyait TreeElement à exclure ce qui incluait donc le PlayerElement, CQFD. Et grâce au fait que maintenant c’est géré côté appelant, donc dans IsoPlayer, il peut clairement indiquer ce qu’il veut ou non, et exclure de manière forte notre fameux PlayerElement.

Dernier détail, ce qui soulève en fait une optimisation, c’est le raycasting, donc le fait de trouver sur la carte ce que l’on survole avec le curseur de la souris. Le but est de déterminer la coordonnée survolée, et si un objet est sur la zone (et qu’il peut avoir une forme variable et donc un contour) le raycasting détermine si on est dessus ou sur une autre coordonnée, en gros.

Mais maintenant nous avons plusieurs zones, donc un travail plus long. Il faut donc prendre les zones peuplées et ne pas considérer Bottom. On simplifie tout en complexifiant le travail ^^.

En résumé, on a amélioré le système de grille avec une gestion spatiale structurée, adapté le système de rendu, de pathfinding et de raycasting, rassemblé du code et éclairci d’autres. Tout fonctionne à nouveau comme si de rien n’était mais nous ouvre à présent de nouvelles portes pour les objectifs désirés.