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