singe/docs/lessons/15-threaded.adoc
2026-09-22 21:57:42 -05:00

405 lines
13 KiB
Text

== Lesson 15: The Other Way to Write It
image::learn/15-threaded.png[The finished lesson, 480]
Every lesson so far has been written the same way. You write functions whose
names begin with `on`, Singe calls them when it feels like it, and you never
say what order anything happens in. That is the *event driven* model, and it
is how most game engines work.
Singe has a second model. In it you write your game as a list of steps, from
the first line to the last, and you are allowed to wait. In this lesson you
will write the same short video sequence both ways and see the difference
with your own eyes.
=== The Sequence
Here is what the game does, in English:
. Start the film at the beginning and play it up to frame 120.
. Stop there and ask the player to press the space bar.
. When they press it, play on to frame 300.
. Stop there, say "The end.", wait four seconds, and quit.
Four steps. Read them again and notice that every one of them contains the
word "then", spoken or not.
=== The Way You Know
Make a file called `events.singe` in the `movie` folder from lesson fourteen,
and type this:
[source,lua]
----
dofile("Singe/Framework.singe")
local step = 1
local endedAt = 0
function onInputPressed(what)
if what == SWITCH_BUTTON1 and step == 2 then
step = 3
discPlay()
end
end
function onOverlayUpdate()
local frame = discGetFrame()
overlayClear()
overlayPrint(2, 2, "Frame " .. frame)
if step == 1 then
if frame >= 120 then
discPause()
step = 2
end
elseif step == 2 then
overlayPrint(2, 4, "Press the space bar to go on.")
elseif step == 3 then
if frame >= 300 then
discPause()
endedAt = singeGetTicks()
step = 4
end
elseif step == 4 then
overlayPrint(2, 4, "The end.")
if singeGetTicks() - endedAt > 4000 then
singeQuit()
end
end
return OVERLAY_UPDATED
end
discSearch(0)
discPlay()
----
Run it:
----
Singe -R -v Singe/menuBackground.mkv events
----
It works. It does exactly the four steps. `singeGetTicks` is new -- it hands
back the number of milliseconds since the engine started, which is the
ordinary way to measure a stretch of time -- and `singeQuit` ends the game,
which is what the engine does for you when you press Escape.
Now read the script and try to find the four steps in it. They are there, but
they are inside out. The order of the story is written in the *values* of
`step`, not in the order of the lines, and `step` is set in one function and
read in another. To answer "what happens after the player presses space?" you
have to hold the whole thing in your head at once.
And this is the easy version. There are two decisions in it.
=== The Other Way
Start a new file, `steps.singe`, and type this:
[source,lua]
----
local message = ""
local pressed = false
local function drawFrame()
overlayClear()
overlayPrint(2, 2, "Frame " .. discGetFrame())
overlayPrint(2, 4, message)
end
local function playTo(frame)
while discGetFrame() < frame do
drawFrame()
singeYield()
end
end
local function waitForButton()
pressed = false
while not pressed do
drawFrame()
singeYield()
end
end
local function waitSeconds(seconds)
local stopTime = singeGetTicks() + seconds * 1000
while singeGetTicks() < stopTime do
drawFrame()
singeYield()
end
end
function onInputPressed(what)
if what == SWITCH_BUTTON1 then
pressed = true
end
end
function singeMain()
discSearch(0)
discPlay()
playTo(120)
discPause()
message = "Press the space bar to go on."
waitForButton()
message = ""
discPlay()
playTo(300)
discPause()
message = "The end."
waitSeconds(4)
end
dofile("Singe/Framework.singe")
----
Run it the same way. It does the same four things.
Now read `singeMain`. The four steps are the four paragraphs, in order, top
to bottom, and there is no `step` variable anywhere because the position in
the story *is* the position in the function. That is the whole point of this
lesson.
=== What Just Happened
[source,lua]
----
function singeMain()
----
`singeMain` is a name Singe looks for, like `onOverlayUpdate`. If your script
defines it, the framework runs your game from it: it is called once, at the
start, and your game lasts as long as it lasts. When it returns, the game
quits.
[source,lua]
----
singeYield()
----
This is the call that makes it possible, and it means "let a frame happen".
Singe is not doing two things at once -- nothing here is running in parallel,
whatever the word *threaded* suggests. What happens is that `singeYield`
stops your function where it stands, hands control back to the engine, and
remembers the exact spot. The engine draws the frame, reads the input, moves
the video on, and then starts your function again from the line after the
`singeYield`, with every variable exactly as you left it.
So a `while` loop with a `singeYield` in it is a way of waiting. It is the
only way of waiting, and you must put one in every loop that is waiting for
something. This is a deal you are making with the engine: you may sit and
wait, as long as you give the frame back sixty times a second.
[source,lua]
----
local function playTo(frame)
while discGetFrame() < frame do
drawFrame()
singeYield()
end
end
----
Singe has no "wait until the video reaches frame 120" function. It does not
need one, because you can write it in four lines, and you just did. Keep
going while the disc is short of the frame you want; give a frame back each
time round.
`waitForButton` is the same shape with a different question, and
`waitSeconds` is the same shape again with a clock. Three small functions,
written once, and `singeMain` reads like the English sentences at the top of
the lesson because of them. This is exactly what lesson five said your own
functions were for.
[source,lua]
----
drawFrame()
singeYield()
----
Every wait loop draws before it yields, and this is the part that surprises
people. In the threaded model there is no `onOverlayUpdate` of your own, so
nothing draws unless you draw it. Whatever you draw before a `singeYield` is
what appears on that frame. Stop drawing and the overlay stops changing.
That is why `message` exists. `singeMain` sets it, and `drawFrame` prints it,
and the three wait loops all call `drawFrame`. It is a small price for
getting the story back in order.
[source,lua]
----
dofile("Singe/Framework.singe")
----
At the *bottom*. This matters more than anything else in the lesson, so it is
worth knowing why.
`dofile` runs another script inside yours, and one of the things the
framework does as it runs is look for a function called `singeMain`. If it
finds one it wraps your game around it and writes an `onOverlayUpdate` of its
own, whose entire job is to start your `singeMain` again once per frame. If
it does not find one, it does nothing of the sort, and your game is an
ordinary event driven game.
So the framework has to be loaded *after* `singeMain` exists. Load it at the
top, the way you have every lesson since the third, and at the moment it
looks there is no `singeMain` yet, and you get a game with no game in it.
=== The Rules
There are four, and between them they cover every mistake you can make here.
*The framework goes last.* For the reason above.
*Do not write your own `onOverlayUpdate`.* The framework has written one, and
there can only be one: whichever of the two is written last wins, and the
other is thrown away without a word. Above the `dofile`, yours goes and your
drawing never appears. Below it, the framework's goes and your `singeMain`
never runs again. Draw from inside `singeMain` instead.
*Every waiting loop needs a `singeYield`.* A loop without one never gives the
frame back. The window stops redrawing, stops responding, and the game is
gone; you close the terminal to get out of it. There is no error message,
because from the engine's point of view your function has not finished yet.
*Returning from `singeMain` quits the game.* Which is tidy when you mean it
and a surprise when you do not. A game that should keep going forever ends
with a loop that never exits:
[source,lua]
----
while true do
drawFrame()
singeYield()
end
----
=== Input Still Arrives
Look again at where `pressed` is set:
[source,lua]
----
function onInputPressed(what)
if what == SWITCH_BUTTON1 then
pressed = true
end
end
----
That is a callback, in a script that is written as a list of steps. Both
models are running at once, and they are meant to be.
When `singeMain` exists, only `onOverlayUpdate` is taken away from you.
Everything else -- `onInputPressed`, `onInputReleased`, `onKeyPressed`,
`onSoundCompleted`, `onShutdown`, all of them -- fires exactly as it always
did, in the gaps between your yields. So the usual way to write a game in
this model is the way you just did: the story in `singeMain`, and a small
callback that catches an event and leaves a note for the story to find.
`waitForButton` sets `pressed` to `false` before it starts waiting, which
throws away anything the player mashed earlier. Leave that line out and a
button pressed during the video counts as the answer to a question that has
not been asked yet.
=== Which One to Use
Neither model is the better one. They suit different jobs, and a real game
uses whichever fits.
The threaded model suits anything that is a *sequence*. Play this, then ask,
then play that. An opening. A cut scene. A tutorial. A boss fight with three
phases. Anything you would naturally describe as a numbered list is a
`singeMain` waiting to be written, and video is the most sequential thing
there is, which is why part three arrives at it here.
The event driven model suits anything that is a *situation*. Ten enemies and
a player, all moving, all colliding, none of them in any order. The game you
wrote in lesson seven has no sequence in it at all: every frame is the same
question asked again, which is exactly the shape of `onOverlayUpdate`. Write
that as a list of steps and you gain nothing and lose the clarity.
When a game is both -- and most are -- it is written as both. `singeMain`
walks through the levels in order, and inside each level a loop does the
lesson seven work sixty times a second until the level ends.
=== Try It
. *Add a step.* After `waitForButton`, make the film jump backwards instead:
`discSkipToFrame(0)`, then `playTo(60)`, then carry on as before. One new
line in one place, and nothing else in the script has to know.
. *Add the same step to the other one.* Now do it to `events.singe`. Count how
many places you had to touch.
. *Write `waitFrames`.* A fourth helper, next to the other three, that waits
for a given number of frames of video rather than a given number of
seconds. Use `discGetFrame` and remember where you started.
. *Take a yield out.* Delete the `singeYield()` inside `playTo` and run it.
Be ready to close the window. Now put it back and delete `drawFrame()`
instead, and work out from what you see why the frame counter stops but the
film does not.
. *Make it a loop.* Put the whole of `singeMain` inside `while true do ...
end` so the sequence starts over instead of quitting. Escape still gets you
out, because that has never been your job.
=== Break It on Purpose
Move the `dofile("Singe/Framework.singe")` line from the bottom of
`steps.singe` to the top, and run it.
There is no error. Nothing is printed. The window opens, the first frame of
the film sits there, and that is all that ever happens.
This is the worst kind of bug and the reason it is worth meeting on purpose.
Nothing has gone wrong, in the engine's view: your script loaded, the
framework loaded, and neither of them defined an `onOverlayUpdate`, so the
engine has nothing to call. The disc is parked on frame one, paused, exactly
as the engine left it, because the `discPlay` that would have started it is
inside a `singeMain` that nobody ever runs.
When a threaded game does nothing at all, look at the last line of the file
first. It is almost always that.
=== What You Learned
* Singe has two ways to write a script, and you choose by defining
`singeMain` or not.
* In the threaded model your game is a list of steps and runs from top to
bottom.
* `singeYield()` gives one frame back to the engine and carries on from the
next line.
* Waiting is a `while` loop with a `singeYield` in it. You write the waiting
function you need; the engine does not provide one.
* Nothing draws unless you draw it, before a yield, on every frame.
* `dofile("Singe/Framework.singe")` goes at the *end* of a threaded script.
* Do not write your own `onOverlayUpdate` when `singeMain` exists.
* Every other callback still fires, so events and steps mix freely.
* Returning from `singeMain` ends the game.
* Sequences want the threaded model; situations want the event driven one.
=== Next Time
You can now write "play this, then ask, then play that" as three lines. A
branching video game is nothing but that sentence, a few hundred times, with
the answers deciding which line comes next. Lesson sixteen builds one, and
the interesting part turns out not to be the video at all. It is keeping the
map of what leads where in a form you can still read when the game has forty
scenes in it.