Apprendre Pyxel : carte de tuiles et collision

Apprendre Pyxel : carte de tuiles et collision

Quatrième chapitre de l’apprentissage de Pyxel, framework de retro game avec son moteur en Rust (pour la fluidité) et son API en Python (pour la simplicité).

Cette démo explore le jeu de plateforme avec une carte de tuiles créée dans un logiciel spécifique.

Démo

Jones Le héros, nommé Jones, explore les dangereuses ruines d’un temple oublié.

Assets

Jusqu’à présent, les démos n’utilisaient que peu de graphismes, mais là, pour expliquer l’utilisation des cartes et des collisions, il va falloir plus de pixels, beaucoup plus.

Pour le défi, et pour avoir un aspect encore plus vintage, les sprites seront cette fois-ci de 8 pixels de côté.

Carte de tuiles

Historiquement, pour gérer l’affichage avec très peu de RAM et une carte graphique antique, tout était tuiles. L’écran est alors rempli de gros carrés qui peuvent être répétés.

Mario Game Boy La Game Boy, par exemple, utilise un Picture Processing Unit capable de gérer jusqu’à 40 sprites de 8 x 8 ou 8 x 16 sur un écran de 160 x 144 pixels, sur 3 calques, et seul le dernier permet de positionner le sprite au pixel près.

Pyxel se contente de rendre hommage aux vieilles consoles, la plupart des contraintes sont des conventions, comme la palette réduite ou le tout petit écran.

Les cartes permettent de composer une grande image (et même plus grande que l’écran, permettant ainsi de déplacer la caméra pour un effet de scroll) à partir de tuiles. Certaines tuiles , par convention, peuvent être considérées comme solides, pour que le personnage puisse marcher dessus et ne pas tomber.

Pyxel (arbitrairement) ne peut gérer que 3 cartes.

Concrètement, les cartes permettent de travailler à 3 sans se marcher sur les pieds:

  • Le graphiste crée le jeu de tuiles et le peaufine ensuite.
  • Le game designer compose la carte à partir du jeu de tuiles pour avoir quelque chose de jouable et d’intéressant.
  • Le développeur utilise la carte pour vérifier les actions (courir, sauter…) et les comportements (tomber, se cogner dans un mur…) du personnage.

Pyxel fournit un éditeur de carte, mais il est un peu rugueux (comme tout ce que propose son éditeur d’assets).

Tiled est un éditeur de cartes un poil plus civilisé, et il est utilisable par quantité de moteurs de jeux. Attention, Pyxel ne sait pas utiliser toutes les options de Tiled, ni tous les formats de sortie.

Tiled

Pyxel gère les cartes TMX avec les calques (pas plus de 3).

Les TSX (jeu de tuiles) doivent être embarqués dans la carte TMX.

Les tuiles non définies dans la carte seront remplacées par le premier bloc du jeu de tuiles.

Pour que la carte gère la transparence (pour avoir un fond uni, par exemple), il faut utiliser une tuile uniforme, dont la couleur sera utilisée comme transparence.

La palette de couleurs utilisée par Pyxel sera issue de la carte.

Nouvelle carte

Nouveau fichier : ctrl-N

Orientation
orthogonale
Format de calque des tuiles
CSV
Ordre d’affichage des tuiles
En bas à droite
Taille de la carte
20 tuiles x 15 tuiles, soit 160 x 120 pixels
Taille des tuiles
8 x 8 pixels

Le jeu de tuiles doit être disponible pour créer une carte, mais ne vous inquiétez pas, il est possible de modifier le PNG, Tiled le rechargera tout seul (sinon, un ctrl-r fera l’affaire).

Tout en bas à droite, le bouton Nouveau jeu de tuiles…

Nom
mettez ce que vous voulez, sinon, le nom par défaut est basé sur le nom du fichier.
Type
Basé sur l’image du jeu de tuiles
Embarquer la carte
✔︎
Source
le chemin du PNG, sélectionnable via [ Parcourir… ]
Largeur/Hauteur
8 pix
Marge/espacement
0 pix
Utiliser la couleur transparente
✔︎ puis le second carré, qui fait apparaitre une mini-carte avec le PNG pour sélectionner la couleur.

La carte apparait dans le coin bas-droite, microscopique, il faut zoomer avec le menu déroulant, pour atteindre au moins les 400%.

Créer sa carte

La grille dans la zone principale, trop petite et mal centrée. ctrl-+ et ctrl-- pour zoomer, sinon, on peut tricher avec ctrl-:. Pour les adeptes du menu, ces réglages sont rangés dans Vue.

Sélectionnez une tuile dans le jeu de tuiles en cliquant dessus, elle devient grisée. Avec l’outil Tampon (raccourci : B), vous pouvez tamponner de la tuile à volonté sur la carte. Pour l’esthétique, pour les plateformes, faites attention à commencer par une tuile début, terminer par une tuile fin, et au milieu des tuiles milieu.

Il ne faut pas utiliser le personnage (qui sera utilisé comme sprite), la carte ne doit contenir que des éléments statiques.

Enregistrer le fichier, ctrl-s, au format TMX.

Tiled

Tuiles

La carte a besoin d’éléments regroupés dans un fichier PNG. Sélectionnez un éditeur spécialisé dans le pixel-art et non un gros éditeur qui sera frustrant (Krita, Gimp, PowerPoint…).

Comme outil libre et multi-plateformes, je vous propose :

  • Piskel est un éditeur web. OK, il est abandonné depuis 6 ans, mais il fonctionne bien, et il est possible de sauvegarder les fichiers source en local.
  • Pikopixel est un éditeur conçu pour GNUStep, mais qui fonctionne sur la plupart des plateformes.

Il doit surement en exister d’autres, dont certains propriétaires, comme Pixen.

Utilisez une palette de couleur réduite, ça aidera à rendre l’ensemble cohérent, et ça rend hommage aux anciens. Il y a des sites pour ça, comme Colorkit.

Décors

Le plus important dans ce type de jeu sont les plateformes. Il faut donc un début, un milieu, une fin de plateforme. Pour simplifier la gestion des collisions, il faut que ces blocs utilisent au maximum les 8 pixels des sprites.

Jeu de temples

Il est aussi possible d’utiliser des tuiles pour décorer, qui ne gênent pas la progression du personnage.

Le jeu de tuiles (32 x 32 pixels) de la démo est organisé par ligne :

  • La plateforme : début, milieu, fin
  • Des décors : herbes, torche, vase
  • Le héros : marche 1, marche 2, tombe, saute

Les tuiles sont transparentes, mais pour la lisibilité, le héros a un contour noir (la couleur du fond dans le jeu), pour se détacher du fond.

Les ruines vides

On va commencer tranquillement, en se contentant d’afficher le décor.

Le PNG est chargé dans l’emplacement 1 de la banque d’images.

Le calque 0 du TMX est chargé dans l’emplacement 1 de la banque de cartes.

L’image 1 est connectée à la carte 1.

Dans draw, la carte est affichée. Le troisième élément désigne la carte 1. Le dernier élément désigne la couleur de transparence.

import pyxel


class App:
    def __init__(self):
        pyxel.init(160, 120, title="The temple")  # width, height, title
        pyxel.images[1] = pyxel.Image.from_image("temple.png", incl_colors=True)
        pyxel.tilemaps[1] = pyxel.Tilemap.from_tmx("temple.tmx", 0)
        pyxel.tilemaps[1].imgsrc = 1  # The map uses this image for its sprites

        pyxel.run(self.update, self.draw)  # Starts Pyxel loop

    def update(self):
        if pyxel.btnp(pyxel.KEY_Q):  # Hit Q to quit
            pyxel.quit()

    def draw(self):
        pyxel.cls(9)  # Clear screen
        # x, y, tm, u, v, w, h
        pyxel.bltm(0, 0, 1, 0, 0, pyxel.width, pyxel.height, 0)


App()

Le temple vide

Il y a une astuce pour obtenir la palette et connaitre le rang des couleurs : Pyxel fournit un raccourci pour sauvegarder la palette de couleurs sous forme de PNG : Ctrl-Shift-Alt-0.

Palette

Tomber dans le vide

Pour bien isoler les parties du code, une class abstraite Sprite est utilisée, ainsi qu’un moteur (primitif) de physiques.

Collisions connait la liste des tuiles solides (leurs coordonnées dans le jeu de tuiles), qui ne peuvent pas être traversées. Dans ce jeu, point de mur, juste du sol pour atterrir avec grâce. Pour éviter les références croisées, Collisions est un attribut de App confié à la méthode update du Sprite.

Le personnage (un Sprite) va pouvoir s’avancer dans le vide au bord d’une plateforme, jusqu’à ce que son dernier pixel soit au-delà du dernier pixel de la plateforme.

import pyxel


class Sprite:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.direction = 1  # 1: -> -1: <-


class Physics:
    def __init__(self, solids: list[tuple[int, int]]):
        "List of solid tiles"
        self.solids = solids
        self.map = 1

    def floor(self, sprite: Sprite) -> bool:
        "Does the sprite have floor under its feet?"
        if sprite.direction == -1:  # the tile before
            x = pyxel.ceil(sprite.x / 8)
        else:  # the tile after
            x = pyxel.floor(sprite.x / 8)
        # the tile under the feet of Jones
        tile = pyxel.tilemaps[self.map].pget(x, sprite.y // 8 + 1)
        return tile in self.solids

    def too_low(self, sprite: Sprite) -> bool:
        "Does the sprite fall under the bottom of the screen?"
        return sprite.y >= (pyxel.height - 8)

Jones hérite de Sprite. De manière similaire aux chapitres précédents, Jones va gérer un state qui va permettre de choisir la bonne image, et de gérer la physique, essentiellement les sauts et les chutes.

C’est le héros qui va gérer l’affichage clignotant du tant redouté GAME OVER.

TRANSPARENT = 0

WAITING = 0
WALKING = 1
FALLING = 2
JUMPING = 3
DEAD = 4


class Jones(Sprite):
    def __init__(self, x, y):
        super().__init__(x, y)
        self.images_right = [(i * 8, 16, 8, 8, TRANSPARENT) for i in range(4)]
        self.images_left = [(i * 8, 16, -8, 8, TRANSPARENT) for i in range(4)]
        self.state = WAITING
        self.how_high = 0
        self.death_time = 0

    def image(self) -> tuple[int, int, int, int, int]:
        if self.direction == 1:
            images = self.images_right
        else:
            images = self.images_left

        if self.state == FALLING:
            return images[2]
        if self.state == WALKING:
            return images[(pyxel.frame_count // 5) % 2]  # move legs every 5 frames
        if self.state == JUMPING:
            return images[3]
        # WAITING
        return images[0]

    def physic(self, world: Collisions):
        if self.state == DEAD:  # there is no physics when you are a soul
            return
        if self.state == JUMPING:
            if self.y > self.how_high:
                self.y -= 2
                self.x += self.direction
            else:  # start falling soon
                self.state = WAITING
            return
        if world.too_low(self):
            self.death_time = pyxel.frame_count + 30
            self.state = DEAD
            return
        if world.floor(self):
            if self.state == FALLING:
                self.state = WAITING  # soft landing
            return
        else:
            self.state = FALLING
            self.y += 2

    def move(self, direction):
        if direction == 0:
            self.state = WAITING
        else:
            self.state = WALKING
            self.direction = direction
            self.x += self.direction * 2

    def jump(self, direction):
        self.state = JUMPING
        self.how_high = self.y - 16
        self.direction = direction

    def update(self, world: Collisions):
        if self.state in (WAITING, WALKING):
            if pyxel.btn(pyxel.KEY_RIGHT):
                dx = 1
            elif pyxel.btn(pyxel.KEY_LEFT):
                dx = -1
            else:
                dx = 0

            if pyxel.btnp(pyxel.KEY_UP):
                self.jump(dx)
            else:
                self.move(dx)
        if self.state == DEAD and pyxel.frame_count > self.death_time:
            pyxel.quit()
        self.physic(world)

    def draw(self):
        if self.state == DEAD:
            if pyxel.frame_count % 10 < 5:
                pyxel.text(70, 10, "GAME OVER", 8, None)
        else:
            pyxel.blt(self.x, self.y, 1, *(self.image()))

L’application, App, est super classique. Elle va charger les assets, instancier le moteur de physique avec les trois premières tuiles comme étant solides, puis instancier le héros, qui commence la partie sur une chute.

class App:
    def __init__(self):
        pyxel.init(160, 120, title="The temple")  # width, height, title
        pyxel.images[1] = pyxel.Image.from_image("temple.png", incl_colors=True)
        pyxel.tilemaps[1] = pyxel.Tilemap.from_tmx("temple.tmx", 0)
        pyxel.tilemaps[1].imgsrc = 1  # The map uses this image for its sprites

        self.world = Physics([(0, 0), (1, 0), (2, 0)])

        self.jones = Jones(8, 0)

        pyxel.run(self.update, self.draw)  # Starts Pyxel loop

    def update(self):
        if pyxel.btnp(pyxel.KEY_Q):  # Hit Q to quit
            pyxel.quit()
        self.jones.update(self.world)

    def draw(self):
        pyxel.cls(9)  # Clear screen
        # x, y, tm, u, v, w, h
        pyxel.bltm(0, 0, 1, 0, 0, pyxel.width, pyxel.height, 0)
        self.jones.draw()


App()

Les sources de Jones sont disponible sur Github.

La suite

Le personnage pourrait courir (en plus de marcher) pour pouvoir faire des sauts plus longs.

Il pourrait aussi s’accrocher quand il finit son saut sur un mur, puis remonter sur la plateforme (comme dans Prince of Persia).

Il pourrait s’arrêter en dérapant juste au bord du vide (comme dans Flashback).

Pour l’ambiance, ce serait bien d’avoir la flamme des torches animées (en recouvrant l’image de fond par un autre sprite).

Les herbes pourraient onduler au passage du héros (et continuer quelques secondes après).

blogroll

social