482 lines
18 KiB
Text
482 lines
18 KiB
Text
== Lesson 14: Playing Video
|
|
|
|
image::learn/14-video.png[The finished lesson, 480]
|
|
|
|
Singe can play a film and run your game inside it. The picture becomes the
|
|
world, and the overlay you have been drawing on since lesson one is laid over
|
|
the top of it. In this lesson you will start a video, find out which frame is
|
|
showing, stop it, and jump to any moment you like.
|
|
|
|
Before any of that, one thing needs saying plainly, because it decides whether
|
|
you should read this part of the book at all.
|
|
|
|
=== A Game Plays Video Only If It Asks To
|
|
|
|
Singe is a game engine. Everything you built in parts one and two -- the
|
|
sprites, the sound, the collisions, the score, the game folder -- is the
|
|
whole of Singe, and none of it involves video. What part three adds is an
|
|
extra: a game may ask for a film to be playing behind it, and then the film
|
|
is the world instead of a black canvas.
|
|
|
|
Nothing else changes. `onOverlayUpdate` is still called sixty times a second.
|
|
`overlayPrint` still counts in character cells. Your sprites draw where they
|
|
drew before. A game with video and a game without it are written the same
|
|
way, with the same functions, and share the same book.
|
|
|
|
So if the game you want to make has no video in it, you may close this part
|
|
now and go to part four. Nothing in part four depends on anything in part
|
|
three, and the only lessons that need this one are the four that follow it.
|
|
|
|
Still here? Good.
|
|
|
|
=== You Already Have a Video
|
|
|
|
You do not need to find one, buy one, or make one. The first time you ran
|
|
Singe in lesson one, a folder called `Singe` appeared in your work folder.
|
|
Open it and look: among the files the engine unpacked there is
|
|
`menuBackground.mkv`. It is the backdrop the engine's own menu uses, it is
|
|
720 by 480 pixels, and it is 420 frames long -- about fourteen seconds.
|
|
|
|
That is the video for this lesson and the two after it.
|
|
|
|
Make a folder called `movie` next to your other work, and a file inside it
|
|
called `movie.singe`. Then run Singe once in that folder, the plain way, so
|
|
that the engine unpacks its `Singe` folder there too:
|
|
|
|
----
|
|
Singe movie
|
|
----
|
|
|
|
A black window opens and sits there doing nothing, because your script is
|
|
still empty and there is nothing for the engine to call. That is fine. Press
|
|
Escape, and look in the folder: the `Singe` folder is there now, with the
|
|
video inside it.
|
|
|
|
=== Type This In
|
|
|
|
[source,lua]
|
|
----
|
|
dofile("Singe/Framework.singe")
|
|
|
|
|
|
function onOverlayUpdate()
|
|
overlayClear()
|
|
overlayPrint(2, 2, "Frame " .. discGetFrame())
|
|
return OVERLAY_UPDATED
|
|
end
|
|
|
|
|
|
discPlay()
|
|
----
|
|
|
|
Save it.
|
|
|
|
=== Run It
|
|
|
|
The video is not part of your script, so you do not load it in your script.
|
|
You hand it to the engine on the command line, with `-v`:
|
|
|
|
----
|
|
Singe -R -v Singe/menuBackground.mkv movie
|
|
----
|
|
|
|
`-v` is short for `--framefile`, and it names the video the game is to play.
|
|
The name of your script comes last, as it always has. `-R` is the same `-R`
|
|
you have been using since lesson one, and it still restarts the game when you
|
|
save.
|
|
|
|
Asking is the whole point of `-v`. If you drop a video file next to your
|
|
script and say nothing, the engine notices it, says so on the console, and
|
|
runs your game without it. A game is a video game only when somebody says it
|
|
is.
|
|
|
|
A window opens. A ball of orange fire fills it, a dragon forms out of the
|
|
fire, the word `SINGE` appears under it, and then a magenta grid rolls away
|
|
under an orange sun. In the top left corner, over all of it, is a number
|
|
counting up.
|
|
|
|
Watch it to the end. Then watch what happens at the end.
|
|
|
|
=== What Just Happened
|
|
|
|
[source,lua]
|
|
----
|
|
discPlay()
|
|
----
|
|
|
|
Singe calls the video *the disc*, and every function that drives it begins
|
|
with `disc`. The name is historical: the engine began life playing games that
|
|
kept their film on a laserdisc, and the name stuck to the idea. It is a video
|
|
file on your hard drive and nothing more.
|
|
|
|
A game has at most one disc, so none of the `disc` functions takes an
|
|
argument saying which one. There is only the one.
|
|
|
|
This line is not inside any function. It sits at the bottom of the file, on
|
|
its own, and it runs once when Singe loads your script -- before the first
|
|
`onOverlayUpdate`, before anything else. Singe has no `onStartup` callback
|
|
because it does not need one: a Lua script *is* a list of instructions, and
|
|
anything you write outside a function happens as the file is read. Startup
|
|
code goes at the bottom, below the functions, by convention.
|
|
|
|
You need `discPlay()` because the disc does not start itself. When the game
|
|
begins, the engine parks the disc on frame one and pauses it there. Nothing
|
|
moves until you ask.
|
|
|
|
[source,lua]
|
|
----
|
|
overlayPrint(2, 2, "Frame " .. discGetFrame())
|
|
----
|
|
|
|
`discGetFrame` answers one question: which frame is showing right now? It
|
|
takes no arguments and hands back a number.
|
|
|
|
Notice where that number is drawn. Column 2, row 2 -- exactly where `Hello!`
|
|
went in lesson one, and it lands in the same place on screen. The overlay you
|
|
draw on is half the size of the video on each side, which for a 720 by 480
|
|
video is 360 by 240, which is exactly the size of the canvas you have been
|
|
drawing on for thirteen lessons. Your coordinates did not move. If you ever
|
|
play a video of some other size, the overlay changes size with it, and
|
|
`overlaySetResolution` sets it to whatever you want; the manual's entry has
|
|
the details.
|
|
|
|
This is the whole idea of the overlay. The video is underneath. Your drawing
|
|
is on top, and wherever you have not drawn, the film shows through. That is
|
|
why `overlayClear` matters even more now than it did: clear the overlay and
|
|
you are looking at the film.
|
|
|
|
=== A Frame Is an Address
|
|
|
|
The number `discGetFrame` returns is a *frame* number. A frame is one still
|
|
picture. This video holds 420 of them and shows about thirty every second, so
|
|
it lasts about fourteen seconds.
|
|
|
|
Frames are counted from zero, so this video has frames 0 to 419. The count
|
|
and the last number are not the same, which catches everybody once.
|
|
`discGetFrameCount` tells you the count -- 420 -- and the highest frame you
|
|
can ask for is one less than that.
|
|
|
|
The important thing about a frame number is that it never changes and never
|
|
drifts. Frame 110 is the moment the dragon breathes fire, in this copy of the
|
|
file, on this machine, today and next year. Seconds drift, because a video
|
|
does not always play at exactly the rate you think. A frame number is the
|
|
address of a moment, and addresses are what a video game is built out of: you
|
|
write down the frame where the dragon appears, and from then on you can send
|
|
the film there whenever you like.
|
|
|
|
Finding those numbers is a job you do once per video, and the script you
|
|
wrote is the tool for it: run the film, watch the counter, write down the
|
|
frames where things happen. Everything in lesson sixteen is built on a list
|
|
of numbers found exactly that way.
|
|
|
|
=== What Happened at the End
|
|
|
|
The counter reached 419, and then it was 0 again and the fire was back.
|
|
|
|
When the disc runs off the end, the engine starts it again from frame zero.
|
|
It does not stop, and it does not tell you. A real game never lets it get
|
|
there: it watches the frame number and sends the disc somewhere else before
|
|
the end arrives. Forgetting to do that is why a half finished video game
|
|
suddenly plays its own title sequence in the middle of a fight.
|
|
|
|
=== Stopping, Jumping, and Asking
|
|
|
|
Add this function above `onOverlayUpdate`:
|
|
|
|
[source,lua]
|
|
----
|
|
function onInputPressed(what)
|
|
if what == SWITCH_BUTTON1 then
|
|
if discGetState() == DISC_PLAYING then
|
|
discPause()
|
|
else
|
|
discPlay()
|
|
end
|
|
elseif what == SWITCH_LEFT then
|
|
discSearch(0)
|
|
elseif what == SWITCH_RIGHT then
|
|
discSkipForward(30)
|
|
elseif what == SWITCH_START1 then
|
|
discStop()
|
|
end
|
|
end
|
|
----
|
|
|
|
Save, and try each one. Space plays and pauses. Left goes back to the start.
|
|
Right jumps on about a second. The `1` key stops the disc altogether, and
|
|
what you get is a blue screen -- the colour a laserdisc player showed when it
|
|
had stopped, kept because a great many games drew their score table on it.
|
|
|
|
Five new calls there, and they divide into two kinds.
|
|
|
|
*Asking.* `discGetState` hands back one of four names the engine has already
|
|
defined for you: `DISC_PLAYING`, `DISC_PAUSED`, `DISC_STOPPED`, and
|
|
`DISC_EJECTED`. They are like `OVERLAY_UPDATED` -- no quotes, because they
|
|
are names for values rather than text. Compare with `==` as you compared
|
|
numbers in lesson three.
|
|
|
|
*Telling.* `discPlay` starts or resumes. `discPause` freezes on the frame
|
|
that is showing and leaves it on screen. `discStop` is the heavier one: the
|
|
picture is replaced by the blue screen, `discGetFrame` answers `0` until the
|
|
disc runs again, and `discPause` and the skip calls are ignored while it is
|
|
stopped. Any of `discPlay`, `discSearch`, or `discSkipToFrame` un-stops it.
|
|
|
|
Two calls send the disc to a frame, and the difference between them is the
|
|
only thing in this lesson worth memorising:
|
|
|
|
* `discSearch(frame)` goes there and *pauses* on it.
|
|
* `discSkipToFrame(frame)` goes there and *plays* from it.
|
|
|
|
Search when you want to park on a picture and wait. Skip when you want a
|
|
scene to start. `discSkipForward(30)` and `discSkipBackward(30)` are the same
|
|
idea measured from where you are rather than from the beginning, and they
|
|
leave the disc playing if it was playing and paused if it was paused.
|
|
|
|
There is also `discPauseAtFrame`, which does exactly what `discSearch` does
|
|
under an older name. The manual marks it as a legacy alias. You will see it
|
|
in other people's scripts; write `discSearch` in your own.
|
|
|
|
=== Waiting Is Harder Than It Sounds
|
|
|
|
Here is the sentence every video game is made of:
|
|
|
|
____
|
|
Play from frame 0 to frame 120, then ask the player a question.
|
|
____
|
|
|
|
Try to write it. You cannot write "play, then wait, then ask", because you do
|
|
not have anywhere to write it. You have `onOverlayUpdate`, which Singe calls
|
|
sixty times a second and which must return immediately every single time. A
|
|
function that sat and waited would stop the engine dead: no drawing, no
|
|
input, no video.
|
|
|
|
So you cannot say "then". You have to turn "then" into a variable that
|
|
remembers where you have got to, and check it on every one of those sixty
|
|
calls:
|
|
|
|
[source,lua]
|
|
----
|
|
local asking = false
|
|
|
|
|
|
function onOverlayUpdate()
|
|
overlayClear()
|
|
if not asking and discGetFrame() >= 120 then
|
|
discPause()
|
|
asking = true
|
|
end
|
|
if asking then
|
|
overlayPrint(2, 4, "Left or right?")
|
|
end
|
|
return OVERLAY_UPDATED
|
|
end
|
|
----
|
|
|
|
That works. Read it and you can see it works. But notice what it cost: one
|
|
variable, whose only job is to remember which part of the sentence you are
|
|
in, and two `if` statements to keep it honest.
|
|
|
|
Now add the rest of the scene. The player answers, so one of two things plays
|
|
next. Each of those ends somewhere and asks another question. A modest
|
|
branching game has forty of these moments, and every one of them wants its
|
|
own variable and its own `if`. You end up with a script where the story is
|
|
not written down anywhere -- it is spread across a dozen flags, and to find
|
|
out what happens after the dragon you have to read all of them.
|
|
|
|
Hold that thought. Lesson fifteen is nothing but the fix for it.
|
|
|
|
=== When There Is No Video
|
|
|
|
Run your script the plain way again, with no `-v`:
|
|
|
|
----
|
|
Singe -R movie
|
|
----
|
|
|
|
Nothing crashes. You get a black window with `Frame 0` in the corner.
|
|
|
|
This is worth understanding, because it is the reassuring half of the promise
|
|
at the top of this lesson. A game without a disc still runs every `disc` call
|
|
you make; they do nothing useful. The engine documents exactly what they
|
|
answer, and it never changes: `discGetFrame` and `discGetFrameCount`
|
|
return `0`, `discGetState` returns `DISC_EJECTED`, `discGetWidth` and
|
|
`discGetHeight` return the size of the canvas, and `discPlay`, `discPause`,
|
|
`discSearch`, and the rest are recorded in the log and otherwise ignored.
|
|
|
|
You cannot break a script by calling a disc function when there is no disc.
|
|
|
|
When a script needs to *know*, it asks `SINGE_DISC`. That is another name the
|
|
engine defines before your script runs, and it is `true` when the game has a
|
|
video and `false` when the world is a plain canvas. It is the right way to
|
|
write a game that can run both ways:
|
|
|
|
[source,lua]
|
|
----
|
|
if not SINGE_DISC then
|
|
overlayPrint(2, 2, "This game has no video.")
|
|
overlayPrint(2, 4, "Run it again with -v Singe/menuBackground.mkv")
|
|
return OVERLAY_UPDATED
|
|
end
|
|
----
|
|
|
|
`not` flips a true or false value over, as it did in lesson three.
|
|
|
|
=== The Whole Thing
|
|
|
|
Put together, with a small function that turns the state into words and a
|
|
frame to hold at, this is the finished script. It is a frame finder: the tool
|
|
you will use every time you meet a new video.
|
|
|
|
[source,lua]
|
|
----
|
|
dofile("Singe/Framework.singe")
|
|
|
|
local stopAt = 400
|
|
local held = false
|
|
|
|
|
|
local function stateName()
|
|
local state = discGetState()
|
|
|
|
if state == DISC_PLAYING then
|
|
return "playing"
|
|
elseif state == DISC_PAUSED then
|
|
return "paused"
|
|
elseif state == DISC_STOPPED then
|
|
return "stopped"
|
|
end
|
|
return "not there"
|
|
end
|
|
|
|
|
|
function onInputPressed(what)
|
|
if what == SWITCH_BUTTON1 then
|
|
if discGetState() == DISC_PLAYING then
|
|
discPause()
|
|
else
|
|
discPlay()
|
|
end
|
|
elseif what == SWITCH_LEFT then
|
|
discSearch(0)
|
|
held = false
|
|
elseif what == SWITCH_RIGHT then
|
|
discSkipForward(30)
|
|
elseif what == SWITCH_START1 then
|
|
discStop()
|
|
held = false
|
|
end
|
|
end
|
|
|
|
|
|
function onOverlayUpdate()
|
|
local frame = discGetFrame()
|
|
|
|
overlayClear()
|
|
if not SINGE_DISC then
|
|
overlayPrint(2, 2, "This game has no video.")
|
|
overlayPrint(2, 4, "Run it again with -v Singe/menuBackground.mkv")
|
|
return OVERLAY_UPDATED
|
|
end
|
|
|
|
if not held and frame >= stopAt then
|
|
discPause()
|
|
held = true
|
|
end
|
|
|
|
overlayPrint(2, 2, "Frame " .. frame .. " of " .. discGetFrameCount())
|
|
overlayPrint(2, 4, "The disc is " .. stateName() .. ".")
|
|
overlayPrint(2, 6, "Space plays and pauses. Left rewinds.")
|
|
overlayPrint(2, 7, "Right jumps on a second. 1 stops the disc.")
|
|
if held then
|
|
overlayPrint(2, 9, "Holding at frame " .. stopAt .. ".")
|
|
end
|
|
|
|
return OVERLAY_UPDATED
|
|
end
|
|
|
|
|
|
discPlay()
|
|
----
|
|
|
|
`held` is the same kind of variable as `asking` above, and it is here for a
|
|
reason you can test: take it out, leave `if frame >= stopAt then discPause()
|
|
end`, and then try to press space to carry on past frame 400. You cannot. The
|
|
disc resumes for a sixtieth of a second and the next `onOverlayUpdate` pauses
|
|
it again, forever. `held` is how you say "I have already done this once".
|
|
|
|
=== Try It
|
|
|
|
. *Find the dragon.* Run it, watch the counter, and write down the frame
|
|
where the dragon first appears, the frame where it breathes fire, and the
|
|
frame where the grid takes over the screen. You will want these three
|
|
numbers in lesson sixteen.
|
|
. *Step through it.* Change `discSkipForward(30)` to `discSkipForward(1)` and
|
|
hold the right arrow down. Now change `stopAt` to a frame in the middle of
|
|
the dragon and use `discSearch` and the arrow key to land on it exactly.
|
|
. *Make it loop.* Change the hold so that instead of pausing at frame 400 it
|
|
calls `discSkipToFrame(0)`. You have built an attract mode: a scene that
|
|
plays forever until somebody presses a button.
|
|
. *Ask for a frame that is not there.* Change the `SWITCH_RIGHT` line to
|
|
`discSkipToFrame(100000)`. It does not crash. Read the manual's entry for
|
|
`discSkipToFrame` and find the sentence that says why.
|
|
. *Play it without the video.* Run `Singe -R movie` and then press
|
|
every key. Nothing you press can break it.
|
|
|
|
=== Break It on Purpose
|
|
|
|
The mistake that costs the most time here is not a Lua mistake. Run this:
|
|
|
|
----
|
|
Singe -R -v menuBackground.mkv movie
|
|
----
|
|
|
|
The path is wrong -- the video is in the `Singe` folder, not beside your
|
|
script -- and Singe will not start. What it does instead is print its entire
|
|
list of command line options, which is alarming the first time, and then this
|
|
as the very last line:
|
|
|
|
----
|
|
Error: Unable to locate video.
|
|
----
|
|
|
|
A wall of options like that always means the same thing: the engine never got
|
|
as far as your game. Ignore the wall and read the last line.
|
|
|
|
That one is easy. The unhelpful version of it is running with no `-v` at all
|
|
and forgetting you did. There is no error, because a game without a video is
|
|
a perfectly good game. You get a black screen and a counter stuck on `0`, and
|
|
you go looking for the bug in your script for twenty minutes.
|
|
|
|
That is what the `SINGE_DISC` block in the finished script is for. Three
|
|
lines of it, and the game tells you what is wrong instead of sulking. Put a
|
|
check like that in every script you write that needs a video.
|
|
|
|
=== What You Learned
|
|
|
|
* A game plays video only when it asks to, and everything else in Singe works
|
|
the same either way.
|
|
* Singe calls the video the disc. A game has one, and the `disc` functions
|
|
take no handle because there is nothing to choose between.
|
|
* The video goes on the command line with `-v`, not in the script.
|
|
* The overlay is drawn over the picture, and the film shows through wherever
|
|
you have not drawn.
|
|
* A frame is one still picture, counted from zero, and a frame number is the
|
|
address of a moment.
|
|
* `discGetFrame` asks where the disc is; `discGetFrameCount` says how many
|
|
frames there are; `discGetState` says what the disc is doing.
|
|
* `discPlay`, `discPause`, and `discStop` drive it. `discSearch` goes to a
|
|
frame and pauses; `discSkipToFrame` goes to a frame and plays.
|
|
* A disc that runs off the end starts again at frame zero without telling
|
|
you.
|
|
* Without a disc every `disc` call is harmless, and `SINGE_DISC` is how a
|
|
script tells which kind of game it is in.
|
|
* You cannot wait inside `onOverlayUpdate`, so "then" has to be spelled out
|
|
as a variable.
|
|
|
|
=== Next Time
|
|
|
|
That last point is the sore one. A video game is a list of steps -- play
|
|
this, ask that, play the other -- and you have just seen that the callback
|
|
model cannot say "then" without a flag and an `if` for every step. Lesson
|
|
fifteen shows you the other way Singe lets you write a script, in which "then"
|
|
is a new line.
|