Créer un petit jeu de plateforme avec Löve

Posté le 19/03/2021 dans Lua

Löve

On fait suite au précédent article du blog qui introduisait le développement de jeux vidéo en Lua avec Löve. Tu vas voir comment créer un petit jeu de plateforme inspiré de Super Meat Boy et de Disc Room uniquement avec Löve.

Disc Room Escape

Le jeu s'appelle Disc Room Escape et est jouable sur itch.io. Le but est simplement de traverser les différents niveaux, sans mourir, dans la limite de temps disponible. Il faudra sauter sur des plateformes et éviter des scies mortelles.

Ici, pas besoin de librairies et outils annexes. Tout sera fait uniquement avec Löve.

Disc Room

Code source

Le code source est intégralement disponible sur Github. N'hésite pas à le récupérer, à jouer avec, à le modifier et à te l'approprier.

Je vais t'expliquer dans les grandes lignes son fonctionnement. Suis les liens vers les fichiers sources pour les comprendre. Sur cet article, je ne mettrais en exemple que certains bouts de code intéressants pour éviter de trop le surcharger d'information.

Etat du jeu

On va commencer par créer un système qui gère l'état de notre jeu. Dans les prochains fichiers, on va beaucoup utiliser setmetatable, qui permet de gérer des sortes de classes en Lua.

Tu voudras avoir un écran d'accueil où tu lances le jeu, un écran avec le jeu en cours, un écran de fin lors de la victoire et un écran de fin lors de la défaite. Ces écrans vont utiliser les fonctions update et draw de Löve pour se mettre à jour, afficher les éléments et gérer les touches.

function mt:update(dt)
    if love.keyboard.isDown('return') or (Joystick and Joystick:isGamepadDown('start')) then
        GameState.setCurrent('Play', GAME_LEVEL_START)
        local doorSound = love.audio.newSource(SOUND_DOOR, "static")
        doorSound:play()
    end
end

function mt:draw()
    love.graphics.setNewFont(12)
    love.graphics.setColor(1, 1, 1, 1)
    love.graphics.setBackgroundColor( 104/255, 124/255, 133/255 )
    love.graphics.draw(self.img, 70, 0, 0, 0.4, 0.4)
    love.graphics.print({{0,0,0,1}, 'Press [enter] or [start] to start the game !'}, 75, 220)
end

Tous ces écrans vont être manipulables grâce à une classe GameState qui va se charger de gérer la transition des états. Il suffit par exemple, d'utiliser la commande suivante pour changer de niveau :

GameState.setCurrent('Play', self.level_num + 1)

Le fichier principal de Löve va juste se charger de démarrer le jeu sur l'écran d'accueil et d'initier le joystick, le fichier de configuration et les constantes du jeu.

Animation et assets

Tous les assets du jeu, sons et images, sont disponibles ici. Tu peux les modifier selon tes propres envies. Tu auras besoin de deux petits helpers, un pour gérer les animations et un autre pour gérer les assets.

Le monde

Chaque élément du jeu sera ajouté à un monde, qui va se charger de vérifier en continue la position des éléments et leurs collisions.

On vérifie la collision de nos éléments à l'aide d'une fonction toute simple :

local function checkCollision(a, b)
    return a.x < b.x + b.w and
        a.x + a.w > b.x and
        a.y < b.y + b.h and
        a.h + a.y > b.y
end

Les niveaux

Les niveaux seront représentés par des tableaux de la manière suivante :

return {
    6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,
    10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,
    10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,
    10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,
    10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,
    10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,
    10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,
    10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,
    10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,
    10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,
    10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,
    10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,
    10,0,0,0,0,0,0,0,0,3,3,0,0,0,3,3,0,0,0,0,0,0,0,0,10,
    10,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,0,10,
    2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,2,2,2,2,2,2,2,
    1,1,1,1,1,1,1,5,5,5,5,5,5,5,5,5,5,5,1,1,1,1,1,1,1,
}

Chaque chiffre va représenter un élément sur notre écran.

Chacun de ces éléments utilise le système de classes de Lua via setmetatable pour sa représentation. Une classe Level va se charger de l'affichage des éléments du niveau en fonction de ces chiffres.

Il sera alors possible de déclencher des évenements dans le monde via une fonction trigger, par exemple lorsque le joueur franchit une porte.

Pour gérer cet évenement, dans notre classe Door on a :

function mt:update(dt)
    self.touches_hero = GameState.getCurrent().world:check(self, 'is_hero')
end

function mt:draw()
    assets.qdraw(7, self.x, self.y)
    if self.touches_hero then
        GameState.getCurrent():trigger('door:open')
    end
end

Et dans l'état du jeu en cours PlayState on a :

function mt:trigger(event, actor, data)
    if event == 'door:open' then
        local doorSound = love.audio.newSource(SOUND_DOOR, "static")
        doorSound:play()
        if self.level_num < GAME_LEVEL_MAX then
            GameState.setCurrent('Play', self.level_num + 1)
        else
            GameState.setCurrent('Win')
        end
    end
end

Le hero

Notre hero va devoir se déplacer si on utilise le joystick ou le clavier, en utilisant un système d'accélération et de décélération dans son update :

local dx, dy = 0, 0

if love.keyboard.isDown('left') or (Joystick and (Joystick:isGamepadDown('dpleft') or Joystick:getGamepadAxis('leftx') <= -0.25)) then
    self:setAnim('run')
    self.last_direction = -1
    -- acceleration system
    self.vx = self.vx + (-self.speed * self.acceleration * dt)
    if self.vx < -self.speed then self.vx = -self.speed end
elseif love.keyboard.isDown('right') or (Joystick and (Joystick:isGamepadDown('dpright') or Joystick:getGamepadAxis('leftx') >= 0.25)) then
    self:setAnim('run')
    self.last_direction = 1
    -- acceleration system
    self.vx = self.vx + (self.speed * self.acceleration * dt)
    if self.vx > self.speed then self.vx = self.speed end
else
    -- deceleration system
    if self.vx < 0 then
        self.vx = self.vx + (self.speed * self.deceleration * dt)
        if self.vx > 0 then self.vx = 0 end
    elseif self.vx > 0 then
        self.vx = self.vx + (-self.speed * self.deceleration * dt)
        if self.vx < 0 then self.vx = 0 end
    end
end
dx = dx + self.vx * dt

Tu va devoir gérer la gravité lors du saut dans le update également :

if (love.keyboard.isDown('up') or (Joystick and (Joystick:isGamepadDown('a')))) then
    -- init jump
    if self:canJump() then
        self.vy = HERO_JUMP_SPEED
        self.is_jumping = true
        local jumpSound = love.audio.newSource(SOUND_JUMP, "static")
        jumpSound:play()
    -- during the jump
    elseif self.is_jumping == true then
        -- reduce the gravity for smooth jump
        if self.vy < 0 then
            self.vy = self.vy - HERO_JUMP_GRAVITY * dt
        end
    end
end
-- gravity
if self:isGrounded() then
    self.vy = 0
    self.is_jumping = false
    self.ungroundedTime = 0
else
    self:setAnim('jump')
    self.vy = math.min(self.vy + HERO_GRAVITY * dt, HERO_MAX_VELOCITY)
    self.ungroundedTime = self.ungroundedTime + dt
end

Et bien évidemment, il faudra l'animer à l'aide de notre helper :

self:setAnim('run')

et enfin le déplacer dans notre monde via :

GameState.getCurrent().world:move(self, self.x + dx, self.y + self.vy, 'is_solid')

Les particules de sang

Enfin, à la mort, on va utiliser un système de particules pour gérer le sang. On va pouvoir utiliser différents paramètres pour styliser nos particules :

p.psystem:setParticleLifetime(0.5, 3)
p.psystem:setEmissionRate(128)
p.psystem:setEmitterLifetime(0.5)
p.psystem:setSizeVariation(1)
p.psystem:setLinearAcceleration(-100, -100, 100, 100)
p.psystem:setColors(1, 1, 1, 1, 1, 1, 1, 0)

Conclusion

J'espère que cet aperçu va te donner envie d'essayer Löve plus en profondeur !

Ici, on a tout fait à la main, sans librairie. C'est la meilleur manière de procéder je pense pour avoir le contrôle complet de ton code.

Mais si tu veux voir ce que ça donne en utilisant des librairies de gestion de collisions comme Bump, des utilitaires pour gérer l'état du jeu ou la caméra comme Hump, l'outil de gestion des animations Anim8, ou encore l'utilitaire STI pour manipuler des niveaux créés avec Tiled, tu peux jeter un oeil à mon deuxième projet Löve, The Legend Of Shifu, dont l'intégralité du code source est disponible sur Github. C'est un petit jeu inspiré de The Binding Of Isaac.

The Legend Of Shifu

Have fun !