singe/docs/fr/lessons/29-databases.adoc

536 lines
20 KiB
Text
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

[#_lesson_29_keeping_data]
== Leçon 29 : Garder des données
image::learn/29-databases.png[La leçon terminée, 480]
La leçon douze sauvegardait un meilleur score : un nombre, sous un nom, toujours
là le lendemain matin. C'est exactement ce qu'il faut pour un meilleur score, et
ce qu'il ne faut pas pour presque tout le reste de ce qu'un jeu veut garder. Une
liste de toutes les parties que quelqu'un a jouées. Le meilleur temps sur
chacun de vingt circuits. Quarante niveaux que quelqu'un a conçus dans un
tableur. La question « quel est mon meilleur score sur l'épreuve difficile ? »,
à laquelle une pile de nombres sauvegardés ne peut pas répondre sans que vous
écriviez la réponse à la main.
C'est à ça que sert une base de données, et Singe en a une intégrée. Cette
leçon reprend le test de réaction de la leçon vingt-huit, lui donne des épreuves
livrées avec le jeu, et lui fait garder chaque partie jouée par qui que ce soit
et répondre à des questions à leur sujet.
[#_what_a_database_is]
=== Ce qu'est une base de données
Une base de données est un fichier de *tables*. Une table, ce sont des lignes et
des colonnes, comme un tableur dont les noms de colonnes sont décidés à
l'avance : une table de parties pourrait avoir une épreuve, un score et une
heure pour chaque ligne. Vous y mettez des lignes et vous posez des questions
pour en faire sortir, et les questions s'écrivent en *SQL*, un petit langage
qui fait ce travail depuis avant la plupart des langages de programmation dont
vous avez entendu parler.
La base de données que Singe emporte avec lui est *SQLite*. Ce n'est pas un
serveur qu'on installe ni un service auquel on s'inscrit. C'est une
bibliothèque, la base de données est un fichier ordinaire, et le même SQLite se
trouve dans votre téléphone, dans votre navigateur et probablement dans votre
voiture. Vous n'avez pas besoin de connaître le SQL pour suivre cette leçon ;
chaque instruction y est expliquée au passage, et les trois dont vous vous
servirez le plus -- `CREATE TABLE`, `INSERT` et `SELECT` -- sont toutes là.
[#_opening_one]
=== En ouvrir une
[source,lua]
----
local sqlite3 = require("sqlite3")
local runs = sqlite3.open(singeGetDataPath() .. "runs.db")
----
`require("sqlite3")` charge une bibliothèque fournie avec Singe et vous la rend
sous forme de table, si bien que tout ce que SQLite sait faire s'écrit
`sqlite3.` quelque chose. `sqlite3.open` ouvre un fichier de base de données et
le crée s'il n'existe pas encore : le premier lancement de votre jeu en crée un
vide, et chaque lancement suivant le retrouve.
Le fichier va dans `singeGetDataPath()`, pour la même raison que la sauvegarde
de la leçon douze : le dossier d'un jeu peut se trouver sur un disque, sur un
partage réseau, ou être empaqueté dans un seul fichier, et on ne peut écrire
dans aucun d'eux. Tout ce qu'un jeu écrit va dans son dossier de données.
[#_making_a_table]
=== Créer une table
Une base de données vide n'a aucune table. Vous en créez une en la décrivant :
[source,lua]
----
runs:exec([[
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY,
stage TEXT NOT NULL,
score INTEGER NOT NULL,
at INTEGER NOT NULL
)
]])
----
`exec` exécute du SQL qui ne renvoie rien. Les doubles crochets sont la chaîne
longue de Lua : tout ce qui se trouve entre eux, retours à la ligne compris,
forme une seule chaîne, et c'est comme ça qu'on garde le SQL lisible.
`CREATE TABLE` nomme la table et ses colonnes, chacune avec un type : `TEXT`
pour les mots et `INTEGER` pour les nombres entiers. `NOT NULL` veut dire que
chaque ligne doit avoir une valeur à cet endroit. `INTEGER PRIMARY KEY` est
spécial : SQLite le remplit tout seul, en comptant, si bien que chaque ligne
reçoit son propre numéro sans que vous ayez à y penser.
`IF NOT EXISTS` est ce qui permet de l'exécuter sans risque à chaque démarrage
du jeu. La première fois, il crée la table. Toutes les fois suivantes, la table
est déjà là et la ligne ne fait rien. Sans ces trois mots, le deuxième
lancement de votre jeu s'arrêterait sur une erreur disant que la table existe
déjà.
[#_putting_a_row_in]
=== Ajouter une ligne
Vous pourriez construire le SQL d'une nouvelle ligne en collant des chaînes
bout à bout :
[source,lua]
----
runs:exec("INSERT INTO runs (stage, score, at) VALUES ('" .. stage .. "', " .. score .. ", " .. os.time() .. ")")
----
Ne le faites pas. Ça marche jusqu'au jour où un nom contient une apostrophe :
l'apostrophe termine alors la chaîne trop tôt et l'instruction n'a plus aucun
sens. Dans un jeu qui laisse les joueurs taper, c'est pire : tout ce qu'ils
tapent devient un morceau de votre SQL.
La bonne manière consiste à écrire l'instruction une fois, avec des points
d'interrogation à la place des valeurs, et à passer les valeurs à part :
[source,lua]
----
local addRun = runs:prepare("INSERT INTO runs (stage, score, at) VALUES (?, ?, ?)")
local function record(stage, points)
addRun:bind_values(stage, points, os.time())
addRun:step()
addRun:reset()
end
----
`prepare` transforme le SQL en une *instruction* qu'on peut utiliser encore et
encore. `bind_values` met une valeur dans chaque point d'interrogation, dans
l'ordre. Quoi qu'une valeur contienne, apostrophes comprises, c'est une valeur
et jamais un morceau du SQL. `step` exécute l'instruction, et `reset` la remet
en état d'être exécutée de nouveau. `os.time()` est du Lua tout simple : l'heure
actuelle sous forme de nombre de secondes, ce qui est la façon la plus facile
de stocker « quand ».
Préparer une fois et réutiliser l'instruction est aussi plus rapide, parce que
SQLite ne calcule qu'une seule fois comment l'exécuter. Pour un jeu qui écrit
une ligne par minute, ça ne compte pas. Pour un éditeur de niveaux qui en écrit
mille, si.
[#_asking_questions]
=== Poser des questions
Les lignes ressortent avec `nrows`, qui exécute un `SELECT` et vous rend chaque
ligne sous forme de table, avec les noms des colonnes comme clés :
[source,lua]
----
for row in runs:nrows("SELECT stage, score FROM runs ORDER BY score DESC LIMIT 5") do
debugPrint(row.stage .. " " .. row.score)
end
----
Lisez le SQL de gauche à droite. `SELECT stage, score` dit quelles colonnes vous
voulez. `FROM runs` dit quelle table. `ORDER BY score DESC` trie les lignes par
score, le plus haut d'abord, et `LIMIT 5` s'arrête après cinq. C'est un tableau
des meilleurs scores, et il n'a jamais eu besoin de code de tri de votre part.
Les questions peuvent être plus astucieuses que celles que vous auriez envie
d'écrire à la main :
[source,lua]
----
for row in runs:nrows("SELECT stage, MAX(score) AS top, COUNT(*) AS tries FROM runs GROUP BY stage ORDER BY stage") do
debugPrint(row.stage .. ": best " .. row.top .. " of " .. row.tries)
end
----
`GROUP BY stage` rassemble les lignes en un groupe par épreuve, et `MAX(score)`
et `COUNT(*)` sont calculés pour chaque groupe : le meilleur score sur cette
épreuve et le nombre de fois qu'elle a été jouée. `AS top` donne un nom à la
réponse, et c'est la clé avec laquelle vous la relisez.
Quand une question n'a qu'une réponse, `urows` vous rend les colonnes
elles-mêmes plutôt qu'une table, ce qui est plus net pour un comptage :
[source,lua]
----
for count in runs:urows("SELECT COUNT(*) FROM runs") do
played = count
end
----
[#_ask_when_it_changes]
=== Demandez quand ça change
Aucune de ces questions n'a sa place dans `onOverlayUpdate`. Une requête est
rapide, mais soixante par seconde, pour obtenir des réponses qui changent une
fois par minute, c'est le jeu qui pose la même question et obtient la même
réponse cinquante-neuf fois sur soixante.
Alors le jeu demande quand la réponse a pu changer -- une fois au début, et de
nouveau chaque fois qu'une partie est enregistrée -- et garde ce qui est revenu
dans des tables ordinaires :
[source,lua]
----
local function refresh()
best = {}
for row in runs:nrows("SELECT stage, score FROM runs ORDER BY score DESC, id LIMIT 5") do
best[#best + 1] = row
end
-- et pareil pour les deux autres questions
end
----
`onOverlayUpdate` dessine à partir de `best`, ce qui ne coûte rien. `record`
appelle `refresh` après chaque nouvelle ligne, donc le tableau à l'écran n'est
jamais périmé.
[#_data_that_ships_with_the_game]
=== Les données livrées avec le jeu
L'autre moitié des données d'un jeu, c'est la partie que vous écrivez avant que
quiconque y joue : les épreuves, les niveaux, les objets, les répliques des
dialogues. Ça aussi, ça peut être une base de données, créée avec n'importe quel
outil SQLite -- le programme `sqlite3` en ligne de commande, ou un programme
avec des fenêtres comme DB Browser for SQLite -- et rangée dans le dossier de
votre jeu à côté de ses images. Le kit graphique en a une, `stages.db`, avec un
nom et l'attente la plus courte et la plus longue de chaque épreuve.
Un jeu ouvre celle-là autrement :
[source,lua]
----
local stages = {}
local shipped = sqlite3.open(DIR .. "art/stages.db", sqlite3.OPEN_READONLY)
for row in shipped:nrows("SELECT name, waitMin, waitMax FROM stages ORDER BY id") do
stages[#stages + 1] = row
end
shipped:close()
----
`DIR` est le dossier de votre jeu, là où sont les images.
`sqlite3.OPEN_READONLY` dit que vous comptez seulement la lire, et ça compte
pour le jour où vous publierez. Quand vous empaquetterez votre jeu dans un seul
fichier à la leçon suivante, une base de données ouverte en lecture seule sera
lue directement dans ce fichier, sans rien dépaqueter. Une base ouverte en
écriture est copiée, une fois, dans le dossier de données du jeu, et c'est la
copie que vous modifiez à partir de là ; le fichier que vous avez publié n'est
jamais écrit.
Alors gardez les deux sortes à part. Ce que vous avez fait va dans votre dossier
et s'ouvre en lecture seule. Ce que le jeu fait va dans le dossier de données.
Ici, les épreuves sont lues une seule fois au début dans une table ordinaire et
la base de données est fermée, parce qu'une liste de quatre épreuves ne vaut
pas la peine de garder un fichier ouvert.
[#_closing_up]
=== Tout fermer
Les instructions et les bases de données gardent de la mémoire et un fichier
ouvert jusqu'à ce que vous disiez que vous avez fini, et l'endroit propre pour
le dire, c'est `onShutdown` :
[source,lua]
----
function onShutdown()
addRun:finalize()
runs:close()
end
----
`finalize`, c'est le `close` d'une instruction. Une base de données ne se ferme
pas tant qu'une instruction créée à partir d'elle existe encore, donc les
instructions passent en premier.
[#_the_whole_script_4]
=== Le script complet
Créez un dossier appelé `runs`, copiez-y le dossier `art` du livre pour
`stages.db`, et mettez ceci dans `runs.singe` :
[source,lua]
----
dofile("Singe/Framework.singe")
local sqlite3 = require("sqlite3")
local PERFECT = 1000
-- Ce qui est livré avec le jeu : les épreuves, lues une fois puis fermées.
local stages = {}
local shipped = sqlite3.open(DIR .. "art/stages.db", sqlite3.OPEN_READONLY)
for row in shipped:nrows("SELECT name, waitMin, waitMax FROM stages ORDER BY id") do
stages[#stages + 1] = row
end
shipped:close()
-- Ce que le jeu écrit : chaque partie jouée, gardée dans le dossier de données.
local runs = sqlite3.open(singeGetDataPath() .. "runs.db")
runs:exec([[
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY,
stage TEXT NOT NULL,
score INTEGER NOT NULL,
at INTEGER NOT NULL
)
]])
local addRun = runs:prepare("INSERT INTO runs (stage, score, at) VALUES (?, ?, ?)")
local stageAt = 1
local state = "attract"
local goAt = 0
local score = 0
local best = {}
local perStage = {}
local played = 0
-- Les questions, posées quand une partie est ajoutée plutôt qu'à chaque image.
local function refresh()
best = {}
for row in runs:nrows("SELECT stage, score FROM runs ORDER BY score DESC, id LIMIT 5") do
best[#best + 1] = row
end
perStage = {}
for row in runs:nrows("SELECT stage, MAX(score) AS top, COUNT(*) AS tries FROM runs GROUP BY stage ORDER BY stage") do
perStage[#perStage + 1] = row
end
for count in runs:urows("SELECT COUNT(*) FROM runs") do
played = count
end
end
local function record(stage, points)
addRun:bind_values(stage, points, os.time())
addRun:step()
addRun:reset()
refresh()
end
local function play()
local stage = stages[stageAt]
state = "waiting"
goAt = singeGetTicks() + math.random(stage.waitMin, stage.waitMax)
score = 0
end
function onInputPressed(what)
if what == SWITCH_BUTTON2 and state ~= "waiting" and state ~= "go" then
stageAt = stageAt % #stages + 1
return
end
if what ~= SWITCH_BUTTON1 then
return
end
if state == "waiting" then
state = "result"
score = 0
record(stages[stageAt].name, score)
elseif state == "go" then
score = math.max(0, PERFECT - (singeGetTicks() - goAt))
state = "result"
record(stages[stageAt].name, score)
else
play()
end
end
function onOverlayUpdate()
if state == "waiting" and singeGetTicks() >= goAt then
state = "go"
end
overlayClear()
overlayPrint(2, 0, "REACTION TEST: " .. stages[stageAt].name)
if state == "attract" then
overlayPrint(2, 2, "Button 1 to play, button 2 for another stage.")
elseif state == "waiting" then
overlayPrint(2, 2, "Wait for it...")
elseif state == "go" then
overlayPrint(2, 2, "NOW!")
else
overlayPrint(2, 2, "You scored " .. score .. ". Button 1 plays again.")
end
overlayPrint(2, 4, "Runs kept: " .. played)
overlayPrint(2, 6, "BEST RUNS")
for place, row in ipairs(best) do
overlayPrint(2, 6 + place, string.format("%d. %-8s %4d", place, row.stage, row.score))
end
overlayPrint(24, 6, "BY STAGE")
for line, row in ipairs(perStage) do
overlayPrint(24, 6 + line, string.format("%-8s best %4d of %d", row.stage, row.top, row.tries))
end
return OVERLAY_UPDATED
end
function onShutdown()
addRun:finalize()
runs:close()
end
refresh()
----
Lancez-le avec `Singe -R runs`. Le bouton 1 lance une manche et l'arrête, comme
à la leçon vingt-huit, et le bouton 2, entre les manches, passe à l'épreuve suivante.
Jouez-en quelques-unes sur chaque épreuve. Puis quittez et relancez : chaque
partie est toujours là, les cinq meilleures à gauche et le meilleur score de
chaque épreuve à droite.
[#_what_just_happened_24]
=== Ce qui vient de se passer
[source,lua]
----
local runs = sqlite3.open(singeGetDataPath() .. "runs.db")
----
Deux fichiers sont en jeu, et ils ne se ressemblent pas. `art/stages.db` est à
vous : vous l'avez écrit, il est livré avec le jeu, et le jeu ne fait que le
lire. `runs.db` est au jeu : il n'existait pas avant que le premier lancement
le crée, il vit dans le dossier de données, et personne ne le publie.
[source,lua]
----
if state == "waiting" then
state = "result"
score = 0
record(stages[stageAt].name, score)
----
Un bouton pressé trop tôt enregistre quand même une partie, avec un score de
rien du tout. La base de données garde la trace de ce qui s'est passé, et un
faux départ a bien eu lieu. Montrer ou non ces lignes, c'est une question pour
le `SELECT`, pas quelque chose à cacher en ne les écrivant jamais.
[source,lua]
----
for row in runs:nrows("SELECT stage, score FROM runs ORDER BY score DESC, id LIMIT 5") do
----
`ORDER BY score DESC, id` trie par score et, quand deux scores sont égaux, par
`id` -- l'ordre dans lequel les parties ont été jouées. Sans la seconde clé,
deux scores égaux reviennent dans l'ordre qui arrange SQLite, et cet ordre peut
changer d'un lancement à l'autre. Un tableau qui se remélange sans raison
visible a l'air cassé, même quand il ne l'est pas.
[#_try_it_29]
=== Essayez
. *Affichez la date.* `os.date("%Y-%m-%d", row.at)` retransforme une heure
stockée en quelque chose qu'une personne peut lire. Ajoutez la date à chacune
des meilleures parties.
. *Ignorez les faux départs.* Ajoutez `WHERE score > 0` à la question des
meilleures parties, entre `FROM runs` et `ORDER BY`. Puis comptez les faux
départs sur chaque épreuve avec une question de votre cru.
. *Ajoutez une épreuve.* Ouvrez `stages.db` dans un outil SQLite, ajoutez une
ligne, et lancez le jeu. Rien ne change dans le script. C'est tout l'intérêt
de garder les données hors du script.
. *Remettez l'historique à zéro.* `runs:exec("DELETE FROM runs")` vide la table.
Mettez-le sur un bouton, appelez `refresh` juste après, et regardez tout
retomber à rien. C'est le « remettre à zéro les meilleurs scores » d'un menu
de service.
. *Écrivez mille lignes.* Appelez `record` mille fois dans une boucle et
chronométrez avec `singeGetTicks`. Puis mettez `runs:exec("BEGIN")` avant la
boucle et `runs:exec("COMMIT")` après, et chronométrez de nouveau. Une
*transaction* fait de nombreuses écritures une seule, et la différence est
assez grande pour mériter qu'on s'en souvienne.
[#_break_it_on_purpose_28]
=== Cassez-le exprès
Retirez `addRun:reset()` de `record` et jouez trois manches sur trois épreuves
différentes.
Aucune erreur. Le jeu tourne parfaitement. Mais le tableau de droite dit que
vous avez joué la première épreuve trois fois, et les meilleures parties sont
trois copies de votre premier score. `bind_values` n'a pas pu mettre de
nouvelles valeurs dans une instruction déjà exécutée et pas remise à zéro, alors
il a abandonné -- en silence, en renvoyant un nombre que vous ne vérifiiez
pas -- et `step` a exécuté l'instruction de nouveau avec les valeurs qu'elle
avait déjà.
C'est le bogue qui compte dans cette leçon, parce qu'il n'en a pas l'air. Une
requête qui échoue fait du bruit. Une écriture qui écrit la mauvaise chose est
silencieuse, et vous la découvrez une semaine plus tard dans les données. Quand
les lignes d'une base de données ne sont pas celles que vous attendez, regardez
le code qui les y a mises avant le code qui les lit.
Remettez `reset`, et cassez-le de la manière bruyante : changez `score` en
`scroe` dans le premier `SELECT` de `refresh`.
----
Error running script: no such column: scroe
stack traceback:
[C]: in method 'nrows'
runs/runs.singe:42: in local 'refresh'
runs/runs.singe:132: in main chunk
----
SQLite lit les noms de colonnes quand il prépare la question, donc un nom mal
orthographié arrête le jeu tout de suite, et le message le nomme exactement. La
trace de pile se lit de l'intérieur vers l'extérieur : `nrows` s'est plaint,
`refresh` l'a appelé à la ligne 42, et le script lui-même a appelé `refresh` à
la ligne 132, la dernière, au démarrage du jeu. C'est pour ça que le message
dit *running script* au lieu de nommer une fonction que le moteur a appelée :
le jeu n'est jamais arrivé jusqu'à la première image.
[#_what_you_learned_29]
=== Ce que vous avez appris
* Une base de données est un fichier de tables ; une table, ce sont des lignes
avec des colonnes qui ont un nom.
* `require("sqlite3")` vous donne SQLite. `sqlite3.open` ouvre un fichier et le
crée s'il manque.
* Ce que le jeu écrit va dans `singeGetDataPath()`. Ce que vous publiez va dans
votre dossier, ouvert avec `sqlite3.OPEN_READONLY`.
* `CREATE TABLE IF NOT EXISTS` s'exécute sans risque à chaque démarrage du jeu.
* Les valeurs entrent par `prepare`, `bind_values`, `step` et `reset`, jamais
en collant des chaînes dans le SQL.
* `nrows` rend les lignes sous forme de tables ; `urows` rend les colonnes
elles-mêmes.
* `ORDER BY`, `LIMIT`, `GROUP BY`, `MAX` et `COUNT` répondent aux questions pour
lesquelles vous écririez sinon des boucles.
* Demandez quand la réponse peut changer, pas à chaque image.
* Dans `onShutdown`, on finalise les instructions, puis on ferme la base de
données.
[#_next_time_29]
=== La prochaine fois
Votre jeu a maintenant un dossier plein de choses : un script, des images, des
sons, et une base de données ou deux. La leçon trente transforme ce dossier en
un seul fichier qu'un inconnu peut copier sur sa propre machine et jouer -- et
votre base de données en lecture seule part avec, et s'ouvre toujours
exactement comme maintenant.