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

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.