Shader 15 ans plus tard

Ça fout une claque, mais la dernière fois que j’ai touché du HLSL c’était pour mon travail de fin d’étude en 2005 dans un projet que nous avions baptisé Animator 4D. Bon, ce n’est pas le sujet qui nous amène ici, nous allons plutôt « simplifier » l’usage basique du WEBGL dans un contexte de rendu 2D.

Je vous recommande le très bon site
https://webglfundamentals.org/ ! Basé sur ses articles, et d’autres, je vais vous présenter une simplification de compréhension.

Pour info : je travaille en TypeScript (TS).

Charger une texture

C’est relativement simple si vous avez déjà votre img et son event load prêt. En dedans on va ajouter ceci :

const maTex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, maTex);
// Let's assume all images are not a power of 2
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
// Loaded: so copy in the texture
gl.bindTexture(gl.TEXTURE_2D, maTex); // TODO why twice ?
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, monImgObjHTML);

On considérera que monImgObjHTML est votre HTMLImageElement chargé et la variable maTex un attribut de classe ou autre accessible.

Les shaders

En fait il vous en faut 2, un qui gère les vertex (vertex shader) et celui qui s’occupe de la couleur (fragment shader). Du coup, vous aurez besoin de vous faire 2 petites fonctions pour vous aider lors de l’init : une de chargement de shader et une de création de programme contenant vos shader. En résumé (détail sur le site de webglfundamentals) nous avons :

createShader(type: number, source: string): WebGLShader {
    const gl = this.context;

    // Create the shader
    const shader: WebGLShader = gl.createShader(type);

    // Set the source code
    gl.shaderSource(shader, source);

    // Compile the shader
    gl.compileShader(shader);

    // Check compilation status
    const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS);
    if (!success) {
      // Something went wrong during compilation; get the error
      throw new Error('could not compile shader: ' + gl.getShaderInfoLog(shader));
    }

    return shader;
  }

  createProgram(vertexShader: WebGLShader, fragmentShader: WebGLShader): WebGLProgram {
    const gl = this.context;

    // Create a program
    const program: WebGLProgram = gl.createProgram();

    // Attach the shaders
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);

    // Link the program
    gl.linkProgram(program);

    // Check if it linked
    const success = gl.getProgramParameter(program, gl.LINK_STATUS);
    if (!success) {
        // something went wrong with the link
        throw new Error('program filed to link: ' + gl.getProgramInfoLog (program));
    }

    return program;
  }

CreateShader va charger un texte plein (dans ma solution), ou ce que vous voulez, c’est bien foutu; et vous rendre un WebGLShader. Du coup, vous pouvez faire un WebGLProgram qui contiendra vos 2 shaders. Hop le tour est joué.

Évidemment cela demande une fonction d’init’ dans laquelle vous allez préciser les variables de vos shaders et appelez vos 2 fonctions.

maFonctionInit() {
  const vertexShader: WebGLShader = this.createShader(gl.VERTEX_SHADER, vertexShaderTxtCode);
  const fragmentShader: WebGLShader = this.createShader(gl.FRAGMENT_SHADER, fragmentShaderTxtCode);

  // Create program
  const program = createProgram(vertexShader, fragmentShader);

  // Buffers
  const colorLocation = gl.getUniformLocation(program, 'u_color');

  ...
}

Bon du coup on est initialisé, et on suppose que le workflow est bon, donc on peut dessiner 🙂 / faire son rendu !

Le rendu

render() {
  gl.bindTexture(gl.TEXTURE_2D, maTex);
  gl.enable(gl.BLEND);
  gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);

  // Tell WebGL to use our shader program pair
  gl.useProgram(program);
  gl.uniform3fv(colorLocation, new Float32Array([0.0, 0.0, 1.0]));

  ...
}

On lie notre texture, on utilise notre programme et on envoie une valeur.

Bonus je vous ai indiqué pour le support de la transparence (genre votre texture en PNG avec transparence). Pensez au premultipliedAlpha: false pour votre canvas !

Pour la fin de la fonction, webglfundamentals vous donnera toutes les infos nécessaires, y compris des librairies de matrices m3/m4 adaptées et bien foutue.

Vous voilà orienté dans une des directions possible pour atteindre votre objectif de rendu 2D en 3D :p.

L’obscurité naît de la lumière

Le rendu actuel est bien joli, mais ça manque d’effet de lumière et donc d’obscurité. Du coup j’ai tenté l’expérience, pleuré, cassé, et réussi à avoir un premier résultat.

D’abord c’est quoi cette notion de lumière/obscurité ? C’est tout simplement le fait d’avoir une image lumineuse ou assombrie sur base d’une source supposée de lumière et d’un calcul pour définir l’effet.

Une source ? N’importe quel Element peut être une source de lumière, j’ai créé un interface Illuminate pour qu’il se prenne pour un truc radioactif.

Un calcul ? J’ai déclaré,pour le test, que la lumière allait à 6 cases de distance en décroissant de 20% de manière arbitraire. Cependant j’ai prévu une interface (pas finie) dont le but sera de définir des paramètres pour l’éclairage, permettant ainsi des variables d’affichage (couleur, force, …).

Il s’agit donc de définir pour chaque Element de chaque Zones de tous les GridBlocks du subsetMap une valeur de « Brightness » (luminosité) sur base du calcul et des sources. Le tout sans faire de calculs inutiles.

J’ai opté pour le moment de la création du subsetMap pour agir. Ceci arrive uniquement lors d’un changement d’offset de la caméra (déplacement du héro en bord d’écran), on peut alors profiter de la boucle de création pour trouver les lumières de la Scene. Ensuite il faut parcourir à nouveau le subsetMap pour mettre le « Brightness » de tout le monde à 0 : le noir total. Oui deux fois, car nous agissons dans PlayerScene basé sur un IsoScene et la création du subsetMap est dans IsoScene.

Ensuite pour savoir ce que la lumière va affecter je me suis dit que le PathFinder ferait un bon système de propagation, respectant les murs et les objets. Ce n’est pas au point, et cette idée doit être poussée plus loin, mais le départ est pas mal. On imagine une source de 100% qui perd 20% à chaque changement de case, et on ajoute ceci à l’éclairage déjà présent.

La théorie est pas mal, on est en 2D isométrique quand même, on ne peut pas faire trop de folies et on est limité par l’architecture de TARS et des possibilités de « Canvas » (notre support HTML5 de dessin). En pratique : les performances s’effondre…

Mais que se passe-t-il ? Il faut savoir que pour avoir cet effet j’utilise le « filter » « brightness » de « Canvas », en gros une propriété au moment du dessin permettant d’affecter la luminosité de l’image que j’ajout pour composer la Scene. Je le fais déjà avec l’alpha sans soucis (cf titre daaboo). Bref, ça fonctionne mais prend un temps de dingue, ce qui rend le résultat non réactif et donne du mal au navigateur pour répondre.

Il n’y a pas d’autre façon, l’accès au données de l’image n’est permis qu’au niveau du rendu final et non à l’image que l’on va ajouter et on ne peut pas travailler sur l’image stockée en mémoire, d’une part car c’est pas permis et de l’autre car il nous faut l’original pour modifier les effets.

Une seule solution : la protestation ! À chanter comme une manif de métallo un jour de pont entre deux grèves…

Comment améliorer les performances ? Comment faire quand les outils manquent, les accès limités, les possibilités réduites… ? Il y a bien quelques solutions, mais l’idée n’est pas de demander plus de travail aux utilisateurs de TARS, enfin, le moins possible.

Je suis parti sur l’idée d’un cache, le soucis étant le temps de rendu, et on sait que les mises à jour ne sont que ponctuelles par comparaison. Mais comment ? Nous allons créer un « Canvas » par IsoElement (on va surement le descendre en SpriteElement par la suite (c’est fait :p) et on fait le rendu dans ce « Canvas » invisible. Au moment de composer la scène on copie le contenu à sa destination comme on le faisait avant, et on ne fait plus que ça jusqu’à ce que l’on doive régénérer le cache.

Pour vérifier que ça fonctionne bien avant de péter tous mes calculs, j’ai simulé un rendu dans mon cache, un carré rouge et au lieu de dessiner comme l’image ci-avant, on utilise le contenu du cache.

Je vous épargne le retour du bug de hauteur, le curseur qui fait bouger les Elements, les soucis liés à la division du calcul en 2 étapes, etc. On a déjà connu, déjà fixé, et j’ai dû le refaire encore une fois…

Une anecdote cependant, si vous ne dite pas à votre cache de se régénérer sur base des paramètres de votre Element (PlayerElement, TreasureElement, etc.), il restera tel quel depuis son premier rendu, en gros Squellettore ne marchait plus, il glissait, droit comme un pique sans changer d’orientation. Logique si vous avez lu. Du coup on doit penser à invalider le cache lors de nos mises à jour.

Vu qu’on parle d’animation, il faudra permettre de savoir quand une animation change afin de régénérer le cache à point nommé. Et hop un TODO de plus…

Une fois tout cela fait, on a un rendu avec des performances bien meilleures, cela demande un peu plus de manipulations car il faut penser au cache mais globalement c’est une nette différence.

Concernant le cache, certains automatismes pourront être mis en place en interne au lieu de demander à l’utilisateur de TARS d’y penser. Comme invalider le cache si vous changez d’orientation, l’alpha ou la luminosité. Ce qui est fait en même temps qu’écrire cet article :p et ajouter 3 TODOs… comme si j’en avais pas assez… ^^

Revenons à nos lumières. On a un rendu correct sur base du calcul déclaré plus avant, simple, efficace, même si pas fini (murs opposés, murs d’une case supérieure à la distance prévue, …). Du coup je décide d’en ajouter une seconde pour voir si le cumul fonctionne bien et c’est la fête du slip… Je n’ai pas fait de photo, mais en gros le point d’origine est assombri et génère de l’obscurité, les cases éclairées sont plus loin, bref ça ne va pas.

Mais pourquoi ? C’est une sacrée boucle à investiguer. Ce n’est pas la fonction d’action sur les Elements, ce n’est pas les préparatifs, ni les accès aux variables. C’est le propagateur (par extension le Pathfinder). En fait, par un malheureux hasard, il se peut que vous passiez plusieurs fois sur la même case au cours d’une boucle de propagation (ce qui peut arriver). Le truc c’est que je pensais que je les avais bien exclues, et c’est vrai, mais il faut aussi vérifier en préventif.

Mes tests montraient une quantité astronomique de calculs pour ma petite zone de 10×10, ce qui ne me semblait pas vraisemblable. C’est ce qui m’a mit la puce à l’oreille en gros. Un « If » de vérification et d’un coup la folie s’arrête, les lumières apparaissent et se cumulent proprement. En plus dans l’histoire on a augmenté les performances du Pathfinder et du propagateur pour les lumières !


Voici un résultat avec 2 lumières, le coffre statique et le héro mobile.

Il ne faut pas perdre de vue que ce n’est pas fini. Il y a un gros soucis de performance à la mise en place des lumières quand on s’occupe du subsetMap. Il faut considérer les lumières animées et s’occuper également des occlusions (ça serait top) et de l’orientations des objets, par exemple les murs, opposés ou de la case d’à côté nous tournant le dos. Là il va falloir creuser les théories les meilleures pour augmenter la qualité de l’effet sans défoncer les performances.

Une autre chose aussi, vu que nous travaillons en 2D isométrique, serait d’ombrer selon le caractère cubique de nos objets et de prévoir ainsi, soit une image South, une image East et pourquoi pas une image Top pour ombrer selon des calculs d’angle (les normales) et de distance. Le tout serait de fournir à ces objets des informations plus complète qu’actuellement.

Ceci dit, en l’état, c’est déjà chouette comme rendu !

Angular library – how to – snippet

Suite à mon article précédent j’ai souhaité investiguer, attiré par la curiosité maladive et la difficulté jusqu’à lors de compréhension de cette mécanique. Je vous liste ci dessous quelques articles qui m’ont guidé tant pour démarrer que pour me mélanger les pinceaux.

Pour résumer, si comme moi, vous êtes perdu voici un snippet de démarrage et une copie d’un conseil donné dans le dernier lien.

ng new tars --directory . --create-application=false
ng generate library tars-lib --prefix=tars
ng generate application tars-test

La dernière ligne est utile plus tard quand il faudra tester via une app angular la librairie que l’on souhaite publier.

À noter que mon but n’est pas de passer par NPM pour la publication ni d’aller sur GitHub pour distribuer mon code.

Le conseil du dernier article proposait les lignes ci-dessous dans votre package.json (le premier en root) dans l’espace des scripts.

    "build_lib": "ng build tars-lib",
    "npm_pack": "cd dist/tars-lib && npm pack",
    "package": "npm run build_lib && npm run npm_pack",

Ma prochaine étape est le déplacement du code TARS au sein de l’espace tars-lib créé et de refaire les liens vers le fichier public_api.ts. Ensuite de créer, comme actuellement, une app démo/test, sans inclure l’éditeur, juste une scène et un rendu fonctionnel, simple et expressif.

Une fois validé, je pense que l’on peut alors faire le build de la version de la librairie TARS et créé un nouveau projet dédié pour HeroQuest et là, voir si l’éditeur et la base du jeu prenne bien la lib’.

Réorientation des Sprites

Il y a un point gênant qui traînait depuis un moment du fait que ce monstre était difficile à cerner. Le problème est que chaque POC (preuve de concept) doit être intégré proprement une fois validé, mais pour certains d’entre eux vous ne voyez pas toujours la portée de ce vous venez de faire, les impactes et incidences, ça vous arrive souvent que quand vous y êtes confronté, ce qui a été mon cas.

Premièrement je dois vous informer que j’ai divisé Element en Element et SpriteElement, car si on pense 3D, Element tel qu’il était ne convenait pas, et il doit rester la base, du coup nous avions pas mal de contenu spécifique aux POC et monde 2D (pour le POC iso), donc en résumé j’ai sorti tout le spécifique 2D dans SpriteElement, sur lequel vient s’accrocher à son tour IsoElement qui l’étend plus encore. Element quant à lui est une identité multi-dimensionnel correspondant à la fois à la 2D et à la 3D, soit XYZ (z = précédemment elevationFactor en 2D), le delta de chaque pour les animations notamment et les interactions qui ne dépendent pas de la dimension.

SpriteElement va alors se compléter avec la gestion des spriteRefs, l’orientation (on va y revenir), l’alpha que j’ai ajouté en refondant le titre pour maximiser l’usage de la librairie TARS, le getSize, les positions d’interactions, des get/set Coord adaptés au monde 2D, ce qui concerne les animations, l’update et le draw du/des sprites (spriteRefs encore) du SpriteElement et le isTouch. Pensez 2D plate, pas encore iso, donc on a des méthodes que l’IsoElement va remplacer car il induit une notion de représentation que SpriteElement n’a pas encore. Ainsi on a sous forme d’étage une complétion structurelle cohérente.

Pour les curieux, IsoElement contient des méthodes comme le draw, isTouch et l’update comme cités avant mais remplacées, ainsi qu’une fonction de rotation, c’est tout. 🙂 Simple, basique, toussa.

Pour revenir à l’orientation, la problématique est simple, le POC se déroule en isométrique avec 4 directions, résumées sous la forme NESW (North, East, South, West), vous aurez compris 😉 (j’espère). Du coup, vu que nous traitons en interne des caractères ‘n’, ‘e’, ‘s’ et ‘w’, comment savoir qui est à l’est du nord de l’objet ? Comment connaître la coordonnée au nord de l’objet ? Au final 2 questions qui deviendront 5 réponses, on en reparle plus tard. En gros, pendant plusieurs mois, suite au développement du déplacement et de la réorientation, le code était dans l’actuel SpriteElement et anciennement dans Element.

Si maintenant je vous demande, avec TARS, de faire un jeu de plateforme 2D latéral ce que je viens de vous dire déconnera plein tube car vous avez un 2D isométrique en dur dans Element, hors à la base c’est l’idée, « être capable de tout ».

« Être capable de tout »

TARS

On a donc un soucis connu, le système d’orientation n’a pas sa place à cet endroit. On peut même imaginer un personnage se déplaçant en 8 directions sur un environnement isométrique en 4 directions, tout à fait réaliste. Donc on se trouve au niveau de Element comme dit, on sait qu’on est au bon endroit mais pas de la bonne manière, ni de manière flexible/variable.

Je vous passe la réflexion interminable, la solution est un service sur mesure, donc une Factory (une classe qui vous donne la bonne selon critère). J’ai donc crée SpriteFactoryService qui donne accès à SpriteOrientationSystem (lol) basé sur un critère simple : combien de direction veux-tu pour cet Element, que j’ai nommé SpriteOrientationType et vous donne accès à une liste du style SO0, SO2, SO4, etc. En gros, une image plate, une image plate qui va à gauche ou droite, une image ayant 4 directions quelque soit le système de rendu, 2D haut, 2D iso, etc. Le résultat nous donnera une classe SpriteOrientation0, SpriteOrientation4, etc.

On a donc l’actuel SpriteElement qui demande ce fameux service et selon sa configuration passée au type de l’Element, on a la DTD qui donne des infos générales et s’initialise. On peut désormais avoir un SpriteElement en 2 directions à côté d’un autre en 4 directions etc. C’est la même classe, avec un service sur mesure. Le tout est cadenassé par une interface bien sur (modèle à respecter), ce qui garanti que SpriteElement peut travailler avec n’importe quel système de SpriteOrientation.

Je vous ai épargné beaucoup de détails car au final c’est +17 fichiers qui ont été impactés, adaptés, corrigés et nettoyés. Une aventure difficile mais dont je suis content d’être sorti avec une certaine élégance dans le résultat.

Après ces développements et tentatives de diminuer les TODOs présents, de +40 on est passé à 31, ce qui permet de respirer un peu et de voir de l’avancement, même si rien de nouveau en visuel.

À propos de visuel, l’éditeur avait un peu avancé avant tout ça, voici une capture. En gros, le chargement de scène fonctionne, la suppression d’un Element aussi, la rotation selon molette de souris et si l’Element le permet, fonctionne. J’ai également ajouté la notion des Zones du gridBlock que vous voyez sous la forme BCTNESW avec le C de ‘center’ sélectionné et j’ai ajouté une sélection, que vous voyez en fuchsia pointillé, fonctionnelle mais ne rend pas encore la sélection sur base du filtre de Zones.

Évidemment, tout ceci a permis de découvrir des problèmes, d’amender certains points pour les rendre accessible ou carrément meilleur.

J’avais tenté une sélection multiple d’objets du catalogue pour préparer un pinceau dynamique mais la manière n’était pas optimale. L’idée sera plutôt d’avoir un onglet spécifique aux outils permettant en leur sein d’avoir des options à choisir, via des listes et autres. Par exemple, vu qu’on est un éditeur pour HeroQuest, on peut imaginer un outil « création de pièce » avec en option, « quel mur et sol voulez vous« , on peut sélectionner plusieurs sols et jouer sur un aléatoire de style et de rotation si coché par exemple. Je pense que c’est une meilleure approche.

Je vais déjà tenter de faire un ajout simple puis on verra les outils plus avancés. Quand je dis « tenter », le truc n’est pas compliqué, mais il faut penser à beaucoup de chose avant de se lancer et de devoir recommencer. Ce qui a donné tous ces développements depuis la dernières fois en gros, même si parfois très indirect.

L’idée futur proche reste la diminution des TODOs pour être stable et propre et l’ajout dans l’éditeur. Vu que nous sommes en DEV nous utilisons un dataProvider Fake (faux) qui renvoie des données brutes stockées localement en JSON (et non venant d’un serveur comme cela sera à terme), ce qui veut aussi dire que la partie sauvegarde du résultat de l’éditeur ne peut pas se faire. J’imagine passer par la console pour avoir une sortie texte brute à mettre moi-même dans un fichier local, ce n’est que temporaire et pour contrôler de toutes façons. On sait ce que temporaire signifie…

On est de plus en plus proche d’un résultat exploitable pour commencer le POC du jeu HeroQuest comme dit, il faudra penser la mécanique manquante des actions au sein d’une Scene déplaçant le joueur d’une Scene à une autre, maîtriser d’un point de vue macro l’ensemble du processus de jeu au delà des limites des Scenes. Typiquement le joueur arrive en début de donjon et doit aller à la fin, le fait de toucher la case ou d’interagir avec le fait quitter le donjon pour aller en zone neutre avant d’attaquer un nouveau donjon. Le tout sans perdre l’état du joueur, ses découvertes et équipements.

Et là je n’ai pas encore parlé de la problématique organisationnelle que ceci est un POC dans un POC, la librairie TARS se doit d’être séparée dans un module spécifique, versionné et récupérable au travers d’un projet NPM propre. Ce qui n’a pas l’air évident du tout.

Que ça soit en mode « player » ou en « editor » on a encore du boulot. 🙂

L’antithèse d’Oppenheimer

Ça y est, l’éditeur a été débuté ! Il s’agit donc d’un outil propre au projet HeroQuest, car un éditeur est spécifique, du moins pour le moment. Nous ne gérons que des IsoScenes au travers d’un IsoEditor.

L’interface est pour le moment très rudimentaire mais fonctionnelle (sauf le bouton « nouveau » à côté du titre). Une zone centrale de vue et de travail, un menu supérieur barre d’outils, un pied qui sera je pense une liste d’indicateurs et enfin la partie latérale à droite, un systèmes d’onglets.

Ce dernier contient actuellement 2 choses : une liste des scènes disponibles et la liste des éléments à mettre sur celle-ci. Un double clic sur un Element transforme votre curseur en l’objet et vous permettra de le placer.

Évidemment il vous vient directement à l’esprit qu’il y a plusieurs zones et donc un simple clic ne suffira pas, il faudra définir la zone impactée. Là j’imagine dans la barre d’outil, à l’image des alignements dans Photoshop, un moyen de préciser la zone via des icônes. On peut toujours aller plus loin car l’objet affiché, un mur par exemple est affiché au nord par défaut, mais est-ce que le sens du mur affecte la zone d’impacte ou est-ce que la zone sélectionnée affecte l’orientation. Des comme ça on en aura surement un paquet et il faudra être bien cohérent sur la définition de l’expérience utilisateur, heureusement à force d’usage je verrais vite ce qui fonctionne le mieux.

Prochaine étape donc, ce placement sur la map. Il faut s’avoir que le composant Editor doit discuter avec l’IsoEditor, ils travaillent de concert et doivent chacun gérer leur histoire, et à moi de ne pas tout mélanger ou mal placer. À venir par exemple il faudra que je divise les onglets en composant pour respecter les idées Angular (mieux que tout dans un seul ^^).

Il faudra également penser au chargement/sauvegarde (fake), déplacement de ce qui est sur la carte, sélection d’un Element et modification de ses propriétés (comme l’élévation) et même accéder au GridBlock. Enfin, ce n’est que le haut de l’iceberg actuel comme toujours :).

Squellettore mon Héro

Le caddie ayant bien roulé, tellement qu’il en a les roues carrées, j’ai décidé de le remplacer par un héro ayant un peu plus de capacité d’animation. C’est tout naturellement que j’ai pensé à mon Héro de toujours : Squellettore.

Faisant directement suite au coffre, il m’aura juste fallu l’encoder dans la bibliothèque et ajouter 2 lignes de code pour activer les 2 animations.

Le coffre de la tourmente

Comme vous l’aurez compris j’ai ajouté un coffre au trésor dans le set d’objet actuellement disponible. Mais pourquoi ?

  1. Nous n’avons rien qui teste le système d’animation depuis qu’il a été créé, et le « target » en vague bleue était l’animation principale.
  2. Rien n’a été mis en place pour le changement d’animation.
  3. Un problème de boucle se soulève en imaginant l’ouverture du coffre, actuellement toutes les animations sont bouclées.

Mais d’abord il faut le dessiner, dans les 4 directions, l’animer, ce qui est une autre sinécure et enfin en faire des Sprites NESW. Là il n’y a pas d’outils actuellement dans mes mains, rien de facile, gratuit pour les POC. Cependant j’ai trouvé un script (Layer2SpriteSheet) un peu bancale mais qui m’a bien aidé. C’est pour Adobe Photoshop et ça utilise vos calques pour générer votre Sprite final.

Et voilà un coffre que je vous anime ci-dessus. Ça c’était la partie facile, même si ça m’a déjà pris quelques heures de chipotages. Maintenant il faut l’intégrer au POC HeroQuest/TARS. Donc comme d’habitude on manipule la bibliothèque en précisant les images NESW, les 4 animations disponibles et les dimensions des images, on édite la liste des Elements disponibles, on ajoute une DTD (définition) pour ce qu’est un « Treasure », donc la coordonnée d’interaction, les interactions disponible comme pour la porte et pas de référence de Sprite spécifique, celui par défaut convient très bien.

Jusque là c’est nickel, j’ai juste dû corriger quelques typos dans mes modifications et ça roule, mais comme on a un objet avec lequel on peut interagir et même si c’est IsoPlayer qui fait la réaction des interactions pour le moment, il va nous falloir un objet spécifique : TreasureElement. En gros c’est la copie d’une porte pour la partie interaction et on retire la gestion multi-sprite au profit de l’usage du sprite par défaut.

Pour rappel, le Sprite par défaut, est l’image ou la somme d’image composant l’ensemble des possibilités d’animations de l’objet. Avoir plusieurs Sprites au sein d’un élément est une construction dynamique, une volonté spécifique pour obtenir un résultat dynamique qu’un Sprite ne peut offrir.

Nous voilà donc avec notre classe TreasureElement à laquelle on va déplacer la gestion des interactions en son sein au lieu de IsoPlayer, c’est quand même IsoPlayer qui l’appelle, mais la partie spécifique est du côté spécifique, meilleure séparation et clarté du code. Nous avons donc une méthode « doInteraction », comme dans IsoPlayer, avec les mêmes paramètres etc. qui va nous permettre de faire les tests de cas en fonction du verbe reçu.

C’est bien joli, mais l’animation dans tout ça ? On y arrive enfin, on a 4 animations : ouvert, fermé, s’ouvre, se ferme; et voici le schéma qu’on aimerait mettre en place :

  1. Le coffre s’initialise avec l’état fermé et l’animation « fermé ».
  2. Je clique pour interagir avec le coffre et choisi de l’ouvrir : on lance l’animation « s’ouvre » et on aimerait qu’après ça passe en « ouvert » et que ça reste ainsi.
  3. Je clique pour interagir avec le coffre et choisi de le fermer : on lance l’animation « se ferme » et on aimerait qu’après ça passe en « fermé » et que ça reste ainsi.

Je ne revient pas sur le fonctionnement de l’interaction, il vous suffit de lire l’article précédent. Donc nous en sommes à recevoir l’action « ouvrir » et on souhaite changer l’animation courante. BIIIIP faux départ ! Cette fonction n’existe pas.

Premier problème rencontré, rien n’a été prévu pour changer l’animation. Je vous passe toutes les réflexions, j’ai ajouté dans Element une fonction setAnimation dont le but est de manipuler la collection des animations et mettre les paramètres dans les bonnes conditions (nom de l’animation, timing, frame courante).

J’ajoute directement ici qu’après réflexions sur les améliorations utiles, le fait de pouvoir attendre la fin d’une animation est très intéressante. Que ce soit d’être prévenu tout simplement par un événement auquel on peut s’accrocher, mais également en interne, initier le changement d’animation en attendant la fin de la précédente, on crée alors en interne l’attente de l’événement et on change alors les mêmes paramètres comme dit avant.

Maintenant que la possibilité existe on peut demander notre changement d’animation suite à l’interaction. Il nous reste à utiliser également l’événement de fin d’animation pour appliquer le changement d’animation vers les « animations » fixes « ouvert » ou « fermé ».

À cela, j’ajouterai que j’ai prévu un paramètre d’animation « loop » qu’il me reste à implémenter pour définir qu’une animation ne boucle pas forcément. On arrêterait ainsi l’animation avec un « flag » (drapeau d’état) et ainsi ne pas boucler sur la dernière image et envoyer X alertes d’événement de fin.

Pour simplifier, parmi les 4 animations citées, nous pourrions supprimer « ouvert » et modifier l’animation « s’ouvre » avec le « flag » « loop » à « false » (non), elle resterait ainsi sur sa dernière image, sans chercher à poursuivre et nous n’aurions qu’une seule fois l’alerte de fin d’animation. Ceci nous économise une définition d’animation et plus de facilités pour l’avenir.

Petite anecdote, j’avais défini tous mes changements d’animation en demandant d’attendre que l’animation courante se termine avant de changer, mais du coup mon coffre s’ouvrait ou se fermait 2 fois avant de rester dans son état ouvert ou fermé. Mais pourquoi ? En fait, en faisant ça, au moment où l’animation se termine, l’événement nous alerte et on lui demande de changer d’animation quand elle sera finie, mais l’événement vient de passer, du coup il relance l’animation une seconde fois. Vu que nous sommes déjà dans l’état de fin, il ne fallait donc pas utiliser « l’attente de fin » encore une fois. Ça vous évitera beaucoup de recherches inutiles.

Tout ceci commença une dizaine d’heures plus tôt en dessinant un personnage pour remplacer notre ami le caddie dans le même objectif de manipuler les animations. Ce que je vais faire suite à ceci ainsi que coder le paramètre « loop ».

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. 🙂