509 lines
20 KiB
Text
509 lines
20 KiB
Text
[#_lesson_29_keeping_data]
|
|
== Lektion 29: Daten aufbewahren
|
|
|
|
image::learn/29-databases.png[Die fertige Lektion, 480]
|
|
|
|
Lektion zwölf hat einen Highscore gespeichert: eine Zahl, unter einem Namen, am nächsten
|
|
Morgen noch da. Für einen Highscore ist das genau richtig, und für fast alles andere, was ein
|
|
Spiel aufbewahren will, ist es falsch. Eine Liste jeder Runde, die irgendwer gespielt hat. Die
|
|
beste Zeit auf jeder von zwanzig Strecken. Vierzig Level, die jemand in einer
|
|
Tabellenkalkulation entworfen hat. Die Frage „Was ist mein Bestwert auf der schweren Stufe?“,
|
|
die ein Haufen gespeicherter Zahlen nicht beantworten kann, ohne dass du die Antwort von Hand
|
|
schreibst.
|
|
|
|
Genau dafür ist eine Datenbank da, und Singe hat eine eingebaut. Diese Lektion nimmt den
|
|
Reaktionstest aus Lektion achtundzwanzig, gibt ihm Stufen, die mit dem Spiel ausgeliefert
|
|
werden, und lässt ihn jeden Durchgang aufbewahren, den irgendwer spielt, und Fragen dazu
|
|
beantworten.
|
|
|
|
[#_what_a_database_is]
|
|
=== Was eine Datenbank ist
|
|
|
|
Eine Datenbank ist eine Datei voller *Tabellen*. Eine Tabelle besteht aus Zeilen und Spalten,
|
|
wie eine Tabellenkalkulation, bei der die Spaltennamen vorher feststehen: Eine Tabelle mit
|
|
Durchgängen könnte in jeder Zeile eine Stufe, eine Punktzahl und eine Zeit haben. Du legst
|
|
Zeilen hinein und stellst Fragen, um Zeilen herauszubekommen, und die Fragen werden in *SQL*
|
|
geschrieben, einer kleinen Sprache, die diese Arbeit schon gemacht hat, bevor es die meisten
|
|
Programmiersprachen gab, von denen du je gehört hast.
|
|
|
|
Die Datenbank, die Singe mitbringt, ist *SQLite*. Sie ist kein Server, den du installierst,
|
|
und kein Dienst, bei dem du dich registrierst. Sie ist eine Bibliothek, die Datenbank ist eine
|
|
gewöhnliche Datei, und dasselbe SQLite steckt in deinem Handy, in deinem Browser und
|
|
wahrscheinlich in deinem Auto. Du musst kein SQL können, um dieser Lektion zu folgen; jede
|
|
Anweisung darin wird erklärt, wenn sie vorbeikommt, und die drei, die du am häufigsten brauchen
|
|
wirst -- `CREATE TABLE`, `INSERT` und `SELECT` -- sind alle dabei.
|
|
|
|
[#_opening_one]
|
|
=== Eine öffnen
|
|
|
|
[source,lua]
|
|
----
|
|
local sqlite3 = require("sqlite3")
|
|
|
|
local runs = sqlite3.open(singeGetDataPath() .. "runs.db")
|
|
----
|
|
|
|
`require("sqlite3")` lädt eine Bibliothek, die mit Singe kommt, und gibt sie dir als Tabelle
|
|
zurück, also ist alles, was SQLite kann, `sqlite3.` irgendwas. `sqlite3.open`
|
|
öffnet eine Datenbankdatei und
|
|
legt sie an, wenn es sie noch nicht gibt, also macht der erste Start deines Spiels eine leere,
|
|
und jeder Start danach findet sie wieder.
|
|
|
|
Die Datei kommt nach `singeGetDataPath()`, aus demselben Grund wie der Spielstand aus Lektion
|
|
zwölf: Der eigene Ordner eines Spiels kann auf einer Disc liegen, auf einer Netzwerkfreigabe
|
|
oder in eine einzige Datei gepackt sein, und in nichts davon kann man schreiben. Alles, was ein
|
|
Spiel schreibt, kommt in seinen Datenordner.
|
|
|
|
[#_making_a_table]
|
|
=== Eine Tabelle anlegen
|
|
|
|
Eine leere Datenbank hat keine Tabellen. Du legst eine an, indem du sie beschreibst:
|
|
|
|
[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` führt SQL aus, das nichts zurückgibt. Die doppelten eckigen Klammern sind Luas langer
|
|
String: Alles zwischen ihnen, Zeilenumbrüche eingeschlossen, ist ein einziger String, und so
|
|
hältst du SQL lesbar.
|
|
|
|
`CREATE TABLE` nennt die Tabelle und ihre Spalten, jede mit einem Typ: `TEXT` für Wörter und
|
|
`INTEGER` für ganze Zahlen. `NOT NULL` heißt, dass jede Zeile dort einen Wert haben muss.
|
|
`INTEGER PRIMARY KEY` ist etwas Besonderes: SQLite füllt es selbst aus und zählt dabei hoch,
|
|
also bekommt jede Zeile ihre eigene Nummer, ohne dass du darüber nachdenken musst.
|
|
|
|
`IF NOT EXISTS` sorgt dafür, dass das bei jedem Spielstart gefahrlos laufen kann. Beim ersten
|
|
Mal legt es die Tabelle an. Bei jedem Mal danach ist die Tabelle schon da, und die Zeile tut
|
|
nichts. Ohne diese drei Wörter würde der zweite Start deines Spiels mit einem Fehler anhalten,
|
|
der sagt, dass es die Tabelle schon gibt.
|
|
|
|
[#_putting_a_row_in]
|
|
=== Eine Zeile hineinlegen
|
|
|
|
Du könntest das SQL für eine neue Zeile bauen, indem du Strings zusammenklebst:
|
|
|
|
[source,lua]
|
|
----
|
|
runs:exec("INSERT INTO runs (stage, score, at) VALUES ('" .. stage .. "', " .. score .. ", " .. os.time() .. ")")
|
|
----
|
|
|
|
Lass das. Es funktioniert, bis ein Name einen Apostroph enthält, und dann beendet dieses
|
|
Anführungszeichen den String zu früh, und die Anweisung ist Unsinn. In einem Spiel, in dem
|
|
Spieler etwas tippen dürfen, wird es schlimmer: Was immer sie tippen, wird Teil deines SQL.
|
|
|
|
Der richtige Weg ist, die Anweisung einmal zu schreiben, mit Fragezeichen dort, wo die Werte
|
|
hinkommen, und die Werte getrennt zu übergeben:
|
|
|
|
[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` macht aus dem SQL eine *Anweisung*, die du immer wieder benutzen kannst.
|
|
`bind_values` setzt der Reihe nach einen Wert in jedes Fragezeichen. Was immer ein Wert
|
|
enthält, Apostrophe eingeschlossen, er ist ein Wert und nie Teil des SQL. `step` führt die
|
|
Anweisung aus, und `reset` macht sie bereit, wieder zu laufen. `os.time()` ist gewöhnliches
|
|
Lua: die aktuelle Zeit als Anzahl von Sekunden, und das ist der einfachste Weg, „wann“ zu
|
|
speichern.
|
|
|
|
Einmal vorbereiten und die Anweisung wiederverwenden ist außerdem schneller, weil SQLite nur
|
|
einmal ausrechnet, wie es sie ausführt. Für ein Spiel, das eine Zeile pro Minute schreibt,
|
|
spielt das keine Rolle. Für einen Level-Editor, der tausend schreibt, schon.
|
|
|
|
[#_asking_questions]
|
|
=== Fragen stellen
|
|
|
|
Zeilen kommen mit `nrows` wieder heraus, das ein `SELECT` ausführt und dir jede Zeile als
|
|
Tabelle gibt, mit den Spaltennamen als Schlüsseln:
|
|
|
|
[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
|
|
----
|
|
|
|
Lies das SQL von links nach rechts. `SELECT stage, score` sagt, welche Spalten du willst.
|
|
`FROM runs` sagt, welche Tabelle. `ORDER BY score DESC` sortiert die Zeilen nach Punktzahl, die
|
|
höchste zuerst, und `LIMIT 5` hört nach fünf auf. Das ist eine Highscore-Tabelle, und sie hat
|
|
nie Sortiercode von dir gebraucht.
|
|
|
|
Die Fragen können klüger sein als die, die du von Hand schreiben wollen würdest:
|
|
|
|
[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` fasst die Zeilen zu einer Gruppe pro Stufe zusammen, und `MAX(score)` und
|
|
`COUNT(*)` werden für jede Gruppe ausgerechnet: die beste Punktzahl auf dieser Stufe und wie
|
|
oft sie gespielt wurde. `AS top` gibt der Antwort einen Namen, und das ist der Schlüssel, mit
|
|
dem du sie wieder ausliest.
|
|
|
|
Wenn eine Frage nur eine Antwort hat, gibt `urows` die Spalten selbst zurück statt einer
|
|
Tabelle, und das ist für eine Anzahl ordentlicher:
|
|
|
|
[source,lua]
|
|
----
|
|
for count in runs:urows("SELECT COUNT(*) FROM runs") do
|
|
played = count
|
|
end
|
|
----
|
|
|
|
[#_ask_when_it_changes]
|
|
=== Frag, wenn es sich ändert
|
|
|
|
Keine dieser Fragen gehört in `onOverlayUpdate`. Eine Abfrage ist schnell, aber sechzig davon
|
|
pro Sekunde, für Antworten, die sich einmal pro Minute ändern, heißt, dass das Spiel
|
|
neunundfünfzig von sechzig Malen dieselbe Frage stellt und dieselbe Antwort bekommt.
|
|
|
|
Also fragt das Spiel dann, wenn sich die Antwort geändert haben kann -- einmal beim Start und
|
|
dann jedes Mal, wenn ein Durchgang aufgezeichnet wird -- und behält, was zurückkam, in
|
|
gewöhnlichen Tabellen:
|
|
|
|
[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
|
|
-- und dasselbe für die anderen beiden Fragen
|
|
end
|
|
----
|
|
|
|
`onOverlayUpdate` zeichnet aus `best`, und das kostet nichts. `record` ruft nach jeder neuen
|
|
Zeile `refresh` auf, also ist die Tabelle auf dem Bildschirm nie veraltet.
|
|
|
|
[#_data_that_ships_with_the_game]
|
|
=== Daten, die mit dem Spiel ausgeliefert werden
|
|
|
|
Die andere Hälfte der Daten eines Spiels ist der Teil, den du schreibst, bevor irgendwer
|
|
spielt: die Stufen, die Level, die Gegenstände, die Dialogzeilen. Das kann auch eine Datenbank
|
|
sein, gemacht mit irgendeinem SQLite-Werkzeug -- dem Programm `sqlite3` auf der Befehlszeile
|
|
oder einem Programm mit Fenstern wie DB Browser for SQLite -- und in den Ordner deines Spiels
|
|
neben seine Grafiken gelegt. Das Grafik-Kit hat eine, `stages.db`, mit einem Namen und der
|
|
kürzesten und längsten Wartezeit für jede Stufe.
|
|
|
|
Ein Spiel öffnet diese anders:
|
|
|
|
[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` ist der Ordner deines Spiels, in dem die Grafiken liegen. `sqlite3.OPEN_READONLY` sagt,
|
|
dass du nur lesen willst, und das zählt an dem Tag, an dem du auslieferst. Wenn du dein Spiel in
|
|
der nächsten Lektion in eine einzige Datei packst, wird eine schreibgeschützt geöffnete
|
|
Datenbank direkt aus dieser Datei gelesen, ohne dass irgendetwas entpackt wird. Eine, die zum
|
|
Schreiben geöffnet wird, wird einmal in den Datenordner des Spiels kopiert, und von da an
|
|
änderst du die Kopie; die Datei, die du ausgeliefert hast, wird nie beschrieben.
|
|
|
|
Halte die beiden Arten also auseinander. Was du gemacht hast, kommt in deinen Ordner und wird
|
|
schreibgeschützt geöffnet. Was das Spiel macht, kommt in den Datenordner. Die Stufen werden hier
|
|
einmal beim Start in eine gewöhnliche Tabelle gelesen, und die Datenbank wird geschlossen, denn
|
|
für eine Liste mit vier Stufen lohnt es sich nicht, eine Datei offen zu halten.
|
|
|
|
[#_closing_up]
|
|
=== Zum Schluss schließen
|
|
|
|
Anweisungen und Datenbanken belegen Speicher und halten eine Datei offen, bis du sagst, dass du
|
|
fertig bist, und der ordentliche Ort, das zu sagen, ist `onShutdown`:
|
|
|
|
[source,lua]
|
|
----
|
|
function onShutdown()
|
|
addRun:finalize()
|
|
runs:close()
|
|
end
|
|
----
|
|
|
|
`finalize` ist `close` für eine Anweisung. Eine Datenbank lässt sich nicht schließen, solange
|
|
eine Anweisung, die aus ihr gemacht wurde, noch da ist, also kommen die Anweisungen zuerst dran.
|
|
|
|
[#_the_whole_script_4]
|
|
=== Das ganze Skript
|
|
|
|
Leg einen Ordner namens `runs` an, kopier den Ordner `art` des Buchs hinein, wegen `stages.db`,
|
|
und schreib das hier in `runs.singe`:
|
|
|
|
[source,lua]
|
|
----
|
|
dofile("Singe/Framework.singe")
|
|
|
|
local sqlite3 = require("sqlite3")
|
|
|
|
local PERFECT = 1000
|
|
|
|
-- Was mit dem Spiel ausgeliefert wird: die Stufen, einmal gelesen und geschlossen.
|
|
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()
|
|
|
|
-- Was das Spiel schreibt: jeder Durchgang, den irgendwer spielt, aufbewahrt im Datenordner.
|
|
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
|
|
|
|
|
|
-- Die Fragen, gestellt, wenn ein Durchgang dazukommt, statt in jedem Frame.
|
|
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()
|
|
----
|
|
|
|
Starte es mit `Singe -R runs`. Knopf 1 startet eine Runde und hält sie an, wie in Lektion achtundzwanzig, und
|
|
Knopf 2 wechselt zwischen den Runden zur nächsten Stufe. Spiel auf jeder ein paar. Dann beende
|
|
es und starte es noch einmal: Jeder Durchgang ist noch da, die besten fünf links und der
|
|
Bestwert jeder Stufe rechts.
|
|
|
|
[#_what_just_happened_24]
|
|
=== Was gerade passiert ist
|
|
|
|
[source,lua]
|
|
----
|
|
local runs = sqlite3.open(singeGetDataPath() .. "runs.db")
|
|
----
|
|
|
|
Hier sind zwei Dateien im Spiel, und sie sind nicht gleich. `art/stages.db` gehört dir: Du
|
|
hast sie geschrieben, sie wird mit dem Spiel ausgeliefert, und das Spiel liest sie nur.
|
|
`runs.db` gehört dem Spiel: Es gab sie nicht, bis der erste Start sie angelegt hat, sie wohnt im
|
|
Datenordner, und niemand liefert sie aus.
|
|
|
|
[source,lua]
|
|
----
|
|
if state == "waiting" then
|
|
state = "result"
|
|
score = 0
|
|
record(stages[stageAt].name, score)
|
|
----
|
|
|
|
Ein zu früh gedrückter Knopf zeichnet trotzdem einen Durchgang auf, mit einer Punktzahl von
|
|
nichts. Die Datenbank ist eine Aufzeichnung dessen, was passiert ist, und ein Frühstart ist
|
|
passiert. Ob man diese Zeilen zeigt, ist eine Frage für das `SELECT`, nicht etwas, das man
|
|
versteckt, indem man sie nie schreibt.
|
|
|
|
[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` sortiert nach Punktzahl und, wo zwei Punktzahlen gleich sind, nach
|
|
`id` -- der Reihenfolge, in der sie gespielt wurden. Ohne den zweiten Schlüssel kommen zwei
|
|
gleiche Punktzahlen in der Reihenfolge zurück, die SQLite gerade bequem ist, und die kann sich
|
|
von einem Start zum nächsten ändern. Eine Tabelle, die sich ohne sichtbaren Grund neu mischt,
|
|
sieht kaputt aus, auch wenn sie es nicht ist.
|
|
|
|
[#_try_it_29]
|
|
=== Probier es aus
|
|
|
|
. *Zeig das Datum.* `os.date("%Y-%m-%d", row.at)` macht aus einer gespeicherten Zeit wieder
|
|
etwas, das ein Mensch lesen kann. Füg jedem der besten Durchgänge das Datum hinzu.
|
|
. *Ignorier die Frühstarts.* Füg `WHERE score > 0` in die Frage nach den besten Durchgängen
|
|
ein, zwischen `FROM runs` und `ORDER BY`. Dann zähl die Frühstarts auf jeder Stufe mit einer
|
|
eigenen Frage.
|
|
. *Füg eine Stufe hinzu.* Öffne `stages.db` in einem SQLite-Werkzeug, füg eine Zeile hinzu und
|
|
starte das Spiel. Am Skript ändert sich nichts. Genau darum hält man Daten aus dem Skript
|
|
heraus.
|
|
. *Lösch den Verlauf.* `runs:exec("DELETE FROM runs")` leert die Tabelle. Leg es auf einen
|
|
Knopf, ruf danach `refresh` auf und sieh zu, wie alles auf nichts geht. Das ist das
|
|
„Highscores zurücksetzen“ in einem Service-Menü.
|
|
. *Schreib tausend Zeilen.* Ruf `record` in einer Schleife tausendmal auf und miss die Zeit mit
|
|
`singeGetTicks`. Dann setz `runs:exec("BEGIN")` vor die Schleife und `runs:exec("COMMIT")`
|
|
dahinter und miss noch einmal. Eine *Transaktion* macht aus vielen Schreibvorgängen einen, und
|
|
der Unterschied ist groß genug, dass es sich lohnt, ihn zu behalten.
|
|
|
|
[#_break_it_on_purpose_28]
|
|
=== Mach es absichtlich kaputt
|
|
|
|
Nimm `addRun:reset()` aus `record` heraus und spiel drei Runden auf drei verschiedenen
|
|
Stufen.
|
|
|
|
Kein Fehler. Das Spiel läuft einwandfrei. Aber die Tabelle rechts sagt, dass du die erste Stufe
|
|
dreimal gespielt hast, und die besten Durchgänge sind drei Kopien deiner ersten Punktzahl.
|
|
`bind_values` konnte keine neuen Werte in eine Anweisung setzen, die schon gelaufen und nicht
|
|
zurückgesetzt worden war, also hat es aufgegeben -- leise, mit einer Zahl als Rückgabe, die du
|
|
nicht geprüft hast -- und `step` hat die Anweisung noch einmal mit den Werten ausgeführt, die sie
|
|
schon hatte.
|
|
|
|
Das ist der Fehler, auf den es in dieser Lektion ankommt, weil er nicht wie einer aussieht. Eine
|
|
Abfrage, die scheitert, ist laut. Ein Schreibvorgang, der das Falsche schreibt, ist still, und
|
|
du findest ihn eine Woche später in den Daten. Wenn die Zeilen in einer Datenbank nicht das
|
|
sind, was du erwartest, sieh dir den Code an, der sie hineingelegt hat, bevor du dir den Code
|
|
ansiehst, der sie liest.
|
|
|
|
Setz `reset` wieder ein und mach es auf die laute Art kaputt: Ändere `score` im ersten `SELECT`
|
|
in `refresh` in `scroe`.
|
|
|
|
----
|
|
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 liest die Spaltennamen, wenn es die Frage vorbereitet, also hält ein falsch geschriebener
|
|
das Spiel sofort an, und die Meldung nennt ihn genau. Den Traceback liest man von innen nach
|
|
außen: `nrows` hat sich beschwert, `refresh` hat es in Zeile 42 aufgerufen, und das Skript
|
|
selbst hat `refresh` in Zeile 132 aufgerufen, der letzten Zeile, als das Spiel startete. Darum
|
|
steht da *running script*, statt dass eine Funktion genannt wird, die die Engine aufgerufen
|
|
hat: Das Spiel ist nie bis zum ersten Frame gekommen.
|
|
|
|
[#_what_you_learned_29]
|
|
=== Was du gelernt hast
|
|
|
|
* Eine Datenbank ist eine Datei voller Tabellen; eine Tabelle besteht aus Zeilen mit benannten
|
|
Spalten.
|
|
* `require("sqlite3")` gibt dir SQLite. `sqlite3.open` öffnet eine Datei und legt sie an, wenn
|
|
sie fehlt.
|
|
* Was das Spiel schreibt, kommt nach `singeGetDataPath()`. Was du auslieferst, kommt in deinen
|
|
Ordner und wird mit `sqlite3.OPEN_READONLY` geöffnet.
|
|
* `CREATE TABLE IF NOT EXISTS` kann gefahrlos bei jedem Spielstart laufen.
|
|
* Werte kommen über `prepare`, `bind_values`, `step` und `reset` hinein, nie, indem man
|
|
Strings ins SQL klebt.
|
|
* `nrows` gibt Zeilen als Tabellen zurück; `urows` gibt die Spalten selbst zurück.
|
|
* `ORDER BY`, `LIMIT`, `GROUP BY`, `MAX` und `COUNT` beantworten die Fragen, für die du sonst
|
|
Schleifen schreiben würdest.
|
|
* Frag, wenn sich die Antwort ändern kann, nicht in jedem Frame.
|
|
* In `onShutdown` werden erst die Anweisungen finalisiert, dann wird die Datenbank geschlossen.
|
|
|
|
[#_next_time_29]
|
|
=== Beim nächsten Mal
|
|
|
|
Dein Spiel hat jetzt einen Ordner voller Dinge: ein Skript, Grafiken, Sounds und eine oder zwei
|
|
Datenbanken. Lektion dreißig macht aus diesem Ordner eine einzige Datei, die ein Fremder auf
|
|
seinen eigenen Rechner kopieren und spielen kann -- und deine schreibgeschützte Datenbank geht
|
|
mit und öffnet sich noch genau so wie jetzt.
|