536 lines
20 KiB
Text
536 lines
20 KiB
Text
[#_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.
|