Aller au contenu

Un premier jeu

Un jeu papyDeck, c'est une poignée de fichiers dans un dossier, et un programme de deux fonctions. Cette page va du dossier vide à une plateforme en 75 lignes, avec deux des jeux d'exemple. L'API qu'ils emploient est décrite dans les règles, gfx et phy.

Un jeu est un dossier

Un jeu est un dossier : ses .lua et ses assets, côte à côte, et il démarre par main.lua, toujours. Ce n'est pas un réglage. Le dossier est plat : pas de sous-dossier, pas de chemin nulle part. Il contient :

  • main.lua, et les modules que le jeu charge par require("nom"), qui demande nom.lua au dossier du jeu et nulle part ailleurs ;
  • les motifs de collision de ses niveaux, nom.mask, des fichiers texte lus par phy.map("nom") ;
  • les pièces de ses bundles d'art, en groupes de palette : <g>.pal et <g>.atlas avec les PNG indexés que l'atlas nomme (une palette et les images quantifiées avec elle), les variantes de palette <g>.<v>.pal, et les fonds tuilés <g>-<f>.map + <g>-<f>-tiles.png. Il n'y a pas de manifeste : les préfixes font les liens, et un seul appel à gfx.bundle() installe tout.

Les noms de fichiers suivent la règle de la carte micro-SD de la console, la même que dans l'App : minuscules [a-z0-9_-] seulement, et jamais un nom réservé de Windows comme con ou lpt1. FAT32 ne distingue pas la casse, donc un /, un .. ou une majuscule dans un require est une erreur, pas un avertissement.

Les PNG sont acceptés en couleurs indexées uniquement, la sortie native d'Aseprite. L'index 0 est transparent dans un sprite, et c'est aussi le trou d'un fond.

Le squelette

gfx.bundle()                          -- game.pal, game.atlas, game-*.png, game-*.map
local ship = gfx.id('game/ship')      -- l'id d'une region : <groupe>/<nom>
local flamme = phy.anim_load('game/flamme', 6)

function _update()                    -- la logique, une fois par trame
  local p = pad.get()                 -- n'importe quel pad
  ...
  phy.step()
end

function _draw()                      -- DECRIT la trame : rien n'est dessine ici
  gfx.sprite(ship, x, y)
  gfx.print(8, 8, 'SCORE ' .. score, 2)
end

Le haut du fichier s'exécute une fois, au chargement : il installe le bundle, relève les ids des images qu'il dessinera, crée ses acteurs. Ensuite _update() et _draw() sont appelés à chaque trame, dans cet ordre.

_update() est la logique : il lit les manettes, déplace les choses, appelle phy.step(). Il n'y a pas de dt, et il n'y en aura jamais : la trame logique est l'appel à _update(), les vitesses sont en pixels par trame. La console ralentit, elle ne saute jamais.

_draw() décrit la trame. Il ne dessine rien lui-même : chaque gfx.sprite() ajoute une entrée à la display list, et l'ordre des appels est l'ordre de peinture, le dernier devant. La display list recommence entre _update() et _draw() : seuls les gfx.sprite appelés depuis _draw() s'affichent. Et _draw() ne doit pas modifier l'état du jeu, parce que sous charge la console peut le sauter.

Un sprite qui suit la croix directionnelle

L'exemple ship, en entier :

-- Un sprite qui suit la croix directionnelle. C'est tout le programme.

gfx.bundle()  -- les assets du jeu : game.pal, game.atlas, game-*.png

local ship = gfx.id('game/ship')
local x, y = 320, 240

function _update()
  local p = pad.get(1)
  if p & pad.LEFT  ~= 0 then x = x - 2 end
  if p & pad.RIGHT ~= 0 then x = x + 2 end
  if p & pad.UP    ~= 0 then y = y - 2 end
  if p & pad.DOWN  ~= 0 then y = y + 2 end
end

function _draw()
  gfx.sprite(ship, x, y)
end

Son dossier contient quatre fichiers :

Fichier Ce que c'est
main.lua le programme ci-dessus
game.pal la palette du groupe game, 768 octets
game.atlas l'atlas du groupe : quel PNG porte quelle région, et où
game-ship.png le PNG indexé que l'atlas nomme, avec la région ship dedans

gfx.bundle() installe le groupe ; gfx.id('game/ship') est l'id de la région ship du groupe game, ce que gfx.sprite prend. pad.get(1) est un masque de bits : p & pad.LEFT ~= 0 teste un bouton. La position est un simple nombre en pixels ; deux pixels par trame, c'est 120 pixels par seconde.

Les fichiers .pal et .atlas ne s'écrivent pas à la main. Ils viennent de l'éditeur de bundles de l'App, qui construit un groupe depuis vos PNG et les quantifie ensemble, ou du dossier de jeu d'un exemple, que vous pouvez copier et modifier.

Une plateforme en 75 lignes

L'exemple plateforme. Ses commentaires expliquent ce qu'un moteur de jeu retire au code d'un jeu.

-- papyDeck — ce qu'un moteur de jeu retire au code d'un jeu.
--
-- Il n'y a ici NI test de collision, NI integration, NI clamp aux bords, NI
-- gestion du contact avec le sol. Tout cela est dans le moteur, en virgule
-- fixe, et se rejoue au bit pres sur les trois cibles.
--
-- Les positions sont en Q16.16 : phy.PX vaut 65536, donc « 3 * phy.PX » se lit
-- « trois pixels ». Elles ne traversent JAMAIS en `number` -- lua_Number est un
-- float32 ici, et une position au-dela de 256 px y perdrait ses bits bas.

-- LE NIVEAU EST UN FICHIER TEXTE, et il n'existe qu'en un exemplaire.
--
-- niveau.mask : un caractere par case de 8x8 px — 1:1 avec les tuiles du fond,
-- « # » pour la matiere, 80 x 60 pour un ecran. La console y prend sa
-- geometrie, et le jeu la RELIT ci-dessous
-- pour poser ses blocs -- donc le decor dessine ne peut pas mentir sur ce
-- contre quoi on bute. Ouvrez-le et changez un point en diese : le bloc
-- apparait, et il porte.
gfx.bundle()  -- les assets du jeu : game.pal, game.atlas, game-*.png

local W, H = phy.map("niveau")
phy.gravity(0, phy.PX // 2)          -- 0,5 px par trame, par trame

local bloc = gfx.id('game/bloc')
local BLOC = 16

-- Le tableau se construit UNE fois : phy.solid() sonde au pixel, et interroger
-- 1 200 tuiles a chaque trame serait payer douze cents appels pour une image
-- qui ne bouge pas.
local blocs = {}
for ty = 0, H // BLOC - 1 do
  for tx = 0, W // BLOC - 1 do
    if phy.solid(tx * BLOC + BLOC // 2, ty * BLOC + BLOC // 2) then
      blocs[#blocs + 1] = { tx * BLOC, ty * BLOC }
    end
  end
end
-- L'ANIMATION AUSSI est au moteur : un prefixe, une cadence en trames par
-- image, et chaque acteur porte son propre compteur. Sans cela chaque jeu
-- recopiait le meme boilerplate -- des tables de gfx.id() et un t//4 % n.
local marche = phy.anim_load('game/perso', 8)   -- perso0, perso1

local joueur = phy.new(gfx.size(gfx.id('game/perso0')))
phy.flags(joueur, phy.MASK | phy.GRAVITY)
phy.pos(joueur, 32 * phy.PX, 400 * phy.PX)
phy.anim(joueur, marche)

local MARCHE = 3 * phy.PX
local SAUT   = -10 * phy.PX

function _update()
  local p = pad.get(1)
  local _, vy = phy.vel(joueur)

  local vx = 0
  if p & pad.LEFT  ~= 0 then vx = -MARCHE end
  if p & pad.RIGHT ~= 0 then vx =  MARCHE end

  -- Le coyote est dans le moteur : `au_sol` reste vrai quelques trames apres
  -- une corniche, sinon le saut rate a chaque bord de plateforme.
  local _, au_sol = phy.ground(joueur)
  if au_sol and p & pad.A ~= 0 then vy = SAUT end

  phy.vel(joueur, vx, vy)
  phy.step()
end

function _draw()
  for _, b in ipairs(blocs) do
    gfx.sprite(bloc, b[1], b[2])
  end
  -- phy.sprite rend le triplet exact de gfx.sprite : la frame du moment, et
  -- la position de l'acteur en pixels entiers.
  gfx.sprite(phy.sprite(joueur))
end

Le dossier contient main.lua, niveau.mask, game.pal, game.atlas, game-bloc.png (un bloc de 16 × 16) et game-perso.png (les deux images de 12 × 16 du personnage, perso0 et perso1, côte à côte dans l'atlas).

Le niveau est niveau.mask, un dessin ASCII : soixante lignes de quatre-vingts caractères, un caractère par case de 8 × 8 px, # pour la matière, tout le reste est de l'air. Un extrait, le bas du niveau :

................................................................................
################................................................................
################................................................................
................................................................................
................................................................................
................................................................................
................................................................................
................................................................................
################################################################################
################################################################################

phy.map("niveau") le charge et rend les dimensions du monde en pixels, 640 × 480 ici. Les lignes manquantes sont vides, les caractères manquants aussi, et le dehors est solide : un acteur ne sort pas du monde.

La gravité est un vecteur en pixels par trame au carré : phy.gravity(0, phy.PX // 2) fait un demi-pixel par trame, par trame. Notez le // : tout ce que phy.* prend est en Q16.16, des entiers seulement, et / produirait un flottant.

La liste des blocs se construit une fois, au chargement, avec phy.solid(x, y), qui demande au motif de collision ce qu'il dit à ce pixel. Le jeu sonde le centre de chaque case de 16 × 16 et garde les solides ; _draw() dessine ensuite un sprite de bloc par entrée. Le niveau n'existe qu'en un exemplaire : le dessin ne peut pas contredire la géométrie.

L'animation est phy.anim_load('game/perso', 8) : les régions perso0, perso1… du groupe game jusqu'à ce qu'il n'y en ait plus, à la cadence de 8 trames par image, en boucle.

L'acteur est phy.new(w, h), avec une boîte de la taille de la première image, phy.MASK pour buter contre le motif et phy.GRAVITY pour tomber, et une position de départ posée en Q16.16, 32 * phy.PX, 400 * phy.PX.

Le motif vel dans _update() est celui à retenir : phy.vel écrit les deux composantes, donc la vitesse verticale acquise par la gravité est d'abord relue (local _, vy = phy.vel(joueur)) puis reposée avec l'horizontale. Sans cela, la gravité est annulée à chaque trame.

Le coyote time est phy.ground(joueur) : sa seconde valeur reste vraie jusqu'à quatre trames après avoir quitté une corniche, donc un saut pressé un peu tard part quand même.

phy.step() est une trame de monde, appelée explicitement : gravité, déplacement en x contre le motif puis en y, sonde de sol, recouvrements.

phy.sprite(joueur) rend les trois valeurs que gfx.sprite prend, l'image du moment et la position en pixels entiers, donc gfx.sprite(phy.sprite(joueur)) est tout le dessin du personnage. Les blocs sont dessinés d'abord et le personnage en dernier : l'ordre des appels est l'ordre de peinture.

Le lancer

Dans le simulateur, le jeu est un projet du nuage : clonez un exemple de la liste des publiés, ou partez d'un brouillon à vous. Les flèches sont la croix directionnelle et Z le bouton A : il tire dans le shooter, il saute dans la plateforme. Éditez, Ctrl+Entrée, et le jeu est relancé. Voir le simulateur.

Sur la carte, le dossier va sur la carte micro-SD, sous papydeck/games/<nom>/, un dossier par jeu. La console liste les jeux de la carte et lance celui que vous choisissez ; le premier lancement compile le bundle d'art dans la NOR, quelques secondes, et rien ensuite.

Le même fichier, la même API, le même modèle de coût : un jeu qui tourne dans l'un tourne dans l'autre.