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
gfxet 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.
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)
endLa 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.
local serpent = {} -- des cases {x =, y =}, la tête en premier
local occupe = {} -- occupe[cle(x, y)] = true là où est le serpent3. 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.
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
endL'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.
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.
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
end5. 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
-- 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 :
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()
Et la fin d'une partie qui a battu le record, avec le cadre 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")
Le tester
edit serpent, collez, enregistrez, puis tapezserpent. Le serpent part du milieu, vers la droite.- Dirigez avec les flèches, puis avec les lettres de votre clavier : les deux marchent à tout moment.
- 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.
- Mangez quelques pommes : le score monte et le serpent accélère.
Foncez dans un mur : PARTIE FINIE et NOUVEAU RECORD !. Quittez avec Ctrl+T et regardez le record :
> cat serpent.record 7
- 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 + 1et de même pourny, avant le test de collision (%ne donne jamais de nombre négatif). - Des obstacles. Au début d'une partie, marquez quelques cases dans
occupeet dessinez-les en gris : le test de collision s'en occupe déjà, etplacer_pommen'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, avece.pxete.py. - À deux joueurs. L'exemple
pongmontre comment deux ordinateurs partagent une partie par le réseau.