singe/docs/lessons/29-databases.adoc

488 lines
17 KiB
Text

== Lesson 29: Keeping Data
image::learn/29-databases.png[The finished lesson, 480]
Lesson twelve saved a high score: one number, under one name, there the next
morning. That is exactly right for a high score, and wrong for almost
everything else a game wants to keep. A list of every round anybody has played.
The best time on each of twenty tracks. Forty levels somebody designed in a
spreadsheet. The question "what is my best on the hard stage?", which a pile of
saved numbers cannot answer without you writing the answer by hand.
That is what a database is for, and Singe has one built in. This lesson takes
the reaction test from lesson twenty-eight, gives it stages that ship with the
game, and has it keep every run anybody plays and answer questions about them.
=== What a Database Is
A database is a file of *tables*. A table is rows and columns, like a
spreadsheet with the column names decided in advance: a table of runs might
have a stage, a score, and a time for every row. You put rows in and you ask
questions to get rows out, and the questions are written in *SQL*, a small
language that has been doing this job since before most programming languages
you have heard of.
The database Singe carries is *SQLite*. It is not a server you install or a
service you sign up for. It is a library, the database is one ordinary file,
and the same SQLite is in your phone, your browser, and probably your car. You
do not have to know SQL to follow this lesson; every statement in it is
explained as it goes past, and the three you will use most -- `CREATE TABLE`,
`INSERT`, and `SELECT` -- are all here.
=== Opening One
[source,lua]
----
local sqlite3 = require("sqlite3")
local runs = sqlite3.open(singeGetDataPath() .. "runs.db")
----
`require("sqlite3")` loads a library that comes with Singe and hands it back as
a table, so everything SQLite can do is `sqlite3.` something. `sqlite3.open`
opens a database file and
creates it if it is not there yet, so the first run of your game makes an
empty one and every run after that finds it again.
The file goes in `singeGetDataPath()`, and for the same reason lesson twelve's
save does: a game's own folder may be on a disc, on a share, or packed into one
file, and none of those can be written to. Everything a game writes goes in its
data directory.
=== Making a Table
An empty database has no tables. You make one by describing it:
[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` runs SQL that does not hand anything back. The double square brackets
are Lua's long string: everything between them, newlines included, is one
string, which is how you keep SQL readable.
`CREATE TABLE` names the table and its columns, each with a type: `TEXT` for
words and `INTEGER` for whole numbers. `NOT NULL` means every row must have a
value there. `INTEGER PRIMARY KEY` is special: SQLite fills it in by itself,
counting up, so every row gets its own number without you thinking about it.
`IF NOT EXISTS` is what makes this safe to run every time the game starts. The
first time, it makes the table. Every time after that, the table is already
there and the line does nothing. Without those three words the second run of
your game would stop with an error saying the table already exists.
=== Putting a Row In
You could build the SQL for a new row by gluing strings together:
[source,lua]
----
runs:exec("INSERT INTO runs (stage, score, at) VALUES ('" .. stage .. "', " .. score .. ", " .. os.time() .. ")")
----
Do not. It works until a name has an apostrophe in it, at which point the quote
ends the string early and the statement is nonsense. In a game that lets
players type, it gets worse: whatever they type becomes part of your SQL.
The right way is to write the statement once, with question marks where the
values go, and hand the values over separately:
[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` turns the SQL into a *statement* that can be used again and again.
`bind_values` puts a value in each question mark, in order. Whatever a value
contains, apostrophes included, it is a value and never part of the SQL. `step`
runs the statement, and `reset` makes it ready to run again. `os.time()` is
plain Lua: the current time as a number of seconds, which is the easiest way to
store "when".
Preparing once and reusing the statement is also faster, because SQLite works
out how to run it only once. For a game that writes one row a minute that does
not matter. For a level editor that writes a thousand, it does.
=== Asking Questions
Rows come back out with `nrows`, which runs a `SELECT` and hands you each row as
a table with the column names as keys:
[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
----
Read the SQL left to right. `SELECT stage, score` says which columns you want.
`FROM runs` says which table. `ORDER BY score DESC` sorts the rows by score,
highest first, and `LIMIT 5` stops after five. That is a high score table, and
it never needed sorting code of yours.
The questions can be cleverer than the ones you would want to write by hand:
[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` gathers the rows into one group per stage, and `MAX(score)` and
`COUNT(*)` are worked out for each group: the best score on that stage and how
many times it has been played. `AS top` names the answer, which is the key you
read it back with.
When a question has one answer, `urows` hands back the columns themselves
rather than a table, which is tidier for a count:
[source,lua]
----
for count in runs:urows("SELECT COUNT(*) FROM runs") do
played = count
end
----
=== Ask When It Changes
None of those questions belong in `onOverlayUpdate`. A query is fast, but sixty
of them a second, to get answers that change once a minute, is the game asking
the same question and getting the same answer fifty-nine times out of sixty.
So the game asks when the answer can have changed -- once at the start, and
again each time a run is recorded -- and keeps what came back in ordinary
tables:
[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
-- and the same for the other two questions
end
----
`onOverlayUpdate` draws from `best`, which costs nothing. `record` calls
`refresh` after every new row, so the table on screen is never out of date.
=== Data That Ships With the Game
The other half of a game's data is the part you write before anybody plays:
the stages, the levels, the items, the lines of dialogue. That can be a
database too, made with any SQLite tool -- the `sqlite3` program on the
command line, or a program with windows such as DB Browser for SQLite -- and put
in your game's folder beside its art. The art kit has one, `stages.db`, with a
name and the shortest and longest wait for each stage.
A game opens that one differently:
[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` is your game's folder, where the art is. `sqlite3.OPEN_READONLY` says you
only mean to read it, and that matters for the day you ship. When you pack your
game into one file in the next lesson, a database opened read only is read
straight out of that file, with nothing unpacked. One opened for writing is
copied, once, to the game's data directory, and the copy is what you change from
then on; the file you shipped is never written.
So keep the two kinds apart. What you made goes in your folder and is opened
read only. What the game makes goes in the data directory. The stages here are
read once at the start into an ordinary table and the database is closed,
because a list of four stages is not worth keeping a file open for.
=== Closing Up
Statements and databases hold memory and a file open until you say you are
finished, and the tidy place to say it is `onShutdown`:
[source,lua]
----
function onShutdown()
addRun:finalize()
runs:close()
end
----
`finalize` is `close` for a statement. A database will not close while a
statement made from it is still around, so the statements go first.
=== The Whole Script
[source,lua]
----
dofile("Singe/Framework.singe")
local sqlite3 = require("sqlite3")
local PERFECT = 1000
-- What ships with the game: the stages, read once and closed.
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()
-- What the game writes: every run anybody plays, kept in the data directory.
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
-- The questions, asked when a run is added rather than every 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()
----
Run it. Button 1 starts a round and stops it, as in lesson twenty-eight, and
button 2 between rounds moves to the next stage. Play a few on each. Then quit
and run it again: every run is still there, the best five at the left and each
stage's best at the right.
=== What Just Happened
[source,lua]
----
local runs = sqlite3.open(singeGetDataPath() .. "runs.db")
----
Two files are in play, and they are not alike. `art/stages.db` is yours: you
wrote it, it ships with the game, and the game only reads it. `runs.db` is the
game's: it did not exist until the first run made it, it lives in the data
directory, and nobody ships it.
[source,lua]
----
if state == "waiting" then
state = "result"
score = 0
record(stages[stageAt].name, score)
----
A button pressed too soon still records a run, with a score of nothing. The
database is a record of what happened, and a jumped start happened. Whether to
show those rows is a question for the `SELECT`, not something to hide by never
writing them.
[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` sorts by score, and where two scores are equal, by
`id` -- the order they were played. Without the second key, two equal scores
come back in whatever order SQLite finds convenient, which can change between
runs. A table that reshuffles itself for no visible reason looks broken even
when it is not.
=== Try It
. *Show the date.* `os.date("%Y-%m-%d", row.at)` turns a stored time back into
something a person can read. Add the date to each of the best runs.
. *Ignore the jumped starts.* Add `WHERE score > 0` to the best-runs question,
between `FROM runs` and `ORDER BY`. Then count the jumped starts on each stage
with a question of your own.
. *Add a stage.* Open `stages.db` in a SQLite tool, add a row, and run the game.
Nothing in the script changes. That is the point of keeping data out of the
script.
. *Reset the history.* `runs:exec("DELETE FROM runs")` empties the table. Put it
on a button, call `refresh` after it, and watch everything go to nothing.
This is the "clear high scores" in a service menu.
. *Write a thousand rows.* Loop `record` a thousand times and time it with
`singeGetTicks`. Then put `runs:exec("BEGIN")` before the loop and
`runs:exec("COMMIT")` after it, and time it again. A *transaction* makes many
writes one, and the difference is large enough to be worth remembering.
=== Break It on Purpose
Take `addRun:reset()` out of `record` and play three rounds on three different
stages.
No error. The game plays perfectly. But the table at the right says you played
the first stage three times, and the best runs are three copies of your first
score. `bind_values` could not put new values into a statement that had already
run and not been reset, so it gave up -- quietly, returning a number you were
not checking -- and `step` ran the statement again with the values it already
had.
This is the bug that matters in this lesson, because it does not look like one.
A query that fails is loud. A write that writes the wrong thing is silent, and
you find it a week later in the data. When the rows in a database are not what
you expect, look at the code that put them there before the code that reads
them.
Put `reset` back, and break it the loud way: change `score` to `scroe` in the
first `SELECT` in `refresh`.
----
Error running script: no such column: scroe
stack traceback:
[C]: in method 'nrows'
29-databases.singe:43: in local 'refresh'
29-databases.singe:133: in main chunk
----
SQLite reads the column names when it prepares the question, so a misspelled
one stops the game at once, and the message names it exactly. The traceback
reads from the inside out: `nrows` complained, `refresh` called it at line 43,
and the script itself called `refresh` at line 133, the last line, as the game
started. That is why it says *running script* rather than naming a function
the engine called: it never got as far as the first frame.
=== What You Learned
* A database is a file of tables; a table is rows with named columns.
* `require("sqlite3")` gives you SQLite. `sqlite3.open` opens a file and creates
it if it is missing.
* What the game writes goes in `singeGetDataPath()`. What you ship goes in your
folder, opened with `sqlite3.OPEN_READONLY`.
* `CREATE TABLE IF NOT EXISTS` is safe to run every time the game starts.
* Values go in through `prepare`, `bind_values`, `step`, and `reset`, never by
gluing strings into SQL.
* `nrows` hands back rows as tables; `urows` hands back the columns themselves.
* `ORDER BY`, `LIMIT`, `GROUP BY`, `MAX`, and `COUNT` answer the questions you
would otherwise write loops for.
* Ask when the answer can change, not every frame.
* Statements are finalized, then the database is closed, in `onShutdown`.
=== Next Time
Your game now has a folder full of things: a script, art, sounds, and a
database or two. Lesson thirty turns that folder into one file a stranger can
copy onto their own machine and play -- and your read-only database goes with
it, still opening exactly as it does now.