REST API avec Node/Express + OpenAPI

Pas de révolution ici, c’est quelque chose somme toute d’assez classique, un back-end NodeJs en Express et une doc Swagger basé sur un OpenAPI. Le truc étant de faire le routage et la doc en un seul point, améliorant la maintenabilité et automatisant le routage.

Pour ce faire il suffit de démarrer un nouveau projet et d’installer quelques librairies de base :

npm init
npm i express --save
npm i swagger-parser --save
npm i cors --save
npm i swagger-routes-express --save
  • Le framework express comme base,
  • La lib swagger-parser pour lire et interpréter le fichier openapi.yaml (définition de l’API),
  • La lib cors pour définir l’autorisation,
  • La lib swagger-routes-express pour créer un connecteur reliant les contrôleurs au routeur basé sur la définition de l’API,
  • La lib swagger-ui-express pour mettre à disposition un swagger de la définition de l’API, sur une route /api-docs.

Maintenant qu’on a la base il nous faut le squelette d’application, du coup au lieu de taper tout le code ici je t’invite cher lecteur à te rendre sur ce GitHub, et nous allons détailler les parties intéressantes.

OpenAPI

D’abord, qu’est-ce que l’on veut accomplir ? Dans cet exemple on va simplement faire un service REST pour obtenir une liste d’items, un GET. Nous allons donc décrire un fichier openapi.yaml décrivant cela.

openapi: 3.0.0
info:
  description: service backend
  version: 1.0.0
  title: my-api
paths:
  /items:
    get:
      summary: Get all items
      description: Get all items
      operationId: getItems
      responses:
        "200":
          description: success
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/item'
servers:
  - url: /api/v1
components:
  schemas:
    item:
      type: object
      properties:
        id:
          type: number
        name:
          type: string
        date:
          type: string

En gros : on définit une route /items qui renverra un tableau d’item et la structure item. Jusque là c’est du formalisme standard que vous retrouverez un peu partout.

Le serveur

Ensuite il faut construire notre serveur (je n’utiliserai pas le générateur), pour cela nous aurons principalement 2 fichiers de base et un fichier contrôleur. Le schéma étant assez simple, on reçoit une requête, le routeur fait un match et associe la méthode du contrôleur.

On commence par écrire un fichier makeApp.js (dans /src), qui correspond à la définition de notre serveur, notre application. Dedans on y trouve beaucoup de choses, on y reviendra plus en détail après, mais en gros on décrit ce que nous avons dit plus haut, à savoir : définir un serveur express auquel on va connecter le routeur, lui-même basé sur le fichier openapi.yaml décrit plus haut. Le connecteur du routeur se lie aux contrôleurs, nous y reviendrons.

const express = require('express')
const SwaggerParser = require('swagger-parser')
const swaggerRoutes = require('swagger-routes-express')
const ctrls = require('./controllers')
const cors = require("cors")

const makeApp = async () => {
  const parser = new SwaggerParser()
  const apiDescription = await parser.validate('./api/openapi.yaml')
  const connect = swaggerRoutes.connector(ctrls, apiDescription)
  const app = express()

...

  // Connect the routes
  connect(app)

  // Add any error handlers last

  return app
}
module.exports = makeApp

Ensuite on a le fichier point de départ : index.js, que l’on placera dans un répertoire /src, du coup n’oubliez pas de modifier package.json avec la propriété main :

"main": "./src/index",

Ainsi que la commande start, celle ci se lancerait avec un npm run start mais vous pourriez avoir une surprise, via un Git Bash sur Windows, d’avoir une erreur sur le ./

  "scripts": {
    "start": "./src/index.js",

Dans ce fichier index.js on aura un appel au précédent fichier de config.

const makeApp = require('./makeApp')
const port = 3000;

makeApp()
  .then(app => app.listen(port))
  .then(() => {
    console.log(`App running on port ${port}...`)
  })
  .catch(err => {
    console.error('caught error', err)
  })

Le contrôleur

Nous avons vu que le serveur se lie aux contrôleurs, il est maintenant temps de nous y intéresser. Nous allons créer un répertoire controllers dans /src, et un fichier item.js permettant de regrouper par domaine les actions concernant les items. À côté on créer un fichier index.js permettant de lister le contenu du répertoire au niveau de l’appelant, une façon de faire également utilisée en typescript/Angular.

Le fichier item.js

exports.getItems = async (req, res, next) => {
  res.json([]);
};

Le fichier index.js

const { getItems } = require('./item')

module.exports = {
  getItems
}

On peut voir la fonction getItems dont le nom match l’attribut operationId du fichier openapi.yaml. Évidemment c’est voulu 🙂 Et c’est important de garder ça à l’œil quand vous préparez votre YAML. Actuellement la méthode renvoie un tableau vide.

Compléter le serveur

Avant de vouloir tester il nous manque un morceau, en fait plusieurs petits détails. Pour faire des appels à notre API, depuis notre poste, on aura un soucis de CORS, mais aussi, potentiellement de cache (en-tête etag) et d’url encoding. À cela on veut préciser que l’on traitera du JSON dans les échanges.

  // Options
  app.set("etag", false); //turn off
  app.use(
    express.urlencoded({
      extended: true,
    })
  );

  app.use(express.json());

  app.use(cors({
    origin: '*'
  }))

Tester

Pour tester on peut lancer le serveur avec : node src/index.js

On peut ouvrir un Bash et lancer un curl de test, tel que :

$ curl -s localhost:3000/api/v1/items
[]

Du coup bonne nouvelle on a quelque chose qui fonctionne et répond correctement, à savoir une réponse d’un tableau vide ([]). Pour le fun on peut structurer une donnée et la renvoyer, en respectant le modèle proposé dans le YAML.

exports.getItems = async (req, res, next) => {
  res.json([
    {
      id: 1,
      name: "Item 1",
      date: "2022-07-13"
    },
    {
      id: 2,
      name: "Item 2",
      date: "2022-07-01"
    }
  ]);
};
$ curl -s localhost:3000/api/v1/items
[{"id":1,"name":"Item 1","date":"2022-07-13"},{"id":2,"name":"Item 2","date":"2022-07-01"}]

Ajouter la documentation Swagger

D’abord il va nous falloir une lib en plus :

npm i swagger-ui-express --save

Ensuite on va ajouter les lignes suivantes dans notre makeApp.js, l’une dans les déclarations, l’autre après les options afin de déclarer une route de documentation et lancer Swagger.

const express = require('express')
const swaggerUi = require("swagger-ui-express");

...

  // This is the endpoint that will display the swagger docs
  app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(apiDescription));

Couper/relancer le serveur et rendez-vous sur l’url localhost:3000/api-docs.

Illustration du résultat du Swagger

Et ensuite ?

À partir de là libre à vous de créer des services, connecter une base de données, gérer du fichier, etc.

Liste des ressources utilisées

PrimeNg vs AG Grid : rendu et composant de cellule

Je sors d’une mission dans laquelle j’ai été amené à travailler avec PrimeNg, qui propose dans un même framework tout ce dont on a besoin. Évidemment c’est joli sur papier mais dans la réalité ce n’est pas le cas, PrimeNg souffre d’un manque de flexibilité, mais ils ont tous ce soucis, donc bon, on fait avec.

Mise en place

Ici je vais vous parler du cas des tableaux, plus précisément de la partie rendu et composant de cellule, ce que AG Grid propose mais pas PrimeNg. Donc à nous d’ajouter une couche. On va créer un projet de POC avec ce dont on aura besoin (vous avez node lts, npm et un angular/cli) :

ng new primeng-simple-table
cd primeng-simple-table
npm i primeng-lts primeicons --save
npm i primeflex --save
npm i @angular/cdk@12 --save

Dans notre app.module.ts on va ajouter l’import du TableModule

import { TableModule } from 'primeng-lts/table'

...

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    TableModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

On va vider app.component.html, puis créer un composant qu’on va baptiser <simple-table> et dedans on va y mettre le code de base du tableau PrimeNg, dans sa version dynamique.

<p-table [columns]="cols" [value]="data" responsiveLayout="scroll">
  <ng-template pTemplate="header" let-columns>
      <tr>
          <th *ngFor="let col of columns">
              {{ col.header }}
          </th>
      </tr>
  </ng-template>
  <ng-template pTemplate="body" let-rowData let-columns="columns">
      <tr>
          <td *ngFor="let col of columns">
              {{ rowData[col.field] }}
          </td>
      </tr>
  </ng-template>
</p-table>

Notez les modifications de nom de variables et notre fichier .ts .

import { Component, Input, OnInit } from '@angular/core';

@Component({
  selector: 'simple-table',
  templateUrl: './simple-table.component.html'
})
export class SimpleTableComponent implements OnInit {
  @Input() data: any[] = []
  @Input() cols: any[] = []

  constructor() { }

  ngOnInit(): void {
  }

}

Et on l’appel dans notre app.component.html avec une déclaration de variable data et cols comme 2 tableaux vides pour le moment.

<simple-table [data]="data" [cols]="cols">
</simple-table>

On n’oubliera pas de définir la partie CSS sinon on aura rien de bon à l’écran :), notre style.css sera donc :

@import '~primeicons/primeicons.css';
@import '~primeng-lts/resources/themes/saga-blue/theme.css';
@import '~primeng-lts/resources/primeng.min.css';
@import '~primeflex/primeflex'

On compile, on regarde le résultat… vous n’avez pas grand chose !

Des colonnes dynamiques

On faisant un composant dynamique on perd la faculté de définir les colonnes manuellement, c’est le but, on veut un système générique qui le fasse pour nous suivant ce qu’on lui donne, on va lui donner le moyen de créer les colonnes désirées, et on va en profiter pour se créer un jeu de données pour les tests.

export interface ColumnDef {
  field: string
  header: string
}

On se crée donc une interface de définition de colonne et on passera un tableau de ColumnDef à notre composant pour définir ce que nous voulons. Dans ce premier jet nous avons l’attribut de donnée à utiliser pour la cellule (field) et le titre de colonne (header).

Jeu de données

Avant de mettre en pratique et jouer avec un exemple nous allons nous créer un jeu de données, prenons des vaisseaux spatiaux, au grand hasard. Voici la structure utilisée, déposé dans un fichier annexe qu’on importera :

export enum SpacecraftStatus {
  Active,
  Destroyed
}

export const spacecrafts: any[] = [
  { id: 1, name: 'Eagle 5', type: 1, status: SpacecraftStatus.Active, univers: 'Spaceballs' },
  ...
]

export const spacecraftTypes: any[] = [
  { id: 1, label: 'Transport' },

  ...
]

Définition des colonnes

Sur base de notre jeu de données et de l’interface ColumnDef nous allons définir nos colonnes pour notre tableau. J’en profite pour typer les variables cols en fonction. Dans app.component.ts nous modifierons tel que :

  data = spacecrafts

  cols: ColumnDef[] = [
    { field: 'id', header: 'ID' },
    { field: 'name', header: 'Name' },
    { field: 'type', header: 'Type' },
    { field: 'status', header: 'Status' },
    { field: 'univers', header: 'Univers' }
  ]

Magnifique, jusque là on a pas fait grand chose, PrimeNg nous donne la solution et nous avons juste typé plus proprement l’attribut column qu’ils proposent.

Rendu de cellule

Maintenant que nous avons une démo qui fonctionne avec des colonnes dynamiques, voyons ce qu’est le rendu de cellule qui nous manque. Prenez un premier cas avec la valeur status de notre jeu de données, nous voulons afficher un texte compréhensible, et non la valeur de l’enum, en fonction de l’état du vaisseau.

De base rien ne nous le permet, regardez la partie {{ rowData[col.field] }}, ceci ne fait qu’imprimer la valeur de l’attribut courant. Il faut se mettre entre la valeur et son rendu. Une solution : une méthode intermédiaire, optionnelle, et variable s’il vous plaît. Pour ce faire nous allons ajouter un attribut à notre interface ColumnDef.

export interface ColumnDef {
  field: string
  header: string
  renderer?: (rowData: any, field: string) => string
}

Désormais on peut lui donner une fonction qui recevra en paramètre l’entièreté des données de la ligne courante ainsi que le champs courant à rendre. Modifions notre définition pour cette colonne :

...
{
  field: 'status',
  header: 'Status',
  renderer: (rowData, field) => rowData[field] === SpacecraftStatus.Active ? 'Active' : 'Destroyed'
},
...

Jusque là ça ne change rien, il nous faut l’appliquer. Pour ce faire il y a 2 chose : que faire de ceux qui n’ont pas de méthode définie, et comment appliquer cette méthode. Pour la première partie nous allons définir une méthode par défaut sous forme de service et l’appliquer à la place de l’utilisateur à l’initialisation du composant. Pour la seconde nous utiliserons un container et la propriété [innerHTML].

Le service

Ce service contiendrait les solutions fournies de base, rien ne sert de réinventer la roue à chaque projet :).

import { Injectable } from "@angular/core"

@Injectable({
  providedIn: 'root'
})
export class CellRenderer {
  static none(rowData: any, field: string): string {
    const value: any = rowData ? rowData[field] : false

    if (value || value === 0) {
      return value
    }

    return ''
  }
}

Modification de l’@Input cols

On transforme notre variable en privée avec setter/getter pour avoir la main dessus en cas de modifications et pouvoir ainsi mettre les valeurs par défaut. On aide ainsi l’utilisateur de notre service à ne pas se casser la tête avec toutes nos options :).

  _cols: ColumnDef[] = []
  @Input() set cols(columns: any[]) {
    this._cols = columns
    this._cols.forEach((c) => {
      if (!c.renderer) {
        c.renderer = CellRenderer.none
      }
    })
  }
  get cols(): ColumnDef[] {
    return this._cols
  }

Mise en application

Il ne nous reste qu’à modifier notre rendu de valeur au sein de la cellule.

<td *ngFor="let col of columns">
  <div [outerHTML]="col.renderer(rowData, col.field)"></div>
</td>

Et nous voilà avec un rendu de cellule dynamique. Ceci n’est qu’un exemple, ce qui m’est arrivé le plus souvent étant une transformation de valeur booléenne ou de date partant d’un format variable (2022-03-15 ou un timestamp) pour aller vers une représentation francophone (15/03/2022). Dans le cas de la date, la fonction du CellRenderer appel un service spécifique central, qui pourra être appellé par un pipe ou autre service.

Composant de cellule

Au lieu d’une modification de texte, nous souhaitons parfois avoir un comportement, une interaction, avec la donnée ou la ligne, tel qu’un bouton, une liste ou autre type de composant. Ceux-ci ont des paramètres et des événements et il faut arriver à les glisser dynamiquement dans une cellule.

Nous allons jouer avec la colonne type en modifiant data pour l’exemple et voir comment un rendu de cellule aurait pu nous aider (ce n’est pas la seule solution).

  data = spacecrafts.map((m) => ({
    ...m,
    type: spacecraftTypes.find((f) => f.id == m.type)
  }))
{
  field: 'type',
  header: 'Type',
  renderer: (rowData, field) => rowData[field]?.label
},

Mais ici on va directement attaquer un gros morceau en transformant cette cellule en liste déroulante pour modifier le type du vaisseau, soyons fou. Pour ce faire il va nous falloir plusieurs morceaux :

  • Définir ce qu’est un composant par des interfaces,
  • Modifier l’interface ColumnDef pour ajouter une définition pour composant,
  • Créer le composant liste
  • Décrire une mécanique permettant d’insérer un composant au sein de la cellule,
  • Modifier le rendu de la cellule pour détecter s’il s’agit d’un composant ou d’un renderer.
  • Modifier la définition des colonnes pour intégrer les changements

Création des interfaces

De quoi avons-nous besoin ? De dire quel composant il s’agit, de pouvoir lui passer des options et de lui donner le moyen de nous revenir par des événements.

export interface CellComponent {
  component: any
  options?: any
  events?: any
}

Jusque là c’est assez générique, on lui dit qu’on pourra faire quelque chose, fort optionnel, et on spécifiera avec notre cas d’usage : une liste.

export interface CellComponentOptionsList {
  items: any[]
  itemValue?: string
  itemLabel?: string
}

export interface CellComponentEventsList {
  change: (originalEvent: any, rowData: any, field: any) =&gt; void
}

On aura besoin de lui donner la liste des options de la liste déroulante, et quel attribut utiliser comme valeur et comme texte à afficher. En retour nous serons alerté en cas de changement en nous retournant l’événement original du composant, ainsi que les données de la ligne et la colonne courante, ça peut toujours servir de donner les infos que l’on a.

Modification de l’interface ColumnDef

Comme nous avons nos types nous pouvons maintenant modifier notre interface de départ pour ajouter l’option composant.

export interface ColumnDef {
  field: string
  header: string
  renderer?: (rowData: any, field: string) => string
  components?: CellComponent[]
}

Au pluriel car une colonne pourrait contenir plus d’un bouton par exemple, laissons de la flexibilité.

Créer le composant liste

Dans un répertoire /components/cell-components j’ai créé le composant list. Nous nous baserons sur le composant Dropdown de PrimeNg, avec leur exemple custom content, ce n’est pas obligé, mais si vous allez dans le détail ça sera plus facile d’avoir déjà la main sur le rendu via les templates. Du coup en avant dans l’HTML. N’oublier pas d’ajouter DropdownModule et ses dépendances.

@NgModule({
  declarations: [
...
  ],
  imports: [
    BrowserModule,
    <strong>BrowserAnimationsModule</strong>,
    <strong>FormsModule</strong>,

...
    <strong>DropdownModule</strong>
  ],
<p-dropdown [options]="items" [(ngModel)]="value" [optionLabel]="itemLabel" [optionValue]="itemValue"
            [showClear]="true">
  <ng-template let-item pTemplate="selectedItem">
    {{ item[itemLabel] }}
  </ng-template>
  <ng-template let-item pTemplate="item">
    {{ item[itemLabel] }}
  </ng-template>
</p-dropdown>

Sur base de l’exemple qui parle de country, j’ai mis les nommer les variables que l’on va définir et nettoyer l’exemple, les détails comptent. La suite se passe dans le fichier .ts pour définir tout ce beau monde.

@Component({
  selector: 'cc-list',
  templateUrl: './list.component.html'
})
export class ListComponent implements OnInit {
  // Must be in a parent class
  options?: CellComponentOptionsList // Specific to list, any for generic
  events?: CellComponentEventsList
  rowData!: any
  field!: string

  // Specific to list
  items: any[] = []
  itemValue!: string
  itemLabel!: string

  value?: string

  ngOnInit(): void {
  }
}

Alors d’abord, si on avait plusieurs composant de cellule on aurait forcément une classe parente avec la partie commune, pour ce POC j’ai regroupé et annoté. Ensuite, vu qu’on viendra d’une cellule il sera normal et attendu que l’on passe ce que la cellule a à nous donner, à savoir rowData et field, ainsi que la définition spécifique à notre composant (options et events), venant de la config dans ColumnDef. Enfin, comme définit dans nos interfaces, les particularités spécifiques à la liste (items, itemValue et itemLabel). Notez encore la propriété value qui contiendra le choix courant et qui sera utilisé dans l’événement change.

Tel quel, le composant ne fera qu’exister avec une liste vide et les paramètres non-défini. Nous les ajouterons après pour que vous compreniez d’abord bien le chainage des attributs et les affectations. Restons simple, ça se complexifiera vite assez.

La mécanique d’insertion du composant dans la cellule

C’est là tout le truc, en un point du code HTML on doit dire à Angular : « je veux mon composant là et que tu le raccordes à mes options, événements, … ». Pour ce faire on va passer par une directive qui jouera le rôle d’hôte.

@Directive({
  selector: '[cellComponentsHost]'
})
export class CellComponentsHostDirective implements OnInit {
  private items!: CellComponent[]
  @Input('cellComponentsHost') set config(items: CellComponent[]) {
    this.items = items
    this.initContent()
  }

  @Input() rowData: any[] = []
  @Input() field = ''

  constructor(
    private vcRef: ViewContainerRef,
    private cfr: ComponentFactoryResolver
  ) { }

  ngOnInit(): void {
    this.initContent()
  }

  initContent(): void {
    // Clean
    this.vcRef.clear()

    // Add each item
    this.items.forEach((item) => {
      const factory = this.cfr.resolveComponentFactory<ListComponent>(item.component)

      const component = this.vcRef.createComponent<ListComponent>(factory)
      component.instance.rowData = this.rowData
      component.instance.field = this.field
      component.instance.options = item.options
      component.instance.events = item.events
    })
  }
}

Pour faire simple, cette directive récupère ce qu’on lui passe sous forme d’attributs : cellComponentsHost, rowData et field. Attention au casting ListComponent, là c’est la classe parent que l’on indique normalement. Ensuite elle crée le composant et le place dans le container sur lequel est placé la directive. N’oubliez pas de la déclarer.

@NgModule({
  declarations: [
...
    <strong>CellComponentsHostDirective</strong>
  ],

Modifier le rendu de la cellule

<td *ngFor="let col of columns">
  <ng-container *ngIf="!col.components"><div [outerHTML]="col.renderer(rowData, col.field)"></div></ng-container>
  <ng-container *ngIf="col.components" [cellComponentsHost]="col.components" [rowData]="rowData" [field]="col.field"></ng-container>
</td>

Pas de chipotage, on encapsule notre rendu de cellule précédent dans un container conditionnel, dans l’autre on y retrouve notre directive et les attributs qu’on lui passe.

Modification de la définition de colonnes

{
  field: 'type',
  header: 'Type',
  components: [
    {
      component: ListComponent,
      options: {
        items: spacecraftTypes,
        itemLabel: 'label',
        itemValue: 'id'
      } as CellComponentOptionsList,
      events: {
        change: (originalEvent: any, rowData: any, field: any) => {
          console.log(field + ' : ' + rowData[field])
        }
      } as CellComponentEventsList
    }
  ]
},

On lui indique donc le composant voulu et nos options, jusque là vous devriez pouvoir faire les liens :), et on sort en console pour vérifier que l’évent a donné quelque chose.

Compléter le composant liste

Nous avons une interface de définition et nous avons définit notre cas d’utilisation sur la colonne type. Ensuite, nous avons créé le composant sans l’implémenter, ainsi que la directive hôte. Enfin, nous avons mis en place l’usage de la directive. On pourrait tracer le cheminement ainsi :

  • Le tableau boucle sur les colonnes pour créer une ligne.
  • Il arrive sur une cellule et se pose la question : est-ce un composant ou non ?
    • Si non : il tente le rendu de cellule (none par défaut)
    • Si oui :
      • La directive est fournie des informations et se lance pour créer chaque composants demandés en son seing
      • Le composant nouvellement créé est prêt

Il est prêt ? Qu’est-ce que cela veut dire ? On a parlé de compléter le composant pourtant :/ … et en effet on pourrait s’arrêter là et utiliser options directement, excepter le onChange manquant, mais nous désirons peut-être appliquer des contrôles ou des détections préliminaires, et dans cette idée je vais vous montrer une possibilité.

  ngOnInit(): void {
    this.items = this.options?.items || []
    this.itemValue = this.options?.itemValue || 'id'
    this.itemLabel = this.options?.itemLabel || 'label'

    this.value = this.rowData[this.field]
  }

  change(event: any): void {
    if (event.stopPropagation) {
      event.stopPropagation()
    }

    this.rowData[this.field] = this.value

    this.events?.change(event, this.rowData, this.field)
  }

Ainsi nous avons tout qui est relié, prêt à tester, ce qui va nous donner ce résultat :

La dropdown n’est pas des plus esthétique, pour ça il suffit d’un peu de CSS, d’abord ajouter une classe puis la définir :

<p-dropdown [options]="items" [(ngModel)]="value" [optionLabel]="itemLabel" [optionValue]="itemValue"
            [showClear]="true" (onChange)="change($event)" class="full-width">
p-dropdown.full-width .p-dropdown {
  width: 100%;
}

Aller plus loin

Il est beau, il est joli, mais est-ce tout ? Seule l’imagination étant notre limite, la réponse évident est : non ! Tout dépend de votre besoin, de vos envies de flexibilités, de ce que vous souhaitez proposer. Pour ma part voici des pistes abordées et développées :

Reporter les options fournies par p-table

Ce n’est pas une évidence pour tout le monde, mais en dehors des choix fait pour votre composant (imposer le scroll, le système de colonnes, …) vous bloquez l’accès aux autres options proposées par p-table, du coup ceux qui veulent utiliser votre composant vont être bloqués/frustrés. Une bonne pratique est de faire des ponts pour reporter ces options autant que possible, merci le code source de la lib pour vous aider.

Ligne de filtres

PrimeNg permet d’ajouter des filtres mais en dynamique il vous faudra user d’une autre directive hôte et d’une interface, sans oublie l’@Input pour passer votre demande au composant.

Sélection

PrimeNg permet également de mettre en place un système de sélection de ligne(s), mais vu notre côté dynamique c’est à nous de réintégrer cette solution, de manière optionnelle. Ceci sous-entend d’ajouter des colonnes de sélection et de donner à p-table la configuration (none, single, multiple)

Classes CSS

On l’oublie souvent, mais le design ! Du coup vous voulez préciser pour telle colonne un style (classe CSS), il faudra faire évoluer la définition de colonnes et modifier l’HTML de notre composant pour l’intégrer dans la partie template, tant sur l’en-tête que sur la cellule. On peut également imaginer que l’en-tête et la cellule peuvent avoir des styles différents (couleur, alignement, …).

Le composant liste

Notre composant liste peut également être poussé plus loin, en activant le filtre et/ou en améliorant le rendu pour afficher un contenu plus complexe comme un préfix à la valeur, ce qui m’est souvent arrivé : [PRE – valeur affichée] .

Git

Je vous ai fait un Git avec ce POC 🙂

Kraken en boîte, ou comment tout réunir dans un seul composant

Suite de l’article Unwrap the kraken, ou comment faire du multicolonne avec des sous composants, où nous allons pousser le bouchon un cran plus loin.

Dans le dernier article nous avions notre composant qui s’occupait de s’extraire et une directive qui s’occupait de le remettre dans une boite, en gros. Mais bon, si le composant pouvait éviter de s’extraire pour être remis en boite ça serait quand même mieux.

Du coup l’idée est de lui rendre la notion « es-tu une ligne ? » via un @Input() row: boolean = true; En considérant qu’il l’est par défaut (au choix). Ensuite quand on crée le composant on agit selon la valeur de row.

À l’initialisation, si nous somme une ligne, nous allons transformer notre template enfant #tref en contenu de notre propre bloc via le ViewContainerRef pour le créer et le Renderer2 qui va l’injecter dans le nœud de notre composant courant. Ensuite on peut signaler à notre bloc courant, le composant en lui-même, que nous aimerions le décorer de quelques classes CSS pour en faire une row css au sens Bootstrap.

if (this.row) {
  // Transform template into inside elements
  Promise.resolve(null).then(() => {
    const view = this.vcRef.createEmbeddedView(this.tpl);
    view.rootNodes.formEach((node) => this.renderer.appendChild(this.el.nativeElement, node));
  });

  // Transform parent to row with classes
  this.renderer.setAttribute(this.el.nativeElement, 'class', "row " + this.classes);
}

Quand l’élément est prêt, et que nous ne sommes pas une ligne, on continue de sortir notre contenu en dehors du bloc par la même mécanique. On ajoute une classe css au bloc courant pour le masquer (bootstrap: d-none -> display: none) et par la même mécanique que le paragraphe précédent on utilise le template et l’ajoute un cran plus haut du coup. Aussi simple que ça.

if (!this.row) {
  // Hide parent
  this.renderer.setAttribute(this.el.nativeElement, 'class', "d-none");

  // Create template elemet outside
  Promise.resolve(null).then(() => this.vcRef.createEmbeddedView(this.tpl));
}

Code de la solution, dans un contexte plus évolué de composant générique parent gérant l’aspect colonne Bootstrap : https://gist.github.com/killan/b621bf2b50e7add8b297493a05c1b949

Unwrap the kraken, ou comment faire du multicolonne avec des sous composants

Dans cet article nous parlerons d’Angular 11, de bootstrap 5, de component, de directive, de sortir le contenu d’un component au niveau du parent et d’encapsuler un contenu, le tout autour du sujet de grille bootstrap (row/col) et d’imbrications posant problème au sujet des alignements.

Vous avez surement déjà utilisé le système de grille de bootstrap et vous connaissez le principe d’un bloc dans un autre tel des poupées Russes et des Legos. Définissons alors un composant vous permettant d’afficher une zone de texte (input) précédé de son nom/étiquette (label).

Exemple d’un label suivi d’un input de type texte

En Angular nous créerons un component pour réutiliser ce système, nous n’aurons qu’à lui passer le libellé du champs, son formGroup si applicable, etc.

<mon-input label="Email" [form]="form"></mon-input>

Nous pouvons alors définir une structure qui serait par exemple :

<div class="row">
  <label class="col-4"></label>
  <input class="col-8">
</div>

Dans notre exemple nous donnons une répartition 1/3 , 2/3.

Ça c’est un cas simple mais imaginons maintenant un cas où nous aurions 2 colonnes contenant chacune le composant mon-input, suivi d’une ligne avec un composant mon-input mais qui voudrait faire la largeur des 2 colonnes.

Schéma de ce que nous voulons atteindre.

Voici ce que donnera la découpe logique une fois mis en bootstrap.

Schéma bootstrap

La première ligne donne le bon résultat : 2 colonnes chacune occupant 1/3 , 2/3 de l’espace parent. Premier problème : utiliser 1/3 , 2/3 sur la seconde ligne ne fonctionne pas.

Première idée du coup changer la répartition, et on peut essayer… ça n’ira pas. Bootstrap nous donne un conteneur row équivalent à 100% de l’espace divisé en 12 colonne théorique.

Dans notre exemple nous avons donc une coupe en 50/50 (soit 2 * 6), sans oublier qu’il y a une gouttière entre les cellule. Nous voudrions donc que la seconde ligne soit dans une répartition 1/12 ou 2/12 pour le label et le reste à l’input.

L’idée de cet article n’est pas le CSS, la mise en page en trichant ou en cassant les mécaniques rencontrées mais de faire avec naturellement et de solutionner par la construction.

Pour ne pas faire durer inutilement, la solution est que la première ligne ne doit pas être en 2 colonnes mais en une seule et de donner des 12ème de la largeur entière au 2 premiers composants. Le hic, car il y a toujours un hic, c’est qu’Angular encapsule ses composants. Au niveau du rendu en 2 colonnes vous auriez quelque chose comme :

<div class="row">
  <div class="col">
    <mon-input>
      <div class="row">
        <label class="col-4"></label>
        <input class="col-8">
      </div>
    </mon-input>
  </div>
  <div class="col">
    <mon-input>
      <div class="row">
        <label class="col-4"></label>
        <input class="col-8">
      </div>
    </mon-input>
  </div>
</div>

Et si on imagine notre solution de tout mettre sur une ligne :

<div class="row">
  <mon-input>
    <div class="row">
      <label class="col-4"></label>
      <input class="col-8">
    </div>
  </mon-input>
  <mon-input>
    <div class="row">
      <label class="col-4"></label>
      <input class="col-8">
    </div>
  </mon-input>
</div>

Il faudra également dire à notre composant de ne plus se traiter comme une ligne individuelle.

<div class="row">
  <mon-input>
    <label class="col-4"></label>
    <input class="col-8">
  </mon-input>
  <mon-input>
    <label class="col-4"></label>
    <input class="col-8">
  </mon-input>
</div>

Nous y voilà, c’est plus clair : le soucis sont les tags <mon-input>, ceux-ci considéré par Bootstrap comme des cellules sans la classe CSS adéquate (col-x). Et non, comme par magie il ne considérera pas les enfants de mon-input. De plus la répartition cumulée des col-4 et col-8 dans cette ligne dépasse 12.

Voici ce que nous voudrions, tout en ayant un composant (!) :

<div class="row">
  <mon-input></mon-input>
  <mon-input></mon-input>
</div>

Qui donnerait en interprété :

<div class="row">
  <label class="col-2"></label>
  <input class="col-4">
  <label class="col-2"></label>
  <input class="col-4">
</div>

C’est là que nous parlons de décapsuler (unwrap) notre composant pour que son host (bloc parent auto créé) ne viennent pas entraver notre volonté. La technique est simple et pourtant pas évidente à trouver, et au travers du temps, beaucoup de chose ne fonctionnent plus (ancienne version d’Angular) ou pas encore (CSS working draft ou roadmap Angular).

La solution théorique est de considérer l’intérieur de son composant comme un template

<ng-template #tref>
  <label class="col-4"></label>
  <input class="col-8">
</ng-template>

et de le cibler et le mettre en dehors avec la commande :

this.vcRef.createEmbeddedView(this.tpl);

Selon votre implémentation vous aurez peut-être comme résultat de dupliquer votre composant, une fois à l’intérieur et une fois à l’extérieur, du coup je suis parti sur une transformation de l’HTML du composant via un <ng-template #tref> et cibler celui-ci dans ma mécanique avec un :

@ViewChild('tref', { static: true }) tpl;

Ainsi dans votre ngAfterViewInit (ou ngOnInit) vous pourrez utiliser la ligne createEmbeddedView du vcRef (ViewContainerRef).

À noter qu’il faut ajouter dans @Component l’attribut host:style pour qu’il n’affecte plus le dom, car il reste présent malgré tout.

@Component({
  selector: 'mon-input',
  template: '',
  host: { style: "display: none" }
})

Vous me direz que l’on perd la notion de gouttière mais en fait non, on reste en contextes de colonnes, mais vous perdrez le cumul inutile, pour autant qu’il se marque.

Maintenant parlons de cette seconde ligne, toujours en utilisant notre composant mon-input. Vous me direz suffit de lui mettre un <div class= »row »> autour et le c’est fait. C’est pas tout à fait faux, en considérant que l’on lui envoie la nouvelle répartition des colonnes par @Input(), ce que j’ai fait chez moi, pour démo :

<div class="row">
  <mon-input label="Email" [form]="form" [distribution]="[2, 10]"></mon-input>
</div>

Et dans notre HTML nous l’utiliserions ainsi

<ng-template #tref>
  <label class="col-{{ distribution[0] }}"></label>
  <input class="col-{{ distribution[1] }}">
</ng-template>
Nouvelle répartition de la seconde ligne.

Ceci étant dit, nous ne voulons pas mettre à la main le <div class= »row »>, nous voulons que cela se fasse tout seul. Vous allez rire mais nous allons réencapsuler ce que nous venons de décapsuler. À ce stade je n’ai pas trouvé mieux pour le moment.

Pour ce faire c’est plus compliqué, étant un composant nous ne pouvons pas utiliser un sélecteur [mon-selecteur] d’un composant (component), qui se prendrait pour une directive, pour jouer sur le <ng-content> d’un template : <div class= »row »><ng-content></ng-content></div>.

Nous allons devoir utiliser une directive et ce qui est amusant c’est que la directive va appeler un composant pour « composer » notre sortie.

L’idée est que la directive considère le tag (mon-input dans notre cas) sur laquelle elle est comme le template à injecter dans le composant d’encapsulation. Ainsi, nous créons l’encapsulation, on récupère le template, on l’injecte, on peut même le paramétrer et le tour est joué. On passerait de :

<mon-input label="Email" [form]="form" [distribution]="[2, 10]" *bs-row></mon-input>

À ce type de rendu :

<bs-row-container class="row">
  <label class="col-2"></label>
  <input class="col-10">
</bs-row-container>

C’est terrible ! Exactement ce que l’on souhaite avoir.

On peut voir ici en comparaison l’ensemble.

Côté technique vous aurez la directive [bs-row] initiant l’encapsulation, et le component ‘bs-row-container’ ne contenant qu’un template rudimentaire :

@Component({
  selector: 'bs-row-container',
  template: '<ng-container *ngTemplateOutlet="tpl"></ng-container>'
})

Où tpl est renvoyé en attribut @Input par la directive.

On peut ainsi schématiser la solution finale ainsi :

<div class="row">
  <mon-input [distribution]="[2, 4]"></mon-input>
  <mon-input [distribution]="[2, 4]"></mon-input>
</div>
<mon-input [distribution]="[2, 10]" *bs-row></mon-input>

On a donc une ligne de 2 x 6 et une qui totalise 12, c’est propre, minimaliste et conforme 🙂 !


Code complet de l’encapsulation : https://gist.github.com/killan/f88e7723833bd4bb9f8798c178f5cc0b

Sources :

  • https://github.com/angular/angular/issues/7289
  • https://blog.cloudboost.io/creating-structural-directives-in-angular-ff17211c7b28
  • https://stackoverflow.com/questions/35932074/no-provider-for-templateref-ngif-templateref
  • https://stackoverflow.com/questions/41165448/building-a-wrapper-directive-wrap-some-content-component-in-angular2
  • https://stackoverflow.com/questions/60482442/multiple-components-match-node-with-tagname-app-lobby
  • https://stackblitz.com/edit/angular-eumnmt?file=src%2Fapp.ts
  • https://github.com/angular/angular/issues/18877
  • https://www.freecodecamp.org/news/everything-you-need-to-know-about-ng-template-ng-content-ng-container-and-ngtemplateoutlet-4b7b51223691/

Legokerke

Suite à un passage à Middelkerke et l’envie de me remettre à la création de modèle Lego, je me suis lancé le défi d’un building. Évidemment, avec les contraintes de briques existantes et disponibles chez Lego, pareil pour les couleurs, un vrai calvaire, et en faisant l’essai des Modular.

L’idée est donc de reproduire l’aile droite de la résidence Martiny, son espace commercial que l’on considérera comme un vendeur de granitas, glaces et glaçons, et les étages, parties des appartements.

Côté Lego on part sur une plaque 16×16 avec des couleurs au plus proche. On remarque tout de suite que la fenêtre de gauche n’est pas possible vu les pièces disponibles et que la fenêtre latérale sera plus large que prévu. Le balcon sera adapté en conséquence.

Building granita

La modélisation a été faite avec Studio 2 et avec beaucoup de recherches, notamment sur Bricklink pour les références des pièces existantes chez Lego actuellement, ainsi que sur d’autres sites de références sur les couleurs Lego.

Granita vue du dessus

La première rangée est composée d’un frigo à glaçons (popsicle), d’un distributeur de granita 4 bacs et d’un meuble à verres + plante déco et un affichage de prix au dessus. Ensuite on a le comptoir avec la caisse et le meuble à glaces 4 bacs. On peut également voir le carrelage en damier noir et blanc côté « cuisine » avec des points d’accroche, et côté entrée un carrelage stylisé gris et blanc en triangles et lignes. On ne les voit pas bien mais au mur, carré bleu, c’est un équivalent des prix et promos du jour. Il y a également une grande plante en vase pour décorer.

Un panneau publicitaire double-face inspiré du modèle 60203, une machine à tchiniss/un distributeur de bêtises et une poubelle inspirée/reprise de la station essence Octan de 2001.

Premier étage de notre building, une réplique modifiée de celui que nous avions loué, les proportions sont respectées. Un meuble TV avec 2 portes (figées), un petit écran plat, fauteuil, canapé, table basse et lampe halogène sur pied. Le sol représente un carrelage clair en longueur. L’anecdote malencontreuse est que les « roof tile 1×1 » gris foncé n’existent pas en gris clair prévu initialement, j’ai donc tenté cette modification.

Pour des exemples de fauteuil etc, une recherche google « lego sofa » vous donnera un tas de bons conseils et d’exemples.

Le second étage avec une idée design moderne, avec écran géant plat et enceintes, éclairage mural et un ensemble canapé – table basse orange pétant, arrondi; sans oublier la plante ! Le sol est typé planché sombre.

L’angle pour montrer n’étant pas évident, voici un rendu Studio 2, ainsi vous pouvez admirer le beau carrelage et le tapis sous ce magnifique divan avec méridienne. La pièce est sobre, une étagère murale avec livres et sculpture/roche, des éclairages muraux spots directionnels et un télescope !

Et voilà ! Un building complet, dont le manuel fait 90 pages pour 194 étapes, et où le modèle compte plus de 1000 pièces !

Une suite ?

Effectivement, nous avons imaginé ajouter 2 étages pour lui donner de la verticalité. On cherche encore le style des 2 étages.

Serait-ce l’atelier d’un peintre ?

Un ensemble ?

Oui ! Ce building fait partie d’un projet plus grand qui se veut modulaire, c’est à dire utilisable dans son ensemble ou en partie.

Je n’en dirais pas plus ici tant que ce n’est pas fini, ni plus réalisable.

Fusion de la chambre intermix

Suite à l’article précédent j’ai voulu faire un tour de nettoyage et je me suis rendu compte qu’un ancien démon revenait à la charge. Nous avons divisé le code par domaine et contexte de rendu, propre et héritant d’un parent commun, et dans un service je mets le code commun à ce qui concerne Grid, ou Iso etc., de manière à isoler les calculs de l’usage selon le contexte.

Schéma avant fusion

Plus simple avec un schéma, voici la découpe avant fusion. Ce qui nous intéresse c’est la séparation 2D et Gl, puis la découpe par usage/type à savoir Sprite, Grid ou Iso, puis les regroupements de type IsoElement/GridElement, ainsi que des services relatifs aux couches.

On a une sorte de matrice à 3 dimensions en ce qui concerne cette idée. Sauf que programmer ça, en TypeScript, ben c’est pas très évident. En PHP j’aurai pu utiliser des Traits, il existe des mixins en javascript mais non merci, je vous laisse vous faire votre avis mais ce n’est pas à la hauteur. L’héritage multiple n’existe pas (cf mixin), du coup il faut savoir se renouveler et faire preuve d’audace, d’expérimentation et de refontes inévitables. C’est ce que j’ai dû faire, non sans mal.

J’étais parti pour déplacer les fonctions de rendu dans les services par couche (Grid/Iso) et de fusionner Grid2DElement avec GridGlElement, vu que leur différence réside dans le contexte de rendu (2D/Gl). Mais je me suis aperçu que bien que je gagnais en clarté à tout regrouper, on augmentait d’autre part la difficulté de ce même code et des approches. Bref, bien, mais pas bien.

Du coup revirement de situation et revenons sur nos pas de plusieurs mois quand on a justement décidé de diviser par contexte de rendu, quand les lumières sont arrivées (la version solutionnée). Revenons donc à cette idée non divisée et sans emmerder les services déjà très bons tels quels.

C’est la fusion !

Fusionner Grid2D et GridGl, Iso2D et IsoGl, ok, mais on oublie Sprite2D et SpriteGl qui héritent de SpriteElement, il faut commencer par le commencement. C’est donc une refonte jusqu’à Element pour répartir les morceaux des différentes classes Sprite*. Ainsi disparaissent Sprite2DElement et SpriteGlElement au profit d’une nouvelle classe SpriteElement toute équipée.

Quand on parle de fusion on parle bien entendu de gérer le contexte de rendu au sein même de la classe. Ceci peut paraître étrange et contre certains bons principes, mais ces morceaux de code partagent parfois jusqu’à 90% du code, ce qui va contre le principe DRY (Don’t Repeat Yourself) et comme j’ai envie de bisous (KISS : Keep It Stupid Simple), j’ai tout regroupé et cela ne m’a demandé que quelque if peu coûteux, ce qui est très important car on appelle ces bouts de code des centaines, des milliers de fois par seconde (selon complexité de votre projet).

Fusion de Sprite*Element

J’en profite pour illustrer le service de rendu Gl et montrer à partir d’où on le connecte. J’en affiche un peu plus mais ainsi on voit les 2 types de regroupement GridElement et IsoElement. Ne prêtez pas attention à GridBlock, vous le connaissez déjà.

Nous sommes bien parti, continuons. On crée une nouvelle classe GridElement et on met ce que contient Grid2D et GridGl, hop tout dedans et on essaye de faire coller les morceaux. Là où ça se corse c’est de bien segmenter les parties communes et spécifiques, puis quand on arrive à IsoElement c’est encore pire car nous sommes basé sur l’héritage donc on ne réécrit que ce qui a besoin de l’être et là on observe des couacs, des oublis pour la 2D vu qu’on s’est concentré sur Gl depuis les lumières. Par exemple la table en 2D ne fonctionne pas, juste en Gl.

Fusion terminée

Sur papier ça parait plus beau, naturel, élégant, [mettre ici tous les beaux mots que vous désirez]… Mais dans la pratique ça demande pas mal d’efforts, de compréhension, évidemment c’est pour un mieux !

C’est quand même un impact de 33 fichiers dans le projet et son POC de démo, 8 dans TARS même, ainsi que 8 suppressions et 2 ajouts. Ça c’est pour les fichiers, mais en terme de lignes de code, même si je n’ai pas de compteur à cet instant, on a effectué une réduction notable, donc plus facile à maintenir, de par le regroupement aussi.

Preuve que ça fonctionne toujours, même si la table n’est pas gérée encore correctement en 2D.

Garder 2D et Gl ?

Pourquoi garder les 2 ? Car il est très difficile de débuger en Gl, vous ne pouvez pas dessiner aisément un repère ou une trace sans sortir les chars d’assaut et beaucoup d’heures de dev alors qu’en 2D vous êtes libre de manipuler le rendu en direct et ce rapidement.

C’est pour cela que le POC (démo) n’utilise plus les lumières en 2D, le but pour moi ici étant un debug rapide sans ajouter des soucis de performances, qui plus est, connus.

De plus, maintenant que la fusion est faite, on pourrait revoir tout le workflow d’utilisation pour ne plus devoir faire (à l’usage) une scène 2D ou une scène Gl. ainsi en changeant juste le mode, toutes les classes personnelles seraient utilisables, ce qui serait un chouette gain de temps et d’effort. De plus les 2 fonctions ajoutées is2DContext/isGlContext permettent d’agir spécifiquement si besoin était.

Et le fameux ensuite ?

Ah ben oui, ensuite quoi ? Corriger le pourquoi de la table en 2D et tenter de régler un conflit Grid/Iso sur un calcul de positionnement.

Il faut absolument faire un POC purement Grid et non Iso, genre un Mario (S)NES ou Duke Nukem 1-2, un truc tout carré pour voir que les calculs sont bons, juste une grille décorée, pas plus.

Ensuite, mes fameuses particules lumineuses et le miroir :p !

Mais bon, comme d’hab on verra ce qui me stitch comme on dit. Je vais déjà de ce pas fusionner les branches du repo et repartir sur une base saine :).

Edit

Pour ne pas refaire un article juste pour ça, j’ai également fusionné les classes du POC (treasure, door, player), leur code était identique. 3 classes de moins sur les 6 initiales (3x2D et 3xGl). Une bonne chose de faites qui va nous simplifier la vie ultérieurement. Les scènes restent dissociées car différentes, la Gl, plus complète, gère la lumière par exemple, les fusionner ne donnerait rien d’intéressant une fois en release. Au moins le debug (2D) peut se faire sans gêner le résultat final (Gl). Nous voilà dans un état propre, quelques corrections de bugs ou de manques seraient à faire pour solidifier avant d’avancer plus.

Masque splendide

Tel Stanley Ipkiss, TARS joue maintenant avec un masque lors des rendus d’objets occupant plusieurs cases dans notre contexte isométrique. Mais pourquoi ?

La table passe sur le Héro car elle est rendue après.

C’est là qu’il faut vous expliquer comment ça fonctionne, ce qui n’est pas une mince affaire. La table occupe 2 cases, mais comme tout Element, il a une coordonnée qui ici nous dit {x: 8, y: 2} (rappel on commence à zéro) et notre Héro, lui, arrive sur la case {x: 9, y: 1}, ce qui de part notre mécanique de rendu Isométrique dessine le Héro avant la table et donc la table écrase le dessin du Héro. Vous suivez jusque là ?

Ceci illustre bien l’ordre de rendu (dessin), de haut gauche, vers la droite jusqu’en fin de ligne, on passe à la ligne suivante et on repart de gauche à droite.

1 problème ?

On a donc un problème, le Héro devrait être dessiné devant la table, ce qui correspond à notre logique visuelle. Même si on inverse le sens de dessin, en colonne au lieu de ligne, on aura le même soucis avec l’autre table.

Après de longues recherches et essais, une seule solution : la manifestation ! découper l’image en 2 pour occuper chacune une case. Car le problème n’est pas que cette erreur de dessin mais aussi le pathfinder qui passe au travers de la case qui n’est pas affectée (en mémoire), elle ne l’est que par vos yeux; et enfin la lumière qui voit la table tantôt d’un bout (loin), tantôt de l’autre bout (proche) et donc l’éclaire différemment.

3 problèmes ?

Le pathfinder se base sur la présence d’un Element sur la grille que l’on fabrique sur base du subsetMap, et la lumière, chacun ayant son algorithme. Le rendu se base directement sur la subsetMap. On serait tenté de dire qu’il faut solutionner les 3 individuellement et c’est ainsi que j’avais commencé.

J’ai donc ajouté une notion d’additionnalCoords (coordonnées additionnelles) dans la définition de l’élément table et ce pour les 4 orientations NESW, décrivant, dans le cas de notre table orientée vers le Nord et dont la base se trouve en bas gauche de l’image, une case additionnelle en x: 0, y: -1.

Le choix de la coordonnées {0,0} vient du sens de rendu et du sens de détection du raycasting quand on clic droit sur un Element (coffre, porte). La dernière case dominera les précédentes comme démontré par le problème. Le raycasting parcours l’inverse du rendu pour trouver l’élément le plus devant. Notre {0,0} sera donc cette dernière case a être rendue et correspondra à la coordonnée de l’Element, les additionalCoords représente l’ensemble des autres cases en mode relatif (ex: {x: -1, y: 0} pour la table orientée vers l’Est).

Raycasting ? 4 problèmes donc ?!

Maintenant que vous comprenez la structure, le pourquoi du comment et la mécanique céleste, nous allons tenter de corriger les problèmes un à un.

Le plus facile est le pathfinder, qui se base sur des cases occupées pour dire que l’on ne peut s’y rendre. Actuellement le Héro se déplace sur la seconde case de la table « dessous » car la table se dessine après. Il faut donc arriver à lui dire « hey tiens voilà des coordonnées additionnelles à retirer !« .

Aussitôt dit, aussitôt fait, quand on prépare le pathfinder sur base de la subsetMap, on demande à tous les Elements s’ils ont des coordonnées additionnelles et ensuite on les retire du résultat final.

Fin ! 8D

Ah ah ah Oui mais non ! Ça fonctionne, certes, le Héro ne traverse plus les tables, ce n’est plus un drôle de fantôme. Mais ! La lumière n’est pas bonne, le raycasting reste un problème et évidemment notre problème de rendu reste identique, la table passe devant le Héro.

J’ai tenté une théorie visant à dire au GridBlock de prendre en compte des pointeurs, une forme de référence entre cases, mais en vain. Un GridBlock est un multi-ensemble d’Elements, point.

Je vous épargne toute la frustration et brisage de méninges, il m’aura fallu une bonne semaine pour trouver la seule piste envisageable.

Un système de masque

Dit comme ça, ça semble être la solution ultime, et c’est pas loin, mais incomplet. On garde les additionnalCoords et on s’en sert à l’ajout de l’Element sur la Map lors du chargement pour l’ajouter à chacune des cases, le même élément (la même instance, pas une copie). Notre table est donc physiquement présente en mémoire sur 2 cases.

Cela résout le problème de pathfinder et on peut retirer ce que nous avons fait précédemment. Le raycasting est aidé par cette approche mais il faudra l’aider (cf le rendu), nous verrons ça en fin d’article. Il nous reste la lumière et le rendu.

Table ajoutée 2 fois sur la coordonnée de l’Element au lieu des cases spécifiques.
Chacune des 2 cases de la table rend la même image, donc elle « fusionnent » visuellement.

L’idée du masque est de dessiner une partie de l’image sur chaque case correspondante, évitant de demander au graphiste de préparer un grand nombre d’images individuelles et de devoir se battre avec son éditeur de map, ce qui n’est pas gérable.

Chaque case correspond à un rendu à faire, la zone bleue donne B et la zone orange donne A.

Modification du système de rendu

Dans le cas où notre Element a des additionnalCoords il faut appliquer le masque, sinon le rendu classique qui va bien. Pour y arriver, il nous manque quelque chose, comment savoir quelle partie dessiner ? Actuellement nous dessinons selon la coordonnée de l’Element, du coup dans ce cas on dessine une table entière selon ses coordonnées ce qui donne l’effet de fusion illustré ci-avant.

Pour changer ça, il faut dire à notre système de rendu que ce n’est pas la coordonnée de l’Element qu’il faut utiliser, grosso modo. On va commencer par ajouter la notion de coordonnées au gridBlock, qui n’en avait pas besoin jusque là. Et qu’ils passent cette coordonnée à la fonction draw() pour que l’information arrive au système de rendu.

Et après on fait comment ? On se questionne, a-t-on des additionnalCoords ? Si oui, à laquelle correspond le x,y donné par le rendu ? De la on calcul un masque prenant en compte le déplacement dans l’image pour n’afficher que ce qui nous intéresse dans notre cas. Illustrons ça avec la conquête des erreurs de rendu.

Il y a bien un rendu par case, en ayant forcé la largeur de la source à une largeur de case, mais en ayant oublié la déformation de destination.
Correction de la destination, tout le monde à une case de largeur.
Premier résultat concluant.

Le Héro ne passe plus derrière la table ! Mais ?! Qu’est-ce-que c’est que cette drôle de table coupée ? On a un soucis, ok mais lequel ? On dirait que la « fenêtre » du masque n’est pas au bon endroit, mais pourquoi que dans la table orientée à l’Est (à gauche) ?

Pour déterminer ce qu’il se passe, j’ai essayé de dessiner le premier morceau, et on voit que le même problème apparait.

Tentons de voir à quoi ressemble chaque morceau sans considérer la case originale. Nous n’avons pas le même résultat, en vert on a une fenêtre aux bonnes dimensions et bien positionnée, en rouge non.

Pour tenter de comprendre, j’ai modifié la table Est en inversant son origine (en haut gauche au lieu de bas droite) et en modifiant son rendu. J’ai également ajouté un fond à l’image pour comprendre les dimensions gérées.

Inversons le rendu et affichons un fond à la transparence.

En rendu inversé pour la table Est ça fonctionne ! Mais pourquoi ? Qu’est-ce qui change ? Et là une théorie survient, le fait que la base soit au delà d’une distance de case dans l’image quand on calcule le masque, provoque ce décalage. Je vais vous passer les calculs et les correctifs spécifiques au masque, mais en résumé, on doit déplacer la coordonnée source dans l’image selon la théorie isométrique (par demi case en X/Y) mais aussi corriger le résultat par cette même théorie, car il faut rationaliser ce décalage au delà d’une distance d’une case. De plus ce dernier correctif doit être appliqué à l’inverse au positionnement de destination. J’ai tenté un dessin, mais même pour moi il n’a pas été simple de le schématiser pour le coder.

Résultat corrigé du rendu.

On a donc notre Héro devant la table, des tables bien positionnées (celle de l’Est a été déplacée pour les tests) et on a en même temps solutionné la lumière qui éclaire équitablement les 2 morceaux. Ceci est un effet de bord qui tombe à point et qui se base sur le fait que c’est la même instance du même Element qui est référencé dans les 2 cases et donc quand la lumière s’applique, c’est le plus proche qui est choisi et appliqué, par effet de propagation. Enfin, c’est ma déduction car je ne me suis pas amusé à le démontrer.

Bon ok j’ai regardé un petit peu en écrivant ces lignes, il se pourrait que la table aie une addition de quantité d’éclairage par case, il faudra donc vérifier ça et décider du comportement. Vu les formes découpées, je ne suis pas sûr qu’un éclairage non uni soit une bonne idée.

Et le raycasting ?

On y vient, comme dit plus haut, c’est ici que ça se passe. On est vite tenté de dire qu’on a fini car nos problèmes visuels s’en sont allés, mais que nenni, il nous en reste un beaucoup moins visible : le raycasting. Pour ceux qui n’ont pas suivi, le raycasting (lancé de rayon), permet de déterminer dans notre cas ce sur quoi on clic (quand on clic droit sur le coffre, la porte, ou même un arbre).

Donc ici, pour chaque case, il se croit être à l’origine et m’est avis que ça va pas nous aider. Il va falloir, car ce n’est point encore fait, lui expliquer à lui aussi le système de masque. Par extension, on pourra peut-être globaliser et « simplifier ».

Prochaines étapes

En me relisant, je remarque que je parle souvent de l’éditeur mais pas cette fois; ni du jeu que nous allons démarrer comme projet vivant de l’usage de notre moteur TARS, et oui toujours dans le monde de Nahyan, nous y reviendrons prochainement; Mais plutôt vous parler de système de particules et de miroirs qui me sont venu à l’esprit et tant qu’à faire, une démo technique avec une petite explosion dont chaque particule est luminescente et se reflète dans des miroirs…

Smooth crimilight

Hier nous parlions de lumière, de beaux éclairages et de couleurs. Aujourd’hui, nous allons parler de douceur et de déplacement, mais parlons aussi de Disco et d’épilepsie !

Actuellement, la lumière et ses effets sont actualisés à certains moments, nous évitons ainsi les calculs perpétuels inutiles. Mais nous aimerions que la lumière suive notre héro Squellettore quand il se déplace et non pas que quand il traverse une case, ce qui nous donne un clignotement de refresh global quand on change de case. Ça nous fait un effet rétro mais on peut mieux faire.

L’idée est de diviser la lumière en 2 dès que l’on commence à bouger. Une lumière à X% de là où on part et une seconde à Y% là où on va (la case d’à côté dans le sens actuel). La somme donnera un résultat équivalent mais permet de graduellement faire transiter la lumière d’une case à une autre en douceur.

En regardant l’image ci-dessus, en prenant les 2 points vert comme étant le centre de chaque case et en considérant 100% la distance entre les 2, on peut facilement se représenter un delta qui représente la quantité de déplacement au départ de l’origine. Ainsi, tant qu’on est sur la case d’origine on aura X% = 100% – delta et Y% = delta. Je vous épargne les quelques complexités et conditions dues à mon implémentation initiale.

C’est là que le Disco est né au fond d’un donjon… Ça c’est quand modifie la force de la lumière, entre autres… et ce n’est pas ce que l’on veut exactement. Il faudrait altérer la lumière par case, pas sa force.

Pour arriver à cela, j’ai dû améliorer les informations de lumière en ajoutant la notion d’intensité qui altère le résultat sur la force de la lumière. Vous me direz « c’est quoi la différence ? » et je vous dirais que la force donnera la distance à laquelle la lumière éclaire (5 cases par exemple pour Squellettore et 3 pour le coffre) tandis que l’intensité altère la valeur par case uniformément. C’est ce qui a permis de faire glisser la lumière d’une case à l’autre.

Oh douceur du déplacement progressif remplaçant le changement saccadé brutal. Joie !

À cela on ajoute un code refactorisé, simplifié, plus facile à suivre et documenté. Ahhh TODO que j’aime tant… te retirer. C’est bon, on la garde !

Illumineux

Ça y est, j’ai pris à bras le corps le sujet des lumières qui étaient encore à l’essai, ramasser les TODOs (pas tous) et figer dans le système et son POC, ces mécaniques.

Ainsi Squellettore notre héro et le coffre sont détectés dans la subsetMap comme éléments lumineux. Ils contiennent une ébauche d’informations de lumière à savoir une couleur, une force et un état allumé/éteint.

Là où nous appliquons une modification de luminosité (brightness) basé sur la distance de la source de lumière, dorénavant nous appliquons la lumière, c’est à dire sa couleur selon sa distance. Ainsi, notre magnifique shader qui permet cette opération est enfin utilisé par le système et non plus via mes essais manuels.

Évidemment cela ne s’est pas fait en 2 tours de cuillères à pot… Il a fallu définir une information de lumière, faire les fonctions d’usage, les modificateurs de variables, faire la différence entre être une lumière et illuminer, ce qui est fort important je vous l’assure pour le bon nommage de vos éléments; et comme dit ci-avant, la détection de ces lumières dans la map automatiquement. Ceci a engendré des correctifs bien utiles et des améliorations en retirant la redondance et simplifiant certains déroulements.

Ensuite nous avions des murs mal illuminés, c’est à dire que les murs, ou éléments de la zone, Sud et Est appartiennent à un gridBloc (carré sur le sol) mais en fait concernent leur case voisine, car vous les observez depuis une autre case, donc leur illumination ne doit pas se faire depuis la case les possédant mais bien par leur case voisine respective.

En bas à droite de l’image ci-dessus vous verrez un mur quasi noir derrière un mur éclairé. C’est du fait que l’élément est masqué virtuellement par un élément non traversable. En conséquence il ne reçoit aucune lumière et ne subit en fait que l’éclairage global. Cela relève du choix personnel lors de votre implémentation en utilisant le moteur, j’ai fait ce choix pour ma démo.

Un autre point que cet article va traiter est la rotation de Squellettore, notre intrépide héro, qui, tel un Derek Zoolander, peut enfin illustrer et parfaire son mouvement de rotation gauche ET droite !

Son déplacement, suite au résultat du pathfinder, est une suite de points à suivre tel Hansel et Gretel. Quand un changement de direction survient actuellement on applique la réorientation du personnage sans autre cérémonie. C’est là que ça commence, il faut s’insérer temporairement dans le mouvement du déplacement le temps d’animer une rotation puis de relancer le déplacement, le tout sans se planter.

Je vous passe la construction d’une technique de détection de direction de rotation, et les emmerdes dues aux croisements d’informations sur les animations en cours et les modifications de l’animation courante qui se chevauchaient, annulant ainsi ce qu’on essaye de faire.

Du coup, en s’abonnant à l’événement de fin d’animation et en détectant que l’on change de direction, l’on peut intercéder, couper le déplacement, lancer l’animation de rotation dans le bon sens, attendre la fin de celle-ci et une fois fait relancer la prochaine étape du déplacement. Aussi simple, même s’il m’aura fallu 6h pour affronter tous les soucis desquels je vous épargne un tantinet.

Il reste cependant à mieux gérer la rotation pour l’utiliser également quand on cible un objet à côté de nous pour interagir avec. Si on tourne en se déplaçant, pourquoi regarder d’un coup un objet à côté de nous.

Dans la gamme du « reste à faire », j’aimerai tenter d’adoucir le changement d’éclairage au déplacement, plus gourmand, complexe, mais pas forcément une mauvaise idée. À suivre donc.

On a donc ici, une amélioration, et non des moindres, de l’atmosphère de notre rendu, de la gestion générale des lumières et un essai d’animation intermédiaire qui convainc plus encore la démarche de notre héro.

Il y a toujours de quoi faire en éclairage, ombres et ombrages et effets visuels variés, on peut citer le normal mapping, des particules, des shaders d’altérations, et bien plus encore, mais tout cela relève surtout de ce que vous voulez faire dans votre projet. Le POC ici arrivent tout doucement à sa fin et un projet devrait débuter sous peu dès que j’aurais revu l’architecture globale et tenter d’éliminer les TODOs restant.

TARS n’est pas parfait, loin de là, mais il faut le mettre à l’épreuve et, au travers d’un projet, corriger et améliorer ce qui sera nécessaire.

C’est en pavant…

J’ai eu l’occasion de montrer une démo de TARS à un de mes professeurs et cela m’a amené à tenter de produire un nouveau type de rendu. Moi qui vente les mérites de la flexibilité de mon système, c’est l’occasion de le prouver… et de me prendre une petite baffe au moral en passant.

Reprendre un projet plus de 6 mois plus tard, même documenté, s’il a été pensé mais trop orienté (scène ISO), ben ça n’aide pas. Ici, il s’agit de faire une vue à 45° de face, une forme de 2.5D, à la Zelda (de l’époque) plus ou moins.

La baffe étant prise et acceptée, une petite demi journée de perdue, je rattaque l’exercice et produit un petit damier, un truc un peu dégueulasse que j’ai affiné avec Boudine en lui montrant. Je vous passe le détail esthétique. Parlons technique !

Côté technique, il s’agit de constater que la hiérarchie : Sprite > Sprite3D > Iso3D est trop spécifique et doit être éclatée. C’est un soucis qu’on a déjà eu et qui maintiens le fait que c’est là le nerf de la guerre structurelle avec GridBlock dont on parlera dans un second temps.

Si je souhaite afficher des bloc non isométrique, je vais partir dès lors sur un Sprite3D (qui doit être renommé en SpriteGl car on ne fait qu’utiliser l’accélération matérielle au final), mais de ce fait je n’ai pas accès à tout ce qu’Iso3D (> IsoGl) contient côté fonctionnalités qui restent valable pour mon histoire de bloc non isométrique.

Le bloc non isométrique dont on parle

Il faut donc se poser la question : est-ce qu’un bloc isométrique est un bloc non isométrique qui surcharge une partie des fonctionnalités ? Force de constater que la seule différence entre les 2 est un service de calcul de position, mais que tout le reste concorde. Donc je dois trouver une astuce.

Ensuite, comme dit plus haut, il y a les GridBlock, qui, initialement, représente une case de la grille en proposant plusieurs espace de stockage, fonction de l’usage comme avec IsoGridBlock, ce qui permet d’aider au rendu. Là encore il y a matière à ce demander si Iso et non iso n’ont pas quelque chose en commun et là aussi revoir qui va avec qui et comment.

Mais ce n’est pas tout, j’ai constaté une erreur de transparence d’image qui m’a replongé dans les shaders d’il y a un an, si vous vous rappelez… Nous avons Alpha et Brightness qui au final touche la même chose, d’une manière bornée différemment, en gros Alpha ne sert à rien, mais du coup il manque Opacity que j’ai ajouté et fait fonctionner et testé avec le title3D daaboo. Il faudra faire du nettoyage pour enlever Alpha.

Comme de bien entendu, je fais un POC dans un POC existant, donc c’est le bordel et c’est parfois sale, mais ça permet de voir ce qui devrait être généralisé et/ou corrigé. Par exemple on a un ElementProvider qui est une sorte de factory personnalisé fournissant les Element correctement typé (DoorElement, TreasureElement, …), mais plus basiquement, ça donne les Grid3DElement ou Iso3DElement et ça m’a permis de tester le fait d’avoir plusieurs providers et le comment du pourquoi ça fonctionne etc.

Ou encore, le fameux catalogue de références de contenu (les sprites), après autant de temps et vu le mélange du POC dans mon POC, cela relève de nouvelles questions, est-ce que le dataProvider est bien pensé, est-ce qu’on ne devrait pas être plus flexible, etc. Il y a le catalogue, la DTD que j’avais oublié et une liste d’éléments utilisable, une sorte de helper pour le chargement dans une scène. Si dans un même projet vous mélangé deux « trucs » il serait intéressant de permettre une meilleure séparation des fichiers, bien que techniquement vous feriez 2 projets séparés, donc je vais peut-être pas m’en occuper tout de suite.

Ceci dit, au final, il y a eu besoin de peu pour réussir à sortir quelque chose. Une scène représentant un niveau, avec des éléments de décors, une flèche animée (mouvement haut bas) et une brillance au dessus du trou qui oscille dans son opacité et son brightness, autant mettre le truc à l’épreuve ah ah.

J’avais prévenu c’est pas super joli, et vu le temps que je perd à essayer de faire le minimum syndical du joli, je vais rester concentrer sur mon code.

L’objectif actuel est atteint, ça tient la route fonctionnellement et « il n’y pas plus qu’à » corriger et améliorer ce dont j’ai parlé dans cet article.

On pourrait ajouter un sprite pour parfaire la démo mais aussi revoir le système de dessin de la grille en partant sur des compositions comme les anciens jeux, permettant ainsi de faire des bords aux blocs dessinés, ce qui « finirait » proprement le dessin, masquant la grille carrée au profit de fioritures esthétique. L’avantage est que c’est plus joli, mais plus compliqué pour un rendu et encore moins dynamique par rapport à ce qu’on essaye de faire avec TARS, on est dans un beau niveau de technicité, mais si on fait une carte vraiment plate en vue du haut à la Zelda ou Graveyard Keeper et autres alors on pourrait penser à ça. Peut-être un défi à faire pour le fun. Bref c’est pas le même challenge ni le même objectif, mais pourquoi pas…