Déplacements avancés
Dans le chapitre sur le shooter, nous avons vu comment nous déplacer pixel par pixel, mais sans collision sur la carte. Dans le chapitre du jeu d'aventure, nous avons vu comment avoir des collisions sur la carte avec un déplacement case par case. Mais comment mêler le meilleur des deux mondes, autrement dit déplacer son personnage pixel par pixel tout en respectant les collisions sur la grille de la carte ? C'est ce que nous allons voir dans cette étape !
La méthode simple
Dans cette démo, mon personnage est moins large qu'une case ; vous pouvez donc vous déplacer de gauche à droite dans le petit couloir !
Vous pouvez lire le contenu complet de cette cartouche à cette adresse. Notez que pour plus de clarté, le code a été écrit dans un éditeur externe et les lignes sont trop longues pour être agréables à lire dans la fenêtre de PICO-8. Cela n'empêche pas le jeu de fonctionner parfaitement !
Pour commencer, préparez un nouveau projet en réalisant quelques sprites, dont un obstacle avec le flag 0, puis dessinez-les sur la carte. Notre game loop sera toute simple :
function _init()
create_player()
end
function _update()
player_movement()
end
function _draw()
cls()
map()
spr(p.sprite, p.x, p.y)
end
Le personnage aura de nouvelles propriétés. La largeur de mon sprite est de 7 pixels et et sa hauteur est de 8, mais vous pouvez changer ces valeurs librement en fonction de la taille de votre sprite : vous n'êtes plus contraint·e par la grille de la carte !
function create_player()
p = {
x = 24, y = 24, -- position au pixel près
dx = 0, dy = 0, -- vitesse actuelle du perso
sprite = 6,
speed = 1,
w = 7, h = 8 -- largeur et hauteur
}
end
Notez que pour trouver le point à droite et en bas du sprite, il faudra faire p.x+p.w-1
et p.y+p.h-1
, sinon, on sera un pixel trop loin. Souvenez-vous en !
C'est dans la fonction player_movement()
que se déroulera toute la magie du système. On commence par changer la vitesse du personnage d'après les touches appuyées :
function player_movement()
if (btn(⬅️)) p.dx -= p.speed
if (btn(➡️)) p.dx += p.speed
if (btn(⬆️)) p.dy -= p.speed
if (btn(⬇️)) p.dy += p.speed
Ensuite, on vérifie si on peut bouger à l'endroit souhaité en donnant à une fonction can_move()
un carré qui représente la hitbox du personnage. can_move()
prendra donc quatre arguments : un point x, y ainsi que la largeur et la hauteur du carré.
if can_move(p.x+p.dx, p.y, p.w, p.h) then
p.x += p.dx
end
if can_move(p.x, p.y+p.dy, p.w, p.h) then
p.y += p.dy
end
p.dx, p.dy = 0, 0 -- Ceci est une affectation multiple en une ligne !
end
On vérifie la direction x et y séparément afin de ne pas être stoppé net si une seule des directions est bloquée. Cela nous permet de longer les murs quand on se déplace en diagonale ! A la fin, on remet la vitesse à 0.
Revenons à can_move()
. Cette fonction prend le rectangle qu'on lui donne en argument (x, y, largeur, hauteur) et vérifie, pour chaque coin du rectangle, si un tile avec le flag 0 s'y trouve.
function can_move(x, y, w, h)
if (check_obstacle(x, y)) return false -- coin haut-gauche
if (check_obstacle(x+w-1, y)) return false -- coin haut-droite
if (check_obstacle(x, y+h-1)) return false -- coin bas-gauche
if (check_obstacle(x+w-1, y+h-1)) return false -- coin bas-droite
return true
end
Dès que le programme atteint un return
, on sort de la fonction et le reste n'est pas joué. Si on est arrivé jusqu'en bas, c'est qu'aucun coin n'était sur un obstacle, donc on peut return true
!
La fonction check_obstacle(x, y)
prend un pixel sur la map, trouve la case correspondante sur la carte et renvoie true
si son sprite a le flag 0.
function check_obstacle(x, y)
local map_x = flr(x/8)
local map_y = flr(y/8)
local sprite = mget(map_x, map_y)
return fget(sprite, 0)
end
En écrivant tout ceci, vous devriez avoir reproduit l'exemple ci-dessus !
Cette méthode suffira pour certains jeux, mais se révèle trop imprécise dans d'autres cas. Essayez par exemple de mettre p.speed
à 3
, et vous verrez que vous ne pourrez plus vous coller aux murs. Si un ou deux pixels vous séparent du mur, vous ne pourrez pas avancer de 3 pixels donc vous serez bloqué·e. Idéalement, le personnage devrait être ralenti pour se déplacer de ces un ou deux pixels !
Méthode avec accélération et friction
Voyons comment programmer une méthode de déplacement plus précise avec des effets de physique. Cela rendra vos contrôles plus satisfaisants, mais cela peut aussi être une base pour un système plus vaste ! Vous pourriez par exemple appliquer une force sur le personnage ou les ennemis pour les repousser, et ils se stopperont correctement contre les murs.
Vous pouvez lire le code complet de la démo à cette adresse.
On utilisera la même game loop que précédemment :
function _init()
create_player()
end
function _update()
player_movement()
end
function _draw()
cls()
map()
spr(p.sprite, p.x, p.y)
end
Et nous donnerons de nouvelles propriétés au personnage :
function create_player()
p = {
sprite = 5,
x = 24, y = 24,
w = 7, h = 8,
dx = 0, dy = 0,
max_speed = 3,
acceleration = 1,
friction = 0.85
-- friction = 1 : pas de ralentissement
-- friction = 0 : arrêt instantané
}
end
L'accélération est la vitesse ajoutée à chaque frame quand on appuie sur une flèche. Elle s'accumule donc au fil des frames mais ne pourra jamais dépasser la valeur max_speed
. La friction est la vitesse retirée à chaque frame quand on n'appuie plus sur les flèches.
Dans player_movement()
, ajoutons l'accélération à la vitesse actuelle en fonction des touches appuyées :
function player_movement()
if (btn(⬅️)) p.dx -= p.acceleration
if (btn(➡️)) p.dx += p.acceleration
if (btn(⬆️)) p.dy -= p.acceleration
if (btn(⬇️)) p.dy += p.acceleration
Cela ressemble à ce que nous avions fait lors de l'étape précédente, mais le problème avec cette méthode, c'est que lorsqu'on bouge en diagonale, on va plus vite. Plus précisément, si au cours d'une frame on se déplace à la fois d'un pixel sur l'axe X et d'un pixel sur l'axe Y, on a parcouru une distance 1,4 fois plus grande que si on se déplaçait sur un seul axe. D'où je sors ce chiffre ? Vous pouvez le retrouver avec un petit coup de théorème de Pythagore !
Ici, a et b sont les distances parcourues en X et en Y, tandis que c est la distance parcourue en diagonale. D'après le théorème, c² = a² + b² = √2 ≈ 1,41. Cela signifie que pour corriger la vitesse, il faudrait la multiplier par environ 0,71, mais ce n'est pas obligatoire ! Dans certains jeux, il sera plus fun et naturel de multiplier par un juste milieu tel que 0,8 ou de ne pas altérer la vitesse du tout. A vous de voir ! Personnellement, j'aime bien le résultat que donne 0,75. J'effectue le calcul lorsque le personnage se déplace à une vitesse significative dans les deux axes.
if abs(p.dx) > p.max_speed/2 and abs(p.dy) > p.max_speed/2 then
p.dx *= 0.75
p.dy *= 0.75
end
Astride
La fonction abs
, qui signifie valeur absolue, permet d'obtenir la valeur positive d'un nombre. Si un nombre x vaut parfois 1 et parfois -1, le résultat de abs(x)
sera toujours 1. C'est pratique pour connaître la vitesse du personnage quelque soit sa direction !
Avec ce système, l'accélération s'accumule à chaque frame, donc nous devons limiter le tout à la vitesse maximum :
p.dx = mid(-p.max_speed, p.dx, p.max_speed)
p.dy = mid(-p.max_speed, p.dy, p.max_speed)
Avant même de se déplacer, nous allons bloquer le mouvement dans la ou les directions où l'on est collé·e à un mur. Cela nous permettra de longer les murs quand on se déplace en diagonale, mais nous écrirons cette fonction plus tard ! C'est juste pour vous dire que c'est à ce stade que nous l'appellerons.
check_walls(p)
Ensuite vient le déplacement. Comme précédemment, la fonction can_move()
renverra true
s'il n'y a pas d'obstacle à la destination souhaitée.
if can_move(p, p.dx, p.dy) then
p.x += p.dx
p.y += p.dy
Par contre, cette fois, s'il y a un obstacle, on va en rapprocher le personnage aussi près que possible ! Nous allons stocker la distance que le personnage était censé parcourir, puis progressivement raccourcir cette distance jusqu'à trouver une position sans aucun obstacle.
if can_move(p, p.dx, p.dy) then
p.x += p.dx
p.y += p.dy
else
-- On sauvegarde la distance que le perso devait parcourir
local target_x = p.dx
local target_y = p.dy
-- Tant qu'on ne peut pas se déplacer jusqu'à 'target x y'…
while not can_move(p, target_x, target_y) do
-- Si 'target x' a été réduite au point d'être presque 0…
if abs(target_x) <= 0.1 then
target_x = 0 -- on la met simplement à 0.
else
-- Sinon, sa nouvelle valeur est 90% de l'ancienne
target_x *= 0.9
end
-- Pareil pour y
if abs(target_y) <= 0.1 then
target_y = 0
else
target_y *= 0.9
end
end
-- On se déplace jusqu'à la distance obtenue
p.x += target_x
p.y += target_y
end
Parfait ! Maintenant que le personnage a été déplacé, vous pouvez appliquer la friction. Il sera ainsi ralenti si on n'appuie plus sur une flèche, mais cela ne se verra pas si on continue d'appuyer puisque l'accélération est plus forte.
if (abs(p.dx) > 0) p.dx *= p.friction
if (abs(p.dy) > 0) p.dy *= p.friction
Si le personnage ralentit depuis un moment est que sa vitesse a presque atteint 0, on peut la mettre simplement à 0.
if (abs(p.dx) < 0.02) p.dx = 0
if (abs(p.dy) < 0.02) p.dy = 0
end
Passons aux fonctions que nous avons appelées. Comme à l'étape précédente, can_move
regarde si les coins d'un carré sont sur une case de la map avec le flag 0. Ici, la fonction prend comme arguments un objet avec des propriétés x, y, largeur, hauteur, ainsi qu'une vitesse pour former le carré à l'endroit où l'on souhaite se rendre.
function can_move(a, dx, dy)
-- Quelques variables pour plus de clarté
local x_left = a.x + dx
local x_right = a.x + dx + a.w-1
local y_top = a.y + dy
local y_bottom = a.y + dy + a.h-1
if (check_obstacle(x_left, y_top)) return false -- coin haut-gauche
if (check_obstacle(x_left, y_bottom)) return false -- coin haut-droite
if (check_obstacle(x_right, y_top)) return false -- coin bas-gauche
if (check_obstacle(x_right, y_bottom)) return false -- coin bas-droite
return true
end
A part le fait de devoir constituer le carré en prenant en compte dx
et dy
, c'est le même principe qu'avant. Au passage, la fonction check_obstacle
est exactement la même que précédemment :
function check_obstacle(x, y)
local map_x = flr(x/8)
local map_y = flr(y/8)
local sprite = mget(map_x, map_y)
return fget(sprite, 0)
end
Enfin, la fonction check_walls
est une nouveauté. Elle vérifie si un objet aux propriétés x
, y
, w
, h
, dx
, dy
est collée à un mur, et bloque la direction X ou Y correspondante si c'est le cas.
function check_walls(a)
-- Si on va vers la gauche
if a.dx < 0 then
local wall_top_left = check_obstacle(a.x-1, a.y)
local wall_bottom_left = check_obstacle(a.x-1, a.y+a.h-1)
-- S'il y a un mur dans cette direction,
-- on réduit la vitesse x à 0
if wall_top_left or wall_bottom_left then
a.dx = 0
end
-- vers la droite
elseif a.dx > 0 then
local wall_top_right = check_obstacle(a.x+a.w, a.y)
local wall_bottom_right = check_obstacle(a.x+a.w, a.y+a.h-1)
if wall_top_right or wall_bottom_right then
a.dx = 0
end
end
-- vers le haut
if a.dy < 0 then
local wall_top_left = check_obstacle(a.x, a.y-1)
local wall_top_right = check_obstacle(a.x+a.w-1, a.y-1)
if wall_top_left or wall_top_right then
a.dy = 0
end
-- vers le bas
elseif a.dy > 0 then
local wall_bottom_left = check_obstacle(a.x, a.y+a.h)
local wall_bottom_right = check_obstacle(a.x+a.w-1, a.y+a.h)
if wall_bottom_right or wall_bottom_left then
a.dy = 0
end
end
end
Pour bien comprendre l'intérêt de cette fonction, vous pouvez tout simplement ne pas l'appeler, et vous verrez que vous ne pourrez pas longer les murs correctement.
Réutiliser le code pour les ennemis
En réorganisant notre code, on peut le généraliser à toutes les entités de notre jeu : les ennemis, les PNJ... Dans cette démo, j'ai créé un ennemi simple pour l'exemple, qui se rapproche de notre personnage jusqu'à atteindre une certaine distance. On pourra ensuite lui appliquer une force supplémentaire pour le repousser quand on l'attaque.
Vous pouvez lire le code complet de la démo à cette adresse.
Nous devons déplacer le code qui s'appliquera à toutes les entités dans une fonction séparée et ne garder que ce qui est exclusif au personnage dans player_movement()
.
function player_movement()
if (btn(⬅️)) p.dx -= p.acceleration
if (btn(➡️)) p.dx += p.acceleration
if (btn(⬆️)) p.dy -= p.acceleration
if (btn(⬇️)) p.dy += p.acceleration
entity_movement(p)
end
Alors que contient la fonction entity_movement()
? Tout simplement ce que contenait la fonction player_movement()
auparavant, excepté la gestion des touches du clavier bien sûr.
Créons l'ennemi maintenant. Il aura les mêmes propriétés que notre personnage :
function create_enemy()
e = {
sprite = 7,
x = 80, y = 24,
w = 6, h = 8,
dx = 0, dy = 0,
max_speed = 1,
acceleration = 0.5,
friction = 0.85
}
end
La fonction player_movement()
ne contient plus que les règles changeant les valeurs de dx
et dy
. Dans la même veine, on mettra juste les règles correspondant à l'ennemi dans enemy_movement()
.
function enemy_movement()
local distance = abs(e.x-p.x) + abs(e.y-p.y)
if distance > 30 then
if (p.x+2 < e.x) e.dx -= e.acceleration
if (p.x-2 > e.x) e.dx += e.acceleration
if (p.y+2 < e.y) e.dy -= e.acceleration
if (p.y-2 > e.y) e.dy += e.acceleration
end
entity_movement(e)
end
Ici, on additionne la distance en X et la distance en Y entre l'ennemi et le personnage, puis on se rapproche si le total est supérieur à 30.
Plus qu'à afficher un sprite pour l'ennemi et le tour est joué !
spr(e.sprite, e.x, e.y)
Donner un coup d'épée dans toutes les directions
Avant de pouvoir repousser l'ennemi comme dans la démo, nous devons mettre au point la hitbox du coup d'épée, qui changera en fonction de la direction du joueur ou de la joueuse.
On ajoute donc la propriété p.angle
qui indiquera la direction du personnage. Ce sera utile pour la direction de l'épée mais aussi pour afficher des sprites différents dans chaque direction si vous le souhaitez.
function create_player()
p = {
sprite = 6,
x = 24, y = 24,
w = 7, h = 8,
dx = 0, dy = 0,
max_speed = 3,
acceleration = 1,
friction = 0.5,
angle = 6
}
sword_timer = 0
sword_x1, sword_y1, sword_x2, sword_y2 = 1, 1, 1, 1
end
La variable sword_timer
permettra la durée du coup d'épée, tandis que les quatre variables suivantes stockeront les coordonnées du rectangle de la hibox.
Nous allons écrire une petite fonction pour mettre à jour p.angle
. Pour rendre la valeur plus lisible, j'ai choisi des chiffres qui correspondent à ceux du pavé numérique de votre clavier ou d'un téléphone. Imaginez que le personnage est au milieu du pavé, à 5, et que les chiffres tout autour indiquent sa direction.
function update_player_angle()
if btn(➡️) and btn(⬆️) then
p.angle = 9
elseif btn(⬅️) and btn(⬆️) then
p.angle = 7
elseif btn(⬅️) and btn(⬇️) then
p.angle = 1
elseif btn(➡️) and btn(⬇️) then
p.angle = 3
elseif btn(➡️) then
p.angle = 6
elseif btn(⬆️) then
p.angle = 8
elseif btn(⬅️) then
p.angle = 4
elseif btn(⬇️) then
p.angle = 2
end
end
En utilisant cet angle fraîchement acquis, nous pouvons maintenant déterminer le rectangle de la hitbox.
function update_sword()
update_player_angle()
-- Point d'origine : centre du perso
local ox, oy = p.x+3, p.y+3
if p.angle == 9 then
sword_x1, sword_y1, sword_x2, sword_y2 = ox, oy-10, ox+10, oy
elseif p.angle == 7 then
sword_x1, sword_y1, sword_x2, sword_y2 = ox-10, oy-10, ox, oy
elseif p.angle == 1 then
sword_x1, sword_y1, sword_x2, sword_y2 = ox-10, oy, ox, oy+10
elseif p.angle == 3 then
sword_x1, sword_y1, sword_x2, sword_y2 = ox, oy, ox+10, oy+10
elseif p.angle == 6 then
sword_x1, sword_y1, sword_x2, sword_y2 = ox, oy-5, ox+13, oy+5
elseif p.angle == 8 then
sword_x1, sword_y1, sword_x2, sword_y2 = ox-5, oy-13, ox+5, oy
elseif p.angle == 4 then
sword_x1, sword_y1, sword_x2, sword_y2 = ox-13, oy-5, ox, oy+5
elseif p.angle == 2 then
sword_x1, sword_y1, sword_x2, sword_y2 = ox-5, oy, ox+5, oy+13
end
-- On réduit le timer du coup au fil du temps
sword_timer = max(0, sword_timer-1)
if (sword_timer == 0) sword_hitting = false
if btnp(❎) and not sword_hitting then
sword_hitting = true
sword_timer = 4
end
end
Pour vérifier que tout est correct, vous pouvez afficher la hitbox de l'épée sous le personnage dans draw :
rectfill(sword_x1, sword_y1, sword_x2, sword_y2, sword_hitting and 9 or 8)
La couleur sera de 9 si vous êtes en train de donner un coup, 8 sinon.
Repousser l'ennemi
Nous allons vérifier si l'ennemi est en collision avec la hitbox de l'épée, en utilisant la méthode de collision vue dans le chapitre sur le shooter.
La propriété e.hit
ne sert à rien en terme de gameplay pour le moment, mais nous permettra de faire clignoter le sprite quand il est touché.
function enemy_movement()
local distance = abs(e.x-p.x) + abs(e.y-p.y)
if distance > 30 then
if (p.x+2 < e.x) e.dx -= e.acceleration
if (p.x-2 > e.x) e.dx += e.acceleration
if (p.y+2 < e.y) e.dy -= e.acceleration
if (p.y-2 > e.y) e.dy += e.acceleration
end
e.hit = false
if sword_hitting then
-- Méthode de collision vue dans le tuto shooter
if not (e.x > sword_x2
or e.y > sword_y2
or e.x+e.w-1 < sword_x1
or e.y+e.h-1 < sword_y1) then
-- Ennemi touché par l'épée
e.hit = true
-- Repousser l'ennemi
if (p.x+3 < e.x) e.dx += 3
if (p.x-3 > e.x) e.dx -= 3
if (p.y+3 < e.y) e.dy += 3
if (p.y-3 > e.y) e.dy -= 3
end
end
entity_movement(e)
end
On repousse l'ennemi en lui appliquant une force en X et/ou en Y en fonction de sa position par rapport au joueur ou à la joueuse. Si l'ennemi est à peu près en face du personnage, il sera repoussé en ligne droite afin qu'on puisse plus facilement l'enchaîner en continuant d'avancer vers lui.
On a juste un souci : e.max_speed
vaut 1. Cela me convient pour les déplacements normaux, mais cela m'empêche de repousser l'ennemi de 3 pixels comme je souhaitais le faire ci-dessus. Une solution serait de modifier le code qui contraint dx
et dy
à max_speed
dans entity_movement()
.
local max = e.hit and 3 or e.max_speed
e.dx = mid(-max, e.dx, max)
e.dy = mid(-max, e.dy, max)
Désormais, la variable locale max
vaut 3 si l'entité est touchée, ou max_speed
en temps normal.
Pour finir, vous pouvez faire clignoter l'ennemi facilement lorsqu'il est touché en changeant toutes les couleurs de la palette en blanc :
if (e.hit) pal({7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7})
spr(e.sprite, e.x, e.y)
pal()
Le tour est joué ! J'espère que ces exemples vous inspireront pour réaliser des déplacements plus intéressants dans vos jeux !