Apprendre Pyxel : fuir le danger et game over

Apprendre Pyxel : fuir le danger et game over

Troisiè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é).

Beholder Cette démo progresse en difficulté, avec de nouveaux concepts Python, et le risque de se faire tuer son personnage.

La démo va au-delà du monde des sprites, en traçant une ligne (qui reste heureusement pixelisée).

Démo

Dans cette démo, un héros explore un donjon, et rencontre un beholder. Le monstre va viser, tirer, tandis que le héros va tenter de l’esquiver.

Le joueur va utiliser son clavier pour contrôler le héros.

Assets

Beholder

Les sprites sont rangés : par lignes, les personnages (et leurs états), par colonnes, la direction : bas (face), gauche, droite, haut (dos).

Le guerrier a un sprite de plus : l’électrocution.

Le héros se balade

Le guerrier peut se balader avec les flèches du clavier, il se tourne dans la bonne direction.

Pythoneries

Compréhension de liste

Pour créer des collections (dont les listes), Python dispose d’une syntaxe condensée.

Le code suivant :

a = [i for i in range(4)]

Est équivalent à :

a = []
for i in range(4):
    a.append(i)

Techniquement, ajouter des éléments à une liste est plus couteux que la syntaxe dense.

Démo

Une constante, TRANSPARENT, est utilisée pour désigner la couleur de transparence, sans avoir à la recopier dans tout le code et pour faciliter la lecture.

Un personnage a un attribut speed qui correspond à son déplacement en pixels, à chaque frame.

Les images sont ordonnées, l’attribut angle décrit simultanément vers où regarde le personnage, et son rang dans la ligne d’images.

La méthode move déplace le personnage d’un pas (l’attribut speed), dans la bonne direction.

Pour éviter une ribambelle de if, un multiplicateur est utilisé :

  • 0 : rien
  • 1 : en avant
  • -1 : en arrière

Par contre, pour éviter au personnage de sortir de l’écran, il faut une tartine de if.

La class App est très classique, les flèches du clavier sont utilisées pour déplacer (avec move) le personnage dans la bonne direction.

import pyxel

TRANSPARENT = 11


class Hero:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.speed = 4
        self.angle = 2
        self._images = [(i * 16, 32, 16, 16, TRANSPARENT) for i in range(4)]

    def move(self, angle: int):
        # 0: don't move
        # 1: move forward
        # -1: move forward
        # angle : ↓←→↑
        self.angle = angle
        self.x += [0, -1, 1, 0][angle] * self.speed
        self.y += [1, 0, 0, -1][angle] * self.speed
        # Can't escape the screen
        if self.x < 0:
            self.x = 0
        elif self.x > pyxel.width - 16:
            self.x = pyxel.width - 16
        if self.y < 0:
            self.y = 0
        elif self.y > pyxel.height - 16:
            self.y = pyxel.height - 16

    def image(self):
        return self._images[self.angle]

    def draw(self):
        pyxel.blt(self.x, self.y, 0, *(self.image()))


class App:
    def __init__(self):
        pyxel.init(160, 120, title="The hero explores")  # width, height, title
        pyxel.load("beholder.pyxres")  # Load the assets
        self.hero = Hero(32, 104)
        pyxel.run(self.update, self.draw)  # Starts Pyxel loop

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

        if pyxel.btn(pyxel.KEY_DOWN):
            self.hero.move(0)
        if pyxel.btn(pyxel.KEY_LEFT):
            self.hero.move(1)
        if pyxel.btn(pyxel.KEY_RIGHT):
            self.hero.move(2)
        if pyxel.btn(pyxel.KEY_UP):
            self.hero.move(3)

    def draw(self):
        pyxel.cls(13)  # Clear screen
        self.hero.draw()


App()

Le héros de ballade devant le beholder qui le suit du regard

Pythoneries

Héritage de class

Deux personnages sont décrits par deux class, et comme elles ont pas mal de choses en commun (ce sont des sprites avec une direction).

Une class peut hériter d’une autre class. La class parente définit des attributs et des méthodes, les class enfants peuvent avoir leurs propres attributs/méthodes ou surcharger un élément de la class parente.

class Parent:
  def hello(self, txt):
    print("hello", txt)

class Child(Parent):
  def hello(self, txt):
    print("wazza", txt)

Il est possible pour une méthode surchargée d’appeler la méthode d’un ancêtre avec la fonction super().

class Parent:
    def say(self, txt):
        return "I say " + txt

class Child(Parent):
    def say(self, txt):
        return super().say(txt) + ", or not"

Pour obliger à surcharger une méthode, la méthode de la class parente doit lever une erreur NotImplementedError.

class Parent:
    def override_me:
      raise NotImplementedError

Assignation multiple

La syntaxe d’assignation d’une variable dispose aussi d’une syntaxe dense, avec des raccourcis.

Ici, seul l’assignation multiple est utilisé, dans sa forme la plus simple :

a, b = 1, 2

Ce qui équivaut à :

a = 1
b = 2

Typage

Python a du typage pour ses valeurs, mais pas de typage fort : une variable peut être réassignée avec une valeur de type différent.

Python utilise des indices (hints en VO) pour expliciter le type d’une variable. Python tient à garder le comportement historique : les indices ne sont pas pris en compte par l’interpréteur. Le typage est destiné (entre autres) à l’analyse statique (un robot lit et donne son avis sur votre code).

Les éditeurs modernes prennent en compte le typage pour ses suggestions, et pour raler (souligner en rouge) sur les parties de votre code qui ne respectent pas le typage.

a :int # a is an integer

Opérateur ternaire

Python dispose d’une syntaxe dense pour assigner une valeur avec une condition.

if dx > 0:
    angle = 1
else:
    angle = 2

Devient:

angle = 1 if dx > 0 else 2

Valeur 1, if, clause, else, valeur 2.

Attention à ne pas abuser des syntaxes denses qui peuvent anéantir la lisibilité.

Démo

Les comportements de base de Hero sont maintenant dans la class Sprite qui sera la class parente de Hero et Beholder.

La class Beholder a un attribut state, et utilise 2 lignes d’images. state permet de savoir quelle ligne utiliser, la direction reste la colonne comme dans la démo précédente (utilisée dans la méthode image).

Le beholder peut faire les gros yeux à une cible avec la méthode watch.

Un peu de maths : on calcule le delta entre les deux personnages sur l’axe horizontal et vertical, dx et dy. Si la distance horizontale est supérieure à la distance verticale, le beholder regarde à l’horizontal. Si dx est positif, il regarde à gauche, sinon à droite. Même sport pour l’axe vertical.

Dans la class App, la méthode update spécifie que la touche espace active l’état LOADING du beholder (il regarde en l’air), et qu’il surveille le héros.

import pyxel

TRANSPARENT = 11
# Beholder states
LOADING = 0
WAITING = 3


class Sprite:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.speed: int
        self.angle: int  # bottom, left, right, top
        self.state: int

    def move(self, angle: int):
        # 0: don't move
        # 1: move forward
        # -1: move forward
        # angle : ↓←→↑
        self.angle = angle
        self.x += [0, -1, 1, 0][angle] * self.speed
        self.y += [1, 0, 0, -1][angle] * self.speed
        # Can't escape the screen
        if self.x < 0:
            self.x = 0
        elif self.x > pyxel.width - 16:
            self.x = pyxel.width - 16
        if self.y < 0:
            self.y = 0
        elif self.y > pyxel.height - 16:
            self.y = pyxel.height - 16

    def image(self) -> tuple[int, int, int, int, int]:
        # What is the current image
        raise NotImplementedError

    def draw(self):
        pyxel.blt(self.x, self.y, 0, *(self.image()))


class Beholder(Sprite):
    def __init__(self, x, y):
        super().__init__(x, y)
        self.angle = 1
        self.speed = 2
        self.state = WAITING
        # The beholder can be normal : waiting, aiming, moving…
        self._main_images = [(i * 16, 0, 16, 16, TRANSPARENT) for i in range(4)]
        # The beholder loads its death ray
        self._loading_images = [(i * 16, 16, 16, 16, TRANSPARENT) for i in range(4)]

    def watch(self, target: Sprite):
        dx, dy = self.x - target.x, self.y - target.y
        if abs(dx) > abs(dy):
            self.angle = 1 if dx > 0 else 2
        else:
            self.angle = 3 if dy > 0 else 0

    def image(self):
        if self.state == LOADING:
            return self._loading_images[self.angle]
        else:
            return self._main_images[self.angle]


class Hero(Sprite):
    def __init__(self, x, y):
        super().__init__(x, y)
        self.speed = 4
        self.angle = 2
        self._images = [(i * 16, 32, 16, 16, TRANSPARENT) for i in range(4)]

    def image(self):
        return self._images[self.angle]


class App:
    def __init__(self):
        # width, height, title
        pyxel.init(160, 120, title="The beholder watch the hero")
        pyxel.load("beholder.pyxres")  # Load the assets
        self.hero = Hero(32, 104)
        self.beholder = Beholder(128, 32)
        pyxel.run(self.update, self.draw)  # Starts Pyxel loop

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

        if pyxel.btn(pyxel.KEY_DOWN):
            self.hero.move(0)
        if pyxel.btn(pyxel.KEY_LEFT):
            self.hero.move(1)
        if pyxel.btn(pyxel.KEY_RIGHT):
            self.hero.move(2)
        if pyxel.btn(pyxel.KEY_UP):
            self.hero.move(3)

        if pyxel.btn(pyxel.KEY_SPACE):
            self.beholder.state = LOADING
        else:
            self.beholder.state = WAITING
        self.beholder.watch(self.hero)

    def draw(self):
        pyxel.cls(13)  # Clear screen
        self.hero.draw()
        self.beholder.draw()


App()

Le beholder essaye de foudroyer le héros

Pythoneries

Pas de nouveautés Python dans cette dernière démo, il y a suffisamment de maths pour remplir les lignes de code supplémentaires.

Démo

Le héros et le monstre ont maintenant l’attribut state qui désigne (évidemment) l’état.

Les sprites du beholder sont rangés sur deux lignes:

  • La seconde quand il prépare son tir (il regarde en l’air)
  • La première pour les autres états (attente et tir).

Le héros a une colonne de plus, l’état électrocuté, qui n’a pas de direction.

Tout ça est traité par la méthode image.

Pour laisser une chance au héros, le beholder vise la position actuelle du héros, dans la méthode aim, attend un peu, le temps de charger son rayon de la mort, puis tire là où ETAIT le héros, méthode shoot.

Le rayon est instantané, mais il tire en retard. Si le héros ne s’est pas assez déplacé, il meurt.

Le départ et l’arrivée du rayon se font depuis le centre des sprites, et non depuis le coin haut gauche.

Pour savoir si le héros est touché, on ne se contente pas de son centre (touché en plein cœur ?), mais on détermine la face de son sprite qui est face au beholder. Si le rayon ne touche ne serait-ce qu’un de ses orteils, le personnage est frit.

Le rayon est interrompu par le héros, s’il le touche, sinon, il s’arrête au bord de l’écran.

Pour ne pas se perdre dans des if illisibles, un tableau de 3x3 cases est utilisé : - la ligne du milieu correspond à un tir vertical - la colonne du milieu a un tir horizontal - le centre a un tir dans son propre pied - les autres cases correspondent aux combinaisons de gauche/droite, haut/bas

C’est maintenant que débute la platrée de lignes de maths. On commence par calculer la pente du tir, puis on gère le cas où la pente est nulle, puis on vérifie si le héros est touché latéralement, ou verticalement. S’il est touché, le rayon s’arrête au point d’impact, le héros passe à l’état ZAPPED et le rayon change de couleur.

Le tir est dessiné avec la fonction line de Pyxel.

Dans le App, point de maths. Les flèches ne font plus bouger le héros quand il agonise. Au bout de 30 frames de douleur, le jeu quitte.

Le beholder cycle toutes les 40 frames, il vise à la frame 10, tire à la frame 30.

import pyxel

TRANSPARENT = 11

# Beholder states
LOADING = 0
FIRING = 1
MOVING = 2
WAITING = 3

# Hero states
HEALTHY = 0
ZAPPED = 1


class Sprite:
    def __init__(self, x, y):
        self.x = x
        self.y = y
        self.speed: int
        self.angle: int  # bottom, left, right, top
        self.state: int

    def move(self, angle: int):
        # 0: don't move
        # 1: move forward
        # -1: move forward
        # angle : ↓←→↑
        self.angle = angle
        self.x += [0, -1, 1, 0][angle] * self.speed
        self.y += [1, 0, 0, -1][angle] * self.speed
        # Can't escape the screen
        if self.x < 0:
            self.x = 0
        elif self.x > pyxel.width - 16:
            self.x = pyxel.width - 16
        if self.y < 0:
            self.y = 0
        elif self.y > pyxel.height - 16:
            self.y = pyxel.height - 16

    def image(self) -> tuple[int, int, int, int, int]:
        # What is the current image
        raise NotImplementedError

    def draw(self):
        pyxel.blt(self.x, self.y, 0, *(self.image()))


class Beholder(Sprite):
    def __init__(self, x, y):
        super().__init__(x, y)
        self.angle = 1
        self.speed = 2
        self.state = WAITING
        # The beholder can be normal : waiting, aiming, moving…
        self._main_images = [(i * 16, 0, 16, 16, TRANSPARENT) for i in range(4)]
        # The beholder loads its death ray
        self._loading_images = [(i * 16, 16, 16, 16, TRANSPARENT) for i in range(4)]
        # Where the beholder aims
        self._aim_dx: int
        self._aim_dy: int

    def image(self):
        if self.state == LOADING:
            return self._loading_images[self.angle]
        else:
            return self._main_images[self.angle]

    def watch(self, target: Sprite):
        dx, dy = self.x - target.x, self.y - target.y
        if abs(dx) > abs(dy):
            self.angle = 1 if dx > 0 else 2
        else:
            self.angle = 3 if dy > 0 else 0

    def aim(self, target: Sprite):
        self.state = LOADING
        self._aim_dx, self._aim_dy = self.x - target.x, self.y - target.y

    def shoot(self, target: Sprite):
        self.state = FIRING
        # The ray starts avec self.x, self.y
        x_end: float  # horizontal end of the ray
        y_end: float  # vertical end of the ray

        # Center of the shooter
        shooter_x, shooter_y = self.x + 8, self.y + 8
        # Center of the target
        target_x, target_y = target.x + 8, target.y + 8
        # Side hit by the shot
        target_side_x = target_x + pyxel.sgn(self._aim_dx) * 8
        target_side_y = target_y + pyxel.sgn(self._aim_dy) * 8

        # Where the ray ends when it misses ?

        cross = [  # x, y
            [(pyxel.width, pyxel.height), (pyxel.width, shooter_y), (pyxel.width, 0)],
            [(shooter_x, pyxel.height), (None, None), (shooter_x, 0)],
            [(0, pyxel.height), (0, shooter_y), (0, 0)],
        ]
        x_end, y_end = cross[pyxel.sgn(self._aim_dx) + 1][pyxel.sgn(self._aim_dy) + 1]

        if x_end is None:  # It shoots its own foot
            return

        x_hit, y_hit = shooter_x, shooter_y
        if self._aim_dx != 0:
            slope = self._aim_dy / self._aim_dx
            y_end = (x_end - shooter_x) * slope + shooter_y
            y_hit = (target_side_x - shooter_x) * slope + shooter_y
            if slope != 0:
                x_end = (y_end - shooter_y) / slope + shooter_x
                x_hit = (target_side_y - shooter_y) / slope + shooter_x
        if abs(target_x - x_hit) <= 8:
            x_end, y_end = x_hit, target_side_y
            target.state = ZAPPED
        if abs(target_y - y_hit) <= 8:
            x_end, y_end = target_side_x, y_hit
            target.state = ZAPPED
        bolt_color = 10 if target.state == ZAPPED else 6
        pyxel.line(shooter_x, shooter_y, x_end, y_end, bolt_color)


class Hero(Sprite):
    def __init__(self, x, y):
        super().__init__(x, y)
        self.speed = 4
        self.angle = 2
        self._images = [(i * 16, 32, 16, 16, TRANSPARENT) for i in range(4)]
        self._zapped_images = [
            (64, 32, 16, 16, TRANSPARENT),
            (64, 32, -16, 16, TRANSPARENT),
        ]
        self.state = HEALTHY

    def image(self):
        if self.state == HEALTHY:
            return self._images[self.angle]
        elif self.state == ZAPPED:
            return self._zapped_images[pyxel.frame_count % 2]


class App:
    def __init__(self):
        # width, height, title
        pyxel.init(160, 120, title="The beholder and its beam of death")
        pyxel.load("beholder.pyxres")  # Load the assets
        self.hero = Hero(32, 104)
        self.beholder = Beholder(128, 32)
        self.game_over = 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()

        if self.hero.state == ZAPPED:  # Everything is frozen when the hero is zapped
            if self.game_over == 0:
                self.game_over = pyxel.frame_count
            else:
                if pyxel.frame_count - self.game_over == 30:
                    pyxel.quit()
        else:
            if pyxel.btn(pyxel.KEY_DOWN):
                self.hero.move(0)
            if pyxel.btn(pyxel.KEY_LEFT):
                self.hero.move(1)
            if pyxel.btn(pyxel.KEY_RIGHT):
                self.hero.move(2)
            if pyxel.btn(pyxel.KEY_UP):
                self.hero.move(3)

            self.beholder.watch(self.hero)
            if pyxel.frame_count % 40 == 10:  # Every 50 frames, the beholder aims
                self.beholder.aim(self.hero)
            if pyxel.frame_count % 40 == 30:  # 25 later, it shoots
                self.beholder.state = FIRING

    def draw(self):
        pyxel.cls(13)  # Clear screen
        self.hero.draw()
        if self.beholder.state == FIRING:
            self.beholder.shoot(self.hero)
        self.beholder.draw()
        if self.hero.state == ZAPPED:
            pyxel.text(60, 10, "GAME OVER", 7, None)


App()

Les sources de Beholder sont disponible sur Github.

Vous pouvez tester la version en ligne de Démo Beholder en ligne

La suite

Le tir tue d’un coup le héros, on pourrait utiliser un système de barre de vie qui décroit à chaque tir, ainsi qu’une barre de chargement pour la préparation du tir du beholder.

Le héros ne peut pas riposter, quelle injustice. Il faudrait des sprites avec avec une épée qui frappe (une fente avant ?), avec un temps (court) d’attente lors d’une attaque qui l’empêche de se déplacer.

Un son de HUUUUUUM lors du chargement, puis de ZAAAAP lors du tir, un GZZZZZ lors de l’électrocution me semble indispensable.

blogroll

social