520 lines
18 KiB
Text
520 lines
18 KiB
Text
== Lesson 16: Branching
|
|
|
|
image::learn/16-branching.png[The finished lesson, 480]
|
|
|
|
A branching video game is a film with forks in it. A scene plays, the player
|
|
chooses, and a different scene plays next. Do that twenty or thirty times and
|
|
you have a game people will replay for years trying to find the ending they
|
|
have not seen.
|
|
|
|
In this lesson you will build one. It has four scenes, two choices, a timer
|
|
on each choice, a death, and three lives. The code that plays it is about
|
|
thirty lines, and it would still be about thirty lines if the game had two
|
|
hundred scenes -- because the game itself will not be written in code at all.
|
|
|
|
=== Imagine It Is a Dragon
|
|
|
|
The video everybody has is `Singe/menuBackground.mkv`, the same 420 frames
|
|
you have been driving since lesson fourteen. It is a ball of fire, a dragon
|
|
made of paper, the word `SINGE`, and then a magenta grid rolling away under
|
|
an orange sun forever.
|
|
|
|
It is not a drama. There is nobody in it to be in peril, and the sunset does
|
|
not care what you decide.
|
|
|
|
Play along anyway. The dragon is real enough for two minutes, and everything
|
|
you are about to write works exactly the same when the footage is a stunt
|
|
performer falling off a cliff. Here is the map, and the frame numbers are the
|
|
ones you found for yourself in lesson fourteen:
|
|
|
|
[cols="1,1,3",options="header"]
|
|
|===
|
|
| Scene | Frames | What happens
|
|
| `arrive` | 0 to 119 | Fire, and the dragon lands. *Left or Right?*
|
|
| `sunset` | 120 to 259 | It breathes fire. *Duck left, or run right?*
|
|
| `pit` | 260 to 339 | You are eaten. You lose a life.
|
|
| `escape` | 340 to 410 | The grid rolls away and you got out.
|
|
|===
|
|
|
|
Left is always right, so to speak: left from `arrive` goes to `sunset`, left
|
|
from `sunset` gets you to `escape`. Right goes to the `pit` both times, and
|
|
so does taking too long.
|
|
|
|
=== The Map Is Data, Not Code
|
|
|
|
Here is the whole game, and it is a table.
|
|
|
|
[source,lua]
|
|
----
|
|
local scenes = {
|
|
arrive = {
|
|
first = 0,
|
|
last = 119,
|
|
ask = 30,
|
|
prompt = "The dragon lands. Left or Right?",
|
|
left = "sunset",
|
|
right = "pit",
|
|
goesTo = "pit"
|
|
},
|
|
sunset = {
|
|
first = 120,
|
|
last = 259,
|
|
ask = 170,
|
|
prompt = "It breathes fire. Duck Left or run Right?",
|
|
left = "escape",
|
|
right = "pit",
|
|
goesTo = "pit"
|
|
},
|
|
pit = {
|
|
first = 260,
|
|
last = 339,
|
|
death = true,
|
|
goesTo = "arrive"
|
|
},
|
|
escape = {
|
|
first = 340,
|
|
last = 410
|
|
}
|
|
}
|
|
----
|
|
|
|
A table with names down the left instead of numbers, exactly as in lesson
|
|
six, and every value in it is another table. Each of those inner tables is a
|
|
*record*: one scene, described field by field.
|
|
|
|
`scenes.arrive` is the first scene. `scenes.arrive.first` is `0`.
|
|
`scenes.arrive.left` is the string `"sunset"`, which is the name of another
|
|
scene in the same table -- which is how the forks are written down. A scene
|
|
does not contain the scene after it. It contains the *name* of the scene
|
|
after it, and the name is looked up when the time comes.
|
|
|
|
The fields mean this:
|
|
|
|
`first` and `last`:: The frames the scene runs between.
|
|
`ask`:: The frame the question appears on. The player has from `ask` to
|
|
`last` to answer, which is the timer.
|
|
`prompt`:: What the question says.
|
|
`left` and `right`:: The scene each answer leads to.
|
|
`goesTo`:: Where the scene goes when nobody chose -- because the player ran
|
|
out of time, or because there was no question in the first place. For `pit`,
|
|
which is a death, it is where the retry goes back to.
|
|
`death`:: Present, and `true`, only on a scene that costs a life.
|
|
|
|
Three things are worth noticing about that table.
|
|
|
|
The first is that it is the design document. You can hand it to somebody who
|
|
has never written a line of Lua and they can read the game out of it. When
|
|
you want the dragon to fork three ways, you add a field. When you want
|
|
another scene, you add another record. You do not go looking for the code
|
|
that plays scenes, because you do not change it.
|
|
|
|
The second is that the records are not all the same shape. `pit` has no
|
|
`ask`, no `prompt`, and no `left` -- there is nothing to choose in it.
|
|
`escape` has no `goesTo`, because it is the end and goes nowhere. A field
|
|
that is not in a table reads as `nil`, which is the word you met in lesson
|
|
six for "nothing is there", and asking for `scenes.escape.goesTo` is not an
|
|
error. It is `nil`, and `nil` is an answer. Code that reads these records has
|
|
to be ready for that, and in a minute you will see the one line that is.
|
|
|
|
The third is what the table does *not* contain: nothing in it draws, plays,
|
|
or waits. It is a description. That separation is the entire trick of this
|
|
lesson.
|
|
|
|
=== One Function That Plays Any Scene
|
|
|
|
This is the code half, and there is only one function of any size. Written in
|
|
the threaded model from lesson fifteen, because a branching game is the most
|
|
sequential thing there is.
|
|
|
|
[source,lua]
|
|
----
|
|
local function playScene(name)
|
|
local scene = scenes[name]
|
|
|
|
discSkipToFrame(scene.first)
|
|
while discGetFrame() < scene.last do
|
|
local frame = discGetFrame()
|
|
local asking = scene.ask ~= nil and frame >= scene.ask
|
|
|
|
if not asking then
|
|
answer = nil
|
|
elseif answer == "left" or answer == "right" then
|
|
return scene[answer]
|
|
end
|
|
drawScene(scene, frame, asking)
|
|
singeYield()
|
|
end
|
|
discPause()
|
|
|
|
if scene.death then
|
|
lives = lives - 1
|
|
if lives == 0 then
|
|
ending = "The dragon wins. Game over."
|
|
return nil
|
|
end
|
|
waitForGo("Press the space bar to try again.")
|
|
end
|
|
|
|
return scene.goesTo
|
|
end
|
|
----
|
|
|
|
It takes the name of a scene, plays it, and returns the name of the scene
|
|
that comes next. That is its whole contract, and the game is then three
|
|
lines:
|
|
|
|
[source,lua]
|
|
----
|
|
function singeMain()
|
|
local scene = "arrive"
|
|
|
|
while scene ~= nil do
|
|
scene = playScene(scene)
|
|
end
|
|
waitForGo(ending .. " Space to quit.")
|
|
end
|
|
----
|
|
|
|
Start at `arrive`. Play whatever scene you are holding, and hold whatever it
|
|
hands back. When it hands back `nil` there is no next scene, the loop ends,
|
|
and the game is over.
|
|
|
|
=== The Whole Thing
|
|
|
|
Make `dragon.singe` in your `movie` folder and type this in. It is the
|
|
longest script in the book so far, and every piece of it has been explained
|
|
except the two small helpers, which come after.
|
|
|
|
[source,lua]
|
|
----
|
|
local FPS = 30
|
|
|
|
local scenes = {
|
|
arrive = {
|
|
first = 0,
|
|
last = 119,
|
|
ask = 30,
|
|
prompt = "The dragon lands. Left or Right?",
|
|
left = "sunset",
|
|
right = "pit",
|
|
goesTo = "pit"
|
|
},
|
|
sunset = {
|
|
first = 120,
|
|
last = 259,
|
|
ask = 170,
|
|
prompt = "It breathes fire. Duck Left or run Right?",
|
|
left = "escape",
|
|
right = "pit",
|
|
goesTo = "pit"
|
|
},
|
|
pit = {
|
|
first = 260,
|
|
last = 339,
|
|
death = true,
|
|
goesTo = "arrive"
|
|
},
|
|
escape = {
|
|
first = 340,
|
|
last = 410
|
|
}
|
|
}
|
|
|
|
local answer = nil
|
|
local ending = "You got out alive."
|
|
local lives = 3
|
|
|
|
|
|
local function drawScene(scene, frame, asking)
|
|
overlayClear()
|
|
overlayPrint(2, 2, "Lives: " .. lives)
|
|
if scene.death then
|
|
overlayPrint(2, 4, "That did not go well.")
|
|
elseif asking then
|
|
overlayPrint(2, 4, scene.prompt)
|
|
overlayPrint(2, 6, math.ceil((scene.last - frame) / FPS) .. " seconds left.")
|
|
end
|
|
end
|
|
|
|
|
|
local function waitForGo(text)
|
|
answer = nil
|
|
while answer ~= "go" do
|
|
overlayClear()
|
|
overlayPrint(2, 2, "Lives: " .. lives)
|
|
overlayPrint(2, 4, text)
|
|
singeYield()
|
|
end
|
|
end
|
|
|
|
|
|
local function playScene(name)
|
|
local scene = scenes[name]
|
|
|
|
discSkipToFrame(scene.first)
|
|
while discGetFrame() < scene.last do
|
|
local frame = discGetFrame()
|
|
local asking = scene.ask ~= nil and frame >= scene.ask
|
|
|
|
if not asking then
|
|
answer = nil
|
|
elseif answer == "left" or answer == "right" then
|
|
return scene[answer]
|
|
end
|
|
drawScene(scene, frame, asking)
|
|
singeYield()
|
|
end
|
|
discPause()
|
|
|
|
if scene.death then
|
|
lives = lives - 1
|
|
if lives == 0 then
|
|
ending = "The dragon wins. Game over."
|
|
return nil
|
|
end
|
|
waitForGo("Press the space bar to try again.")
|
|
end
|
|
|
|
return scene.goesTo
|
|
end
|
|
|
|
|
|
function onInputPressed(what)
|
|
if what == SWITCH_LEFT then
|
|
answer = "left"
|
|
elseif what == SWITCH_RIGHT then
|
|
answer = "right"
|
|
elseif what == SWITCH_BUTTON1 then
|
|
answer = "go"
|
|
end
|
|
end
|
|
|
|
|
|
function singeMain()
|
|
local scene = "arrive"
|
|
|
|
while scene ~= nil do
|
|
scene = playScene(scene)
|
|
end
|
|
waitForGo(ending .. " Space to quit.")
|
|
end
|
|
|
|
|
|
dofile("Singe/Framework.singe")
|
|
----
|
|
|
|
Run it:
|
|
|
|
----
|
|
Singe -R -v Singe/menuBackground.mkv dragon
|
|
----
|
|
|
|
Play it four or five times. Go left twice and get out. Go right and get
|
|
eaten. Sit on your hands and get eaten anyway. Lose all three lives.
|
|
|
|
=== What Just Happened
|
|
|
|
[source,lua]
|
|
----
|
|
discSkipToFrame(scene.first)
|
|
while discGetFrame() < scene.last do
|
|
----
|
|
|
|
A scene is a jump followed by a wait, which is the pattern from lesson
|
|
fifteen with the numbers coming out of the record instead of being typed in.
|
|
`discSkipToFrame` goes to a frame and plays from it, which is what a scene
|
|
change is.
|
|
|
|
The frame number is right the instant the call returns, so the `while` never
|
|
thinks it is still in the scene you just left.
|
|
|
|
[source,lua]
|
|
----
|
|
local asking = scene.ask ~= nil and frame >= scene.ask
|
|
----
|
|
|
|
The one line that is ready for a missing field, and the reason the death
|
|
scene can leave `ask` out.
|
|
|
|
`and` means both halves have to be true, as it did in lesson three, and Lua
|
|
checks the left half first. If `scene.ask` is `nil` the left half is false,
|
|
Lua stops there, and the comparison on the right never happens. Which is just
|
|
as well, because comparing `nil` to a number is an error and you will meet it
|
|
at the end of this lesson.
|
|
|
|
`asking` is the answer to "is the question on screen right now?", worked out
|
|
once a frame and used three times.
|
|
|
|
[source,lua]
|
|
----
|
|
if not asking then
|
|
answer = nil
|
|
elseif answer == "left" or answer == "right" then
|
|
return scene[answer]
|
|
end
|
|
----
|
|
|
|
While the question is not up, anything the player presses is thrown away. It
|
|
has to be, or a player leaning on the left arrow during the film answers a
|
|
question they have not read.
|
|
|
|
Once the question is up, an answer ends the scene at once. `return
|
|
scene[answer]` is worth a second look: `answer` holds the string `"left"` or
|
|
`"right"`, and `scene["left"]` is the same thing as `scene.left`. Putting the
|
|
name in brackets instead of after a dot lets you look up a field whose name
|
|
you are holding in a variable. Two lines of code for both answers, and for
|
|
five answers it would still be two lines.
|
|
|
|
The `elseif` also explains why only `"left"` and `"right"` are allowed
|
|
through. The space bar sets `answer` to `"go"`, and `scene["go"]` is `nil`,
|
|
so a stray space bar during a question would end the game in silence. Say
|
|
what you accept.
|
|
|
|
[source,lua]
|
|
----
|
|
discPause()
|
|
----
|
|
|
|
Right after the loop, and easy to forget. The scene has reached its last
|
|
frame but the disc has no idea the scene is over -- it plays straight on into
|
|
whatever footage happens to sit next in the file, which in a real game is the
|
|
middle of a scene from somewhere else entirely. Stop it the moment you stop
|
|
wanting it.
|
|
|
|
[source,lua]
|
|
----
|
|
if scene.death then
|
|
lives = lives - 1
|
|
----
|
|
|
|
`scene.death` is `nil` for every scene except `pit`, and `nil` counts as
|
|
false in an `if`, so this reads as "if this scene was a death". You do not
|
|
have to write `death = false` in the other three records, and you should not:
|
|
the absence of the field says it just as well, and there is then only one
|
|
place that decides which scenes are deadly.
|
|
|
|
The retry is the two lines under it. Take a life, and if there are any left,
|
|
hold on the frame and wait for the space bar. Then `return scene.goesTo`
|
|
sends the player back to `arrive`, and the record is what knows that. Change
|
|
`goesTo` to `"sunset"` and the retry becomes a checkpoint instead of a
|
|
restart -- one word, and the game's difficulty changes.
|
|
|
|
[source,lua]
|
|
----
|
|
overlayPrint(2, 6, math.ceil((scene.last - frame) / FPS) .. " seconds left.")
|
|
----
|
|
|
|
The timer. `scene.last - frame` is how many frames are left, and dividing by
|
|
the frames in a second turns that into seconds. `math.ceil` rounds a number
|
|
up to the next whole one, so the countdown reads `3`, `2`, `1` and never
|
|
`0.4` or `2.966666`.
|
|
|
|
`FPS` is `30` because this video runs at a little over thirty frames a
|
|
second. It is the one number in the script that is a guess, and it is a named
|
|
guess at the top of the file rather than a `30` buried in the middle of a
|
|
line, so that when you change videos there is exactly one thing to change.
|
|
|
|
Notice what the timer is measured against: the film. The player is racing the
|
|
video, not a clock, so the question can never outstay the shot it belongs to.
|
|
That is the reason branching games count in frames.
|
|
|
|
=== Why Not Write It Out by Hand
|
|
|
|
You could write this game as one long function. Play frames 0 to 119; if
|
|
left, play 120 to 259; if left again, play 340 to 410; else play 260 to 339,
|
|
take a life, go back to the top. For four scenes it would be shorter than
|
|
what you have.
|
|
|
|
For forty scenes it would be a disaster, and it is worth being precise about
|
|
why.
|
|
|
|
The frame numbers would be scattered through the code, each written once and
|
|
never named, so changing where a scene starts means finding every place it is
|
|
mentioned. The shape of the story would be buried in the shape of the `if`
|
|
statements, so nobody could see the game without reading all of it. And
|
|
adding a scene would mean editing the playing code, which is the code that
|
|
already works -- every new scene another chance to break an old one.
|
|
|
|
The version you wrote has one piece of code that plays scenes and one piece
|
|
of data that says what the scenes are. Adding a scene touches only the data.
|
|
That is worth the extra half hour it took, and it is the same idea underneath
|
|
the `games.dat` file from lesson thirteen: describe the thing, then write one
|
|
piece of code that reads the description.
|
|
|
|
=== Try It
|
|
|
|
. *Add an ending.* Give `escape` a question and a second ending scene that
|
|
reuses frames from somewhere in the film. Only the table changes.
|
|
. *Make it fair.* Lower `ask` in both scenes so the player has five seconds
|
|
instead of three, then raise it so they have one. Find the number you would
|
|
actually ship.
|
|
. *Move the checkpoint.* Change `pit`'s `goesTo` to `"sunset"` and play it.
|
|
Then think about what happens if a player dies in `arrive` -- and whether
|
|
the death scene should know where it came from rather than being told.
|
|
. *Count what the player has seen.* Add a table beside `scenes` that records
|
|
which scene names have been played, and show the count in the corner. Now
|
|
you have a game that can say "you have found three of the five endings".
|
|
. *Break the map.* Change `arrive`'s `left` to `"cave"`, a scene that does
|
|
not exist. Run it, go left, and read what Lua says. That error is the most
|
|
common one a data-driven game has, and knowing its shape saves an hour.
|
|
|
|
=== Break It on Purpose
|
|
|
|
Take the guard off the `asking` line, so it reads:
|
|
|
|
[source,lua]
|
|
----
|
|
local asking = frame >= scene.ask
|
|
----
|
|
|
|
Run it and go right. The `arrive` scene plays fine, because `arrive` has an
|
|
`ask`. Then the `pit` scene starts, which does not, and the game stops with
|
|
this:
|
|
|
|
----
|
|
Error executing function 'onOverlayUpdate': dragon.singe:68: attempt to compare nil with number
|
|
----
|
|
|
|
followed by a traceback -- a list of the functions that were running at the
|
|
moment it went wrong, innermost first.
|
|
|
|
Read it the way lesson one taught. The file and the line come first, and they
|
|
are exactly right: line 68 is the line you just changed. The complaint is the
|
|
rest: you asked Lua whether a number was greater than `nil`, and Lua has no
|
|
idea. `scene.ask` is `nil` because the `pit` record has no `ask` field, and a
|
|
field that is not there is `nil`.
|
|
|
|
One part of that message deserves an explanation, because it is a lie of
|
|
omission. The function named is `onOverlayUpdate`, which you did not write.
|
|
That is the framework's own, the one lesson fifteen said was installed to
|
|
drive your `singeMain`. The error came out through it, so that is the name
|
|
the engine had to hand. Ignore it. The file and the line are yours, and they
|
|
are the ones that are true.
|
|
|
|
=== What You Learned
|
|
|
|
* A branching game is a set of scenes, each of which names the scenes that
|
|
come after it.
|
|
* Keep the scenes as data -- a table of records -- and the code that plays
|
|
them stays one function however big the game gets.
|
|
* A record holds the name of the next scene, not the scene, and the name is
|
|
looked up when it is needed.
|
|
* A field that is not in a record is `nil`, which is not an error, and code
|
|
that reads records must expect it.
|
|
* `scene[answer]` looks up the field whose name is in a variable;
|
|
`scene.left` and `scene["left"]` are the same field.
|
|
* Put the timer on the video: a choice lasts from one frame to another, so it
|
|
can never outlast its own shot.
|
|
* Throw away anything the player pressed before the question appeared.
|
|
* Pause the disc the moment a scene ends, or it plays on into the next
|
|
scene's footage.
|
|
* A missing field standing for false -- `death` on three records out of four
|
|
-- keeps the description short and honest.
|
|
* Comparing `nil` with a number is an error, and `and` is how you avoid it.
|
|
|
|
=== Next Time
|
|
|
|
Your game asks left or right. The arcade machines this kind of game grew up
|
|
in asked something harder: point at the thing on screen and shoot it. Lesson
|
|
seventeen is about light guns -- where the player is aiming, what the game
|
|
does with that, and the calibration a real cabinet cannot do without.
|