Create: Computing AgesDoc Brass
Recettes : vrais projets

Un jeu du serpent

Un Snake complet avec gfx : une grille, un serpent fait d'une liste de cases, les flèches et WASD ou ZQSD, une minuterie pour la vitesse, des pommes au hasard, un record sauvé sur le disque et un écran de fin de partie.

Une pause entre deux postes à l'usine : le grand classique du serpent, sur un Micro-ordinateur. Mangez les pommes rouges, grandissez, allez de plus en plus vite, et ne mordez ni votre queue ni les murs.

Un jeu est un excellent exercice, parce qu'il demande tout à la fois : un état gardé dans des tables, une boucle principale qui mêle une minuterie et le clavier, un dessin qui ne touche que ce qui a bougé, du hasard, et un fichier pour retenir le record. Le même squelette convient à tout jeu sur une grille : un Tetris, un labyrinthe, un démineur.

Ce qu'il vous faut

  • Un Micro-ordinateur, ou un Ordinateur moderne pour un plateau plus grand. Le jeu utilise gfx et les couleurs. Le plateau est calculé d'après la taille de l'écran : 38 × 19 cases sur un Micro-ordinateur, 47 × 24 sur un Ordinateur moderne, 29 × 11 sur un Microcontrôleur.
  • La pleine vitesse de rotation (256 tr/min) : à pleine vitesse, un Micro-ordinateur exécute 1 200 instructions par tick, et un pas du serpent en demande quelques centaines.
  • On joue dans le terminal (clic droit sur l'ordinateur) : les touches n'arrivent à un programme que depuis la fenêtre du terminal. Un mur de moniteurs montre la partie en grand aux spectateurs.

Enregistrez le programme sous le nom serpent et lancez-le en tapant son nom.

Comment ça marche

1. Le plateau

Le plateau est une grille de cases de 8 × 8 pixels, sous une barre de score de 16 pixels de haut. Chaque case est dessinée en 7 × 7 : un pixel d'écart sépare les cases et la grille apparaît d'elle-même.

Brass
local CASE = 8
local BARRE = 16
local taille = gfx.size()
local W, H = taille.w, taille.h
local COLONNES = (W - 2) // CASE
local LIGNES = (H - BARRE - 2) // CASE
local X0 = (W - COLONNES * CASE) // 2 + 1
local Y0 = BARRE + 1

local function dessiner_case(x, y, couleur)
  gfx.rect(X0 + (x - 1) * CASE, Y0 + (y - 1) * CASE, CASE - 1, CASE - 1, couleur, true)
end

La case (1, 1) est celle du coin en haut à gauche. Tout le reste du programme parle en cases ; seule dessiner_case connaît les pixels.

2. Le serpent : une liste de cases

Le serpent est une liste de tables {x =, y =}, la tête en premier. Une seconde table, occupe, marque les cases que couvre le serpent, avec pour clé un nombre fait des deux coordonnées (y * 1000 + x). Demander occupe[cle(x, y)] répond tout de suite à « le serpent est-il ici ? », là où parcourir toute la liste coûterait de plus en plus cher à mesure que le serpent grandit.

Brass
local serpent = {}   -- des cases {x =, y =}, la tête en premier
local occupe = {}    -- occupe[cle(x, y)] = true là où est le serpent

3. Un pas

À chaque battement du jeu, le serpent avance d'une case. Plutôt que de déplacer chaque case de son corps, le programme ajoute une nouvelle tête devant et retire la dernière case de la queue : les cases du milieu ne changent pas. Trois cases seulement sont redessinées : la queue effacée, l'ancienne tête peinte comme le corps, la nouvelle tête en clair.

Brass
local function avancer()
  direction = prochaine
  local tete = serpent[1]
  local nx, ny = tete.x + direction.x, tete.y + direction.y
  local mange = pomme ~= nil and nx == pomme.x and ny == pomme.y
  if not mange then
    local queue = table.remove(serpent)
    occupe[cle(queue.x, queue.y)] = nil
    dessiner_case(queue.x, queue.y, "black")
  end
  if nx < 1 or nx > COLONNES or ny < 1 or ny > LIGNES or occupe[cle(nx, ny)] then
    fin_de_partie()
    return
  end
  dessiner_case(tete.x, tete.y, "green")
  table.insert(serpent, 1, {x = nx, y = ny})
  occupe[cle(nx, ny)] = true
  dessiner_case(nx, ny, "lime")
  if mange then
    score = score + 1
    dessiner_score()
    if not placer_pomme() then fin_de_partie() end
  end
end

L'ordre compte. La queue est retirée avant le test de collision : la tête peut entrer dans la case que la queue vient de quitter, et c'est permis. Quand le serpent mange, la queue reste, et le serpent a une case de plus. table.remove(serpent) sans position prend le dernier élément ; table.insert(serpent, 1, ...) met la nouvelle tête devant.

4. Les touches

Les flèches arrivent comme évènements key, avec e.key valant "up", "down", "left" ou "right". Les lettres arrivent comme évènements char, avec le caractère tapé dans e.char. Les deux dispositions de clavier marchent sans réglage : W et Z montent, A et Q vont à gauche, S descend et D va à droite, et aucune lettre ne veut dire deux choses. string.lower rend Maj et Verr. Maj inoffensives.

Brass
local LETTRES = {w = "up", z = "up", s = "down", a = "left", q = "left", d = "right"}

Une touche ne fait pas bouger le serpent : elle choisit seulement la prochaine direction. diriger refuse le demi-tour, qui ferait mordre la tête dans son propre cou. Elle compare avec direction, celle du dernier pas, pas avec la dernière touche : sinon, en allant vers la droite, appuyer vite sur haut puis gauche entre deux pas ferait quand même revenir le serpent sur lui-même.

Brass
local function diriger(nom)
  local d = DIRECTIONS[nom]
  if d and not (d.x == -direction.x and d.y == -direction.y) then prochaine = d end
end

5. Le rythme : une minuterie

Le jeu avance au rythme d'une minuterie, pas des touches : os.start_timer(delai()) après chaque pas. Le délai raccourcit avec le score, de 0,2 s à 0,05 s. Les minuteries comptent en ticks (un vingtième de seconde, arrondi au-dessus) : 0,05 s, un tick, est le rythme le plus rapide possible.

La boucle principale vérifie que l'évènement de minuterie est bien celui qu'elle attend (e.id == minuterie). Après une pause ou une nouvelle partie, une ancienne minuterie peut encore sonner : son numéro n'est pas celui en cours, et elle est ignorée.

6. Pommes, record, fin de partie

placer_pomme tire des cases au hasard avec math.random jusqu'à en trouver une que le serpent ne couvre pas. Quand le serpent remplit tout le plateau, il ne reste aucune case libre : la partie s'arrête, et vous avez gagné.

Le record vit dans un fichier, serpent.record, lu au lancement et écrit quand une partie le bat. Les deux passent par pcall : sans disque, ou avec un fichier qui ne contient pas un nombre (tonumber donne nil), le jeu tourne quand même, en partant de 0.

fin_de_partie dessine un cadre par-dessus le plateau avec dessiner_cadre. Ensuite Espace ou Entrée lance une nouvelle partie, qui redessine tout le plateau une fois. P met en pause et reprend à tout moment.

Le programme complet

serpent
-- Le serpent : mangez les pommes rouges, ne mordez ni votre queue ni les murs.
-- Flèches, WASD ou ZQSD pour diriger, P pour la pause, Ctrl+T pour quitter.

-- 1. Le plateau, calculé d'après la taille de l'écran
local CASE = 8                                  -- pixels par case
local BARRE = 16                                -- la barre de score au-dessus du plateau
local taille = gfx.size()
local W, H = taille.w, taille.h
local COLONNES = (W - 2) // CASE
local LIGNES = (H - BARRE - 2) // CASE
local X0 = (W - COLONNES * CASE) // 2 + 1       -- premier pixel du plateau
local Y0 = BARRE + 1
local FICHIER_RECORD = "serpent.record"

-- les noms des flèches tels que les envoie l'évènement key
local DIRECTIONS = {
  up = {x = 0, y = -1}, down = {x = 0, y = 1},
  left = {x = -1, y = 0}, right = {x = 1, y = 0},
}
local LETTRES = {w = "up", z = "up", s = "down", a = "left", q = "left", d = "right"}

-- 2. L'état d'une partie
local serpent = {}        -- des cases {x =, y =}, la tête en premier
local occupe = {}         -- occupe[cle(x, y)] = true là où est le serpent
local direction, prochaine   -- la direction du dernier pas, et celle demandée pour le prochain
local pomme = nil
local score, record = 0, 0
local etat = "jeu"        -- "jeu", "pause" ou "fini"
local minuterie = nil

local function cle(x, y)
  return y * 1000 + x
end

-- 3. Le dessin
local function dessiner_case(x, y, couleur)
  gfx.rect(X0 + (x - 1) * CASE, Y0 + (y - 1) * CASE, CASE - 1, CASE - 1, couleur, true)
end

local function dessiner_score()
  gfx.rect(1, 1, W, BARRE - 1, "black", true)
  gfx.text(4, 3, "SCORE " .. score, "white", 2)
  local texte = "RECORD " .. record
  gfx.text(W - #texte * 8, 3, texte, "yellow", 2)
end

local function dessiner_plateau()
  gfx.clear("black")
  gfx.rect(X0 - 1, Y0 - 1, COLONNES * CASE + 1, LIGNES * CASE + 1, "gray")
  for i, c in ipairs(serpent) do
    dessiner_case(c.x, c.y, i == 1 and "lime" or "green")
  end
  if pomme then dessiner_case(pomme.x, pomme.y, "red") end
  dessiner_score()
end

-- Un cadre au milieu du plateau, avec des lignes de texte
local function dessiner_cadre(lignes, couleur)
  local cl, ch = 200, 12 + #lignes * 14
  local cx, cy = (W - cl) // 2, Y0 + (LIGNES * CASE - ch) // 2
  gfx.rect(cx, cy, cl, ch, "black", true)
  gfx.rect(cx, cy, cl, ch, couleur, 2)
  for i, ligne in ipairs(lignes) do
    gfx.text((W - (#ligne * 8 - 2)) // 2, cy + 8 + (i - 1) * 14, ligne, i == 1 and couleur or "white", 2)
  end
end

-- 4. Les règles
local function placer_pomme()
  if #serpent == COLONNES * LIGNES then
    pomme = nil
    return false   -- plus une case libre : le plateau est plein
  end
  repeat
    pomme = {x = math.random(1, COLONNES), y = math.random(1, LIGNES)}
  until not occupe[cle(pomme.x, pomme.y)]
  dessiner_case(pomme.x, pomme.y, "red")
  return true
end

local function delai()
  return math.max(0.05, 0.2 - score * 0.005)   -- un peu plus vite à chaque pomme
end

local function nouvelle_partie()
  serpent, occupe, score, pomme = {}, {}, 0, nil
  local cx, cy = COLONNES // 2, LIGNES // 2
  for i = 0, 2 do
    table.insert(serpent, {x = cx - i, y = cy})
    occupe[cle(cx - i, cy)] = true
  end
  direction, prochaine = DIRECTIONS.right, DIRECTIONS.right
  dessiner_plateau()
  placer_pomme()
  etat = "jeu"
  minuterie = os.start_timer(delai())
end

local function fin_de_partie()
  etat = "fini"
  local lignes = {"PARTIE FINIE", "SCORE " .. score}
  if score > record then
    record = score
    pcall(fs.write, FICHIER_RECORD, tostring(record))   -- sans disque, le record n'est pas gardé
    table.insert(lignes, "NOUVEAU RECORD !")
  end
  table.insert(lignes, "ESPACE : REJOUER")
  dessiner_score()
  dessiner_cadre(lignes, "red")
end

-- Pas de demi-tour : le serpent se mordrait le cou
local function diriger(nom)
  local d = DIRECTIONS[nom]
  if d and not (d.x == -direction.x and d.y == -direction.y) then prochaine = d end
end

-- Un pas : la tête avance, la queue suit, sauf si une pomme a été mangée
local function avancer()
  direction = prochaine
  local tete = serpent[1]
  local nx, ny = tete.x + direction.x, tete.y + direction.y
  local mange = pomme ~= nil and nx == pomme.x and ny == pomme.y
  if not mange then
    local queue = table.remove(serpent)
    occupe[cle(queue.x, queue.y)] = nil
    dessiner_case(queue.x, queue.y, "black")
  end
  if nx < 1 or nx > COLONNES or ny < 1 or ny > LIGNES or occupe[cle(nx, ny)] then
    fin_de_partie()
    return
  end
  dessiner_case(tete.x, tete.y, "green")
  table.insert(serpent, 1, {x = nx, y = ny})
  occupe[cle(nx, ny)] = true
  dessiner_case(nx, ny, "lime")
  if mange then
    score = score + 1
    dessiner_score()
    if not placer_pomme() then fin_de_partie() end
  end
end

local function basculer_pause()
  if etat == "jeu" then
    etat = "pause"
    dessiner_cadre({"PAUSE", "P : REPRENDRE"}, "yellow")
  elseif etat == "pause" then
    etat = "jeu"
    dessiner_plateau()
    minuterie = os.start_timer(delai())
  end
end

-- 5. Boucle principale : la minuterie donne le rythme, les touches ne changent que la direction
local r = pcall(fs.read, FICHIER_RECORD)
if r.ok then record = tonumber(r.value) or 0 end
term.clear()
nouvelle_partie()
while true do
  local e = os.pull_event()
  if e.name == "timer" and e.id == minuterie and etat == "jeu" then
    avancer()
    if etat == "jeu" then minuterie = os.start_timer(delai()) end
  elseif e.name == "key" and etat == "jeu" then
    diriger(e.key)                                -- "up", "down", "left", "right"
  elseif e.name == "key" and e.key == "enter" and etat == "fini" then
    nouvelle_partie()
  elseif e.name == "char" then
    local c = string.lower(e.char)
    if c == "p" then
      basculer_pause()
    elseif c == " " and etat == "fini" then
      nouvelle_partie()
    elseif etat == "jeu" then
      diriger(LETTRES[c])
    end
  end
end

À quoi ça ressemble

Une partie en cours, avec un serpent et une pomme posés à la main, dessinés par les mêmes fonctions que le programme :

démo de partie
local CASE, BARRE = 8, 16
local taille = gfx.size()
local W, H = taille.w, taille.h
local COLONNES = (W - 2) // CASE
local LIGNES = (H - BARRE - 2) // CASE
local X0 = (W - COLONNES * CASE) // 2 + 1
local Y0 = BARRE + 1

-- un état écrit à la main : 14 cases, la tête en premier
local serpent = {}
for _, p in ipairs({{24, 6}, {23, 6}, {22, 6}, {21, 6}, {21, 7}, {21, 8}, {21, 9}, {20, 9},
    {19, 9}, {18, 9}, {17, 9}, {17, 10}, {17, 11}, {16, 11}}) do
  table.insert(serpent, {x = p[1], y = p[2]})
end
local pomme = {x = 30, y = 6}
local score, record = 11, 23

local function dessiner_case(x, y, couleur)
  gfx.rect(X0 + (x - 1) * CASE, Y0 + (y - 1) * CASE, CASE - 1, CASE - 1, couleur, true)
end
local function dessiner_score()
  gfx.rect(1, 1, W, BARRE - 1, "black", true)
  gfx.text(4, 3, "SCORE " .. score, "white", 2)
  local texte = "RECORD " .. record
  gfx.text(W - #texte * 8, 3, texte, "yellow", 2)
end
local function dessiner_plateau()
  gfx.clear("black")
  gfx.rect(X0 - 1, Y0 - 1, COLONNES * CASE + 1, LIGNES * CASE + 1, "gray")
  for i, c in ipairs(serpent) do
    dessiner_case(c.x, c.y, i == 1 and "lime" or "green")
  end
  if pomme then dessiner_case(pomme.x, pomme.y, "red") end
  dessiner_score()
end

dessiner_plateau()
Écran
Écran

Et la fin d'une partie qui a battu le record, avec le cadre de fin_de_partie :

démo de fin de partie
local CASE, BARRE = 8, 16
local taille = gfx.size()
local W, H = taille.w, taille.h
local COLONNES = (W - 2) // CASE
local LIGNES = (H - BARRE - 2) // CASE
local X0 = (W - COLONNES * CASE) // 2 + 1
local Y0 = BARRE + 1

-- 27 cases : la tête est montée dans le mur du haut
local serpent = {{x = 31, y = 1}, {x = 31, y = 2}, {x = 31, y = 3}}
for x = 30, 7, -1 do
  table.insert(serpent, {x = x, y = 3})
end
local pomme = {x = 27, y = 17}
local score, record = 24, 24

local function dessiner_case(x, y, couleur)
  gfx.rect(X0 + (x - 1) * CASE, Y0 + (y - 1) * CASE, CASE - 1, CASE - 1, couleur, true)
end
local function dessiner_score()
  gfx.rect(1, 1, W, BARRE - 1, "black", true)
  gfx.text(4, 3, "SCORE " .. score, "white", 2)
  local texte = "RECORD " .. record
  gfx.text(W - #texte * 8, 3, texte, "yellow", 2)
end
local function dessiner_plateau()
  gfx.clear("black")
  gfx.rect(X0 - 1, Y0 - 1, COLONNES * CASE + 1, LIGNES * CASE + 1, "gray")
  for i, c in ipairs(serpent) do
    dessiner_case(c.x, c.y, i == 1 and "lime" or "green")
  end
  if pomme then dessiner_case(pomme.x, pomme.y, "red") end
  dessiner_score()
end
local function dessiner_cadre(lignes, couleur)
  local cl, ch = 200, 12 + #lignes * 14
  local cx, cy = (W - cl) // 2, Y0 + (LIGNES * CASE - ch) // 2
  gfx.rect(cx, cy, cl, ch, "black", true)
  gfx.rect(cx, cy, cl, ch, couleur, 2)
  for i, ligne in ipairs(lignes) do
    gfx.text((W - (#ligne * 8 - 2)) // 2, cy + 8 + (i - 1) * 14, ligne, i == 1 and couleur or "white", 2)
  end
end

dessiner_plateau()
dessiner_cadre({"PARTIE FINIE", "SCORE " .. score, "NOUVEAU RECORD !", "ESPACE : REJOUER"}, "red")
Écran
Écran

Le tester

  1. edit serpent, collez, enregistrez, puis tapez serpent. Le serpent part du milieu, vers la droite.
  2. Dirigez avec les flèches, puis avec les lettres de votre clavier : les deux marchent à tout moment.
  3. Essayez de faire demi-tour (gauche en allant à droite) : rien ne se passe. Appuyez vite sur haut puis gauche, entre deux pas : le serpent monte et ne se mord pas le cou, car la gauche a été refusée (il allait encore à droite). Appuyez de nouveau sur gauche une fois qu'il monte.
  4. Mangez quelques pommes : le score monte et le serpent accélère.
  5. Foncez dans un mur : PARTIE FINIE et NOUVEAU RECORD !. Quittez avec Ctrl+T et regardez le record :

    Terminal
    > cat serpent.record
    7
  6. Relancez serpent : RECORD affiche 7. Appuyez sur P pendant une partie pour la pause, encore pour reprendre.

Variantes

  • Des murs qui bouclent. Au lieu de perdre au bord, ressortez de l'autre côté : nx = (nx - 1) % COLONNES + 1 et de même pour ny, avant le test de collision (% ne donne jamais de nombre négatif).
  • Des obstacles. Au début d'une partie, marquez quelques cases dans occupe et dessinez-les en gris : le test de collision s'en occupe déjà, et placer_pomme n'y mettra pas de pomme.
  • Des pommes en or. Une pomme sur dix (math.random(10) == 1) est jaune et vaut cinq points, mais disparaît au bout de quelques secondes : une seconde minuterie.
  • Sur un écran tactile. Les touches ne viennent que du terminal ; sur un moniteur, utilisez plutôt les évènements click : un clic à gauche de la tête tourne à gauche, à droite tourne à droite, avec e.px et e.py.
  • À deux joueurs. L'exemple pong montre comment deux ordinateurs partagent une partie par le réseau.